Compare commits
618
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f875b1bc87 | ||
|
|
3388df1c63 | ||
|
|
d3584077d7 | ||
|
|
e49ba1ae71 | ||
|
|
2c46213257 | ||
|
|
77dc7d30a0 | ||
|
|
aa3c279313 | ||
|
|
51dc3df91b | ||
|
|
70e03887d4 | ||
|
|
6ac2bbb7cf | ||
|
|
5812276f46 | ||
|
|
74baac23a1 | ||
|
|
b31eaec78d | ||
|
|
34c5118e85 | ||
|
|
6e4ea0980f | ||
|
|
3ce0e001c1 | ||
|
|
077a5c3b30 | ||
|
|
f3567fba71 | ||
|
|
e7837cdc6b | ||
|
|
88e2f8e9e2 | ||
|
|
b131726497 | ||
|
|
86ec9733c0 | ||
|
|
8a60334978 | ||
|
|
76a6980244 | ||
|
|
fcbbc0bd9a | ||
|
|
31edcc3c5a | ||
|
|
305ccf0984 | ||
|
|
c2c1131079 | ||
|
|
24ea2505a5 | ||
|
|
566e94a31e | ||
|
|
5c68e4d9cc | ||
|
|
38e387bcd6 | ||
|
|
276ab52aed | ||
|
|
12435e6e54 | ||
|
|
1d8182bcfb | ||
|
|
4243676739 | ||
|
|
4324fa72f2 | ||
|
|
ef5462a4dc | ||
|
|
bdf3048773 | ||
|
|
e1da6ba356 | ||
|
|
ae309b5846 | ||
|
|
57e23d661d | ||
|
|
9ef26055fa | ||
|
|
d1bd518043 | ||
|
|
bac4fded0d | ||
|
|
82bfddc1c2 | ||
|
|
8cf23ee7cb | ||
|
|
5777ea36e9 | ||
|
|
6eceebd701 | ||
|
|
4b898e9011 | ||
|
|
cb14ae96b3 | ||
|
|
aeb4b2dcaa | ||
|
|
09f9289deb | ||
|
|
bbb14c24dc | ||
|
|
79f4be31d5 | ||
|
|
e2a1be5e93 | ||
|
|
69911543c3 | ||
|
|
d4eba7fa96 | ||
|
|
bbc2eb111c | ||
|
|
b58b8c51bf | ||
|
|
a7678472ab | ||
|
|
f413db06be | ||
|
|
3d67d83110 | ||
|
|
9de722ab34 | ||
|
|
df088573fb | ||
|
|
a0c64e5ef4 | ||
|
|
eebd6b2a1c | ||
|
|
181f030350 | ||
|
|
6140e503ec | ||
|
|
9539ae8e40 | ||
|
|
7a140e59c3 | ||
|
|
b1bfe6002d | ||
|
|
38832448d2 | ||
|
|
8e8e3a3ce8 | ||
|
|
3d4e43f4fc | ||
|
|
155ce984c9 | ||
|
|
4b93a1cb28 | ||
|
|
1d384b9189 | ||
|
|
9f4e748404 | ||
|
|
025287873d | ||
|
|
6ddda6f1bf | ||
|
|
e0dd800378 | ||
|
|
309c39a8e0 | ||
|
|
39d6caa24d | ||
|
|
0d4bd28a08 | ||
|
|
9264323b29 | ||
|
|
dde5049454 | ||
|
|
1744880648 | ||
|
|
246f4ebaf5 | ||
|
|
a3f5a83527 | ||
|
|
5d7a66d4a3 | ||
|
|
ec5f312fd6 | ||
|
|
3c81741e2e | ||
|
|
aebb75371b | ||
|
|
0a0634cfc2 | ||
|
|
46aa0fe16d | ||
|
|
78165393e0 | ||
|
|
931af6af59 | ||
|
|
f6a4e0d5ad | ||
|
|
44311d0160 | ||
|
|
afb25ae0e9 | ||
|
|
fb44601f21 | ||
|
|
83c64d31dd | ||
|
|
293c085634 | ||
|
|
f81d09fb95 | ||
|
|
f8258576ef | ||
|
|
e437cf4bbb | ||
|
|
cd1ba34d96 | ||
|
|
2cc410db6c | ||
|
|
f57c002ae7 | ||
|
|
495a3b256f | ||
|
|
4210334f55 | ||
|
|
06ec207d17 | ||
|
|
30caba5dcb | ||
|
|
b17634ef7d | ||
|
|
76316ef5f0 | ||
|
|
f5b1c2659f | ||
|
|
b4f0053bbe | ||
|
|
3593818a46 | ||
|
|
e0fd344a26 | ||
|
|
21bbf42ca7 | ||
|
|
edf1e58141 | ||
|
|
bd94c63de8 | ||
|
|
43b1936b65 | ||
|
|
9d8806927f | ||
|
|
dbe47d58fb | ||
|
|
8c2c94e0f1 | ||
|
|
b7b78f0c16 | ||
|
|
c38e988b14 | ||
|
|
ec7593dabb | ||
|
|
1df4cc95a6 | ||
|
|
66468c703f | ||
|
|
773e76a1c6 | ||
|
|
f4e39a219b | ||
|
|
c170a464e1 | ||
|
|
74ef426064 | ||
|
|
5ce391b49b | ||
|
|
4a14f4b1e3 | ||
|
|
451a6fffe7 | ||
|
|
d052c104d9 | ||
|
|
e4e653d3b3 | ||
|
|
6b85867309 | ||
|
|
fdf3e8cc3b | ||
|
|
a5902ca233 | ||
|
|
4665b42bbf | ||
|
|
0fb332c3f3 | ||
|
|
16273de554 | ||
|
|
28669ffeee | ||
|
|
fa5bc65d66 | ||
|
|
27b16b190f | ||
|
|
de1e1aaf6c | ||
|
|
20d1f7c614 | ||
|
|
233e0359e4 | ||
|
|
ad6837d694 | ||
|
|
49afa82d27 | ||
|
|
aabee99e3f | ||
|
|
3eda3b5ce6 | ||
|
|
49ab7a46f4 | ||
|
|
d500f2fe5f | ||
|
|
7849116a69 | ||
|
|
b082135e6e | ||
|
|
bcc9e27da0 | ||
|
|
cf64f2ad02 | ||
|
|
5ab46921c5 | ||
|
|
f8a031564a | ||
|
|
9ae0191270 | ||
|
|
2989c1db37 | ||
|
|
eaee7f1c6f | ||
|
|
211a5fa393 | ||
|
|
103b188458 | ||
|
|
8619d90119 | ||
|
|
156d95e6d1 | ||
|
|
9e3a083c27 | ||
|
|
af199e73e2 | ||
|
|
fd24619437 | ||
|
|
b1f5c67e9c | ||
|
|
037deefe67 | ||
|
|
42e764a7a8 | ||
|
|
3a0d814276 | ||
|
|
6337058d15 | ||
|
|
d38868c8aa | ||
|
|
06ed3aeeb3 | ||
|
|
de8040ecc2 | ||
|
|
96f406be6b | ||
|
|
b244df1634 | ||
|
|
f3ddbb2db0 | ||
|
|
0d99d18acc | ||
|
|
9377abc5f6 | ||
|
|
eb3f7328bb | ||
|
|
3c445ce73a | ||
|
|
fbdb5e8d4d | ||
|
|
e8ca78fe49 | ||
|
|
cc7e7bf0e0 | ||
|
|
b923f522d5 | ||
|
|
ef73257a69 | ||
|
|
461b9b773a | ||
|
|
28bc577e88 | ||
|
|
82dfee7291 | ||
|
|
bab7c8879b | ||
|
|
1d2edbaa9f | ||
|
|
25a5f5b7d2 | ||
|
|
82b17ced32 | ||
|
|
7945f44c9a | ||
|
|
0b43905697 | ||
|
|
28227b19c7 | ||
|
|
622632908e | ||
|
|
63dbf98cdf | ||
|
|
8034f0c2dc | ||
|
|
69cef74e1d | ||
|
|
2ec25ebb98 | ||
|
|
bccc9eb056 | ||
|
|
5b0e44290e | ||
|
|
4bd9f75231 | ||
|
|
8f8f64c391 | ||
|
|
ae9caa06af | ||
|
|
ee0c3507a5 | ||
|
|
72a9902254 | ||
|
|
5f337e0ce5 | ||
|
|
2274fd96f0 | ||
|
|
10fe258e4b | ||
|
|
22ab166c9b | ||
|
|
01a7b20026 | ||
|
|
d3e444cb56 | ||
|
|
6fdd17d3b6 | ||
|
|
5c2181a31d | ||
|
|
0148ad1800 | ||
|
|
956819663d | ||
|
|
670ab1fd5e | ||
|
|
b299400017 | ||
|
|
3afcabc41d | ||
|
|
4a4a1e0407 | ||
|
|
62541947e7 | ||
|
|
e38fd9ab93 | ||
|
|
fb33a0b9e0 | ||
|
|
e94c7063ed | ||
|
|
d6b51a04f4 | ||
|
|
cd3a09e153 | ||
|
|
2f925d29fd | ||
|
|
68483f05b2 | ||
|
|
730d3fa27f | ||
|
|
6eb3efa065 | ||
|
|
a43e75e8df | ||
|
|
783e1eeef9 | ||
|
|
596da1343e | ||
|
|
9e0a67f728 | ||
|
|
19f9285f8c | ||
|
|
c295027e84 | ||
|
|
3817ac230d | ||
|
|
75b54a9467 | ||
|
|
24437fc1a5 | ||
|
|
2aad96fa7b | ||
|
|
3fe2a7f5c0 | ||
|
|
851d491d2a | ||
|
|
b55f243780 | ||
|
|
e9850a2e49 | ||
|
|
12c7b6eb89 | ||
|
|
24fd2d2573 | ||
|
|
90d99d9bbe | ||
|
|
bc3d92ccaf | ||
|
|
61adc87987 | ||
|
|
257ac94fb1 | ||
|
|
9834a84888 | ||
|
|
8097943e3a | ||
|
|
a68c06540c | ||
|
|
44c5ef13b4 | ||
|
|
d5a9a72fa6 | ||
|
|
6f73e5187a | ||
|
|
b7918be6f3 | ||
|
|
a37f614be4 | ||
|
|
b70e109e89 | ||
|
|
b84b56d9f8 | ||
|
|
e66cd95c51 | ||
|
|
7e8b216e07 | ||
|
|
08cf334cc1 | ||
|
|
c515f7c1e5 | ||
|
|
12088f2d44 | ||
|
|
d9c74fbe96 | ||
|
|
3fc144a699 | ||
|
|
5501c9f1a5 | ||
|
|
8eae408e28 | ||
|
|
9f3df91584 | ||
|
|
37cccdbc0e | ||
|
|
1522653844 | ||
|
|
4d274c9da3 | ||
|
|
b6851e641e | ||
|
|
357f31376d | ||
|
|
7ffb48c9ae | ||
|
|
0f178f8382 | ||
|
|
a24fd542c0 | ||
|
|
910fb4a9b1 | ||
|
|
b8418b6a5f | ||
|
|
af71efd253 | ||
|
|
8c19b0048b | ||
|
|
a9751fa74d | ||
|
|
8a0bd34e13 | ||
|
|
5a160cefd8 | ||
|
|
a9c0e0409c | ||
|
|
9616441e54 | ||
|
|
eefe6f05e1 | ||
|
|
89feea7971 | ||
|
|
24b61b1c17 | ||
|
|
edbc8275b6 | ||
|
|
27afa806ca | ||
|
|
6598292b1b | ||
|
|
dea637228d | ||
|
|
a09467f1eb | ||
|
|
34feb348af | ||
|
|
08ee5dc573 | ||
|
|
6b1a1a6201 | ||
|
|
5c45bd57da | ||
|
|
349537e336 | ||
|
|
3af7adaad6 | ||
|
|
9f60cdeef1 | ||
|
|
258caa5906 | ||
|
|
a0d59a79aa | ||
|
|
c7148f3ebb | ||
|
|
ef29b48a55 | ||
|
|
5c33ea526e | ||
|
|
6d3b54b25f | ||
|
|
b6101bdbc3 | ||
|
|
b243b38d65 | ||
|
|
867bb44586 | ||
|
|
819355b31f | ||
|
|
538f2aba6f | ||
|
|
c3d4de52a7 | ||
|
|
4bbd42cc25 | ||
|
|
5004ed8ae6 | ||
|
|
fd0c3350f3 | ||
|
|
2cc24de505 | ||
|
|
0e14f69aae | ||
|
|
9f14035483 | ||
|
|
a5672152a7 | ||
|
|
2d38abe681 | ||
|
|
f478b7d3e1 | ||
|
|
d84c73e8b2 | ||
|
|
6575301a3a | ||
|
|
2359de69fa | ||
|
|
8daf5c71cd | ||
|
|
b62f6c0c02 | ||
|
|
43121f1d8f | ||
|
|
25b5e0b9fa | ||
|
|
03bda02e04 | ||
|
|
cd02fe71e6 | ||
|
|
431f64be85 | ||
|
|
93a2071837 | ||
|
|
4da1e52b08 | ||
|
|
409ee0cb84 | ||
|
|
7cf7598ef2 | ||
|
|
9b220d0ee6 | ||
|
|
d7ae59753d | ||
|
|
72b2af1d1d | ||
|
|
bd41da8caf | ||
|
|
cc0e179a8d | ||
|
|
e0f66384e2 | ||
|
|
2dd067562e | ||
|
|
d979d86fa3 | ||
|
|
33730337f3 | ||
|
|
6be12655dc | ||
|
|
cf3bcb380f | ||
|
|
89e8bd7015 | ||
|
|
64e1dcc150 | ||
|
|
f3ecac1ad1 | ||
|
|
b150911da9 | ||
|
|
e02e4a666d | ||
|
|
46ae1c50fe | ||
|
|
37d6a4ea2e | ||
|
|
20c8e0dfec | ||
|
|
5de36d783a | ||
|
|
cde050f146 | ||
|
|
2bc4ded969 | ||
|
|
468f9200f6 | ||
|
|
e02410dae6 | ||
|
|
74d31566ff | ||
|
|
6765a74d83 | ||
|
|
13e5914638 | ||
|
|
8fcc6baee0 | ||
|
|
6774d423b7 | ||
|
|
8156528c05 | ||
|
|
6f61183732 | ||
|
|
332b74e8ce | ||
|
|
e5a60386c6 | ||
|
|
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 | ||
|
|
1ee37ec4c2 | ||
|
|
2c7d0f3ac4 | ||
|
|
5a345228eb | ||
|
|
06b0a9eef3 | ||
|
|
ea7c66b190 | ||
|
|
83f72377a7 | ||
|
|
b2b6153424 | ||
|
|
59efb4facb | ||
|
|
3605d4f450 | ||
|
|
f34b7741d4 | ||
|
|
2c573ec892 | ||
|
|
50712d3305 | ||
|
|
7e27fa384a | ||
|
|
3027337a34 | ||
|
|
8bf6c15fad | ||
|
|
d0ad33034e | ||
|
|
f79a6fc707 | ||
|
|
5f13876e6e | ||
|
|
5a09204bf5 | ||
|
|
0d11b2be45 | ||
|
|
4bb8c8b586 | ||
|
|
48e522ec00 | ||
|
|
effe7c829e | ||
|
|
0e0007d7b7 | ||
|
|
176b217139 | ||
|
|
7c76dfea4b | ||
|
|
c803cf7eeb | ||
|
|
5c8fd0a48c | ||
|
|
7490bb95c5 | ||
|
|
832d3ab886 | ||
|
|
47f303d3fc | ||
|
|
1199240a36 | ||
|
|
b186e59a01 | ||
|
|
a92f54df3d | ||
|
|
354d39a6f1 | ||
|
|
307c9e797b | ||
|
|
116117e987 | ||
|
|
07ce090eeb | ||
|
|
201949aec1 | ||
|
|
8eeabd1419 | ||
|
|
6035d43899 | ||
|
|
1f127881c9 | ||
|
|
6704eda83a | ||
|
|
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 | ||
|
|
85cb41423b | ||
|
|
b9e7ff8c55 | ||
|
|
02ccd9d134 | ||
|
|
0353215b1d | ||
|
|
b94e21331d | ||
|
|
637a6f2f68 | ||
|
|
ae9ba333a0 | ||
|
|
6dbc8399df | ||
|
|
24cdb85d71 | ||
|
|
a1f8ecb7f0 | ||
|
|
a34ca5a137 | ||
|
|
7887b9473f | ||
|
|
fd3c82aa91 | ||
|
|
9266632694 | ||
|
|
b0cbcd7a04 | ||
|
|
6251117bd0 | ||
|
|
566803c431 | ||
|
|
d6848da60b | ||
|
|
53169a41af | ||
|
|
e90d5bac68 | ||
|
|
d725bdbaa3 | ||
|
|
d75b744a35 | ||
|
|
9aea8c0b5c | ||
|
|
7134f33e1d | ||
|
|
f54b964d62 | ||
|
|
88f7ea5679 | ||
|
|
11fbeb4851 | ||
|
|
2b1f38edae | ||
|
|
c0b562cc07 | ||
|
|
37fbb110e3 | ||
|
|
7564496ea0 | ||
|
|
a0b0d98180 | ||
|
|
b742343937 | ||
|
|
657df05d40 | ||
|
|
c52f2b6e05 | ||
|
|
86f2b14449 | ||
|
|
86014c97cf | ||
|
|
70d58648c8 | ||
|
|
94f3bba504 | ||
|
|
339eaa4b1b | ||
|
|
96774e6e23 | ||
|
|
665b79d8da | ||
|
|
32e7a80889 | ||
|
|
5e6b588b5e | ||
|
|
c1d90672a8 | ||
|
|
3d2d287723 | ||
|
|
969292a8d7 | ||
|
|
ac610f2d24 | ||
|
|
98f45cfe23 | ||
|
|
b581426488 | ||
|
|
fbf97ce402 | ||
|
|
f5b029b1a5 | ||
|
|
75f92cf546 | ||
|
|
6d485f47eb | ||
|
|
6651a162c5 | ||
|
|
a9aa39c3e2 | ||
|
|
b0cd419f28 | ||
|
|
aa487002b7 | ||
|
|
941bf01e30 | ||
|
|
b439c5fa09 | ||
|
|
67250a9da5 | ||
|
|
70243acb95 | ||
|
|
6cce7fdbd8 | ||
|
|
fae21ec9f2 | ||
|
|
779fd82a88 | ||
|
|
3e60b83881 | ||
|
|
66a676b6aa | ||
|
|
23bbbccb7e | ||
|
|
868d9ff2b0 | ||
|
|
cb2f240c0c | ||
|
|
d800060e06 | ||
|
|
8d8206f791 | ||
|
|
15c2bd50b8 | ||
|
|
711d557b93 | ||
|
|
6102e9fc68 | ||
|
|
fd61868393 | ||
|
|
ae426f37de | ||
|
|
4dbfaa4cef | ||
|
|
d5c8982c00 | ||
|
|
2ea5acc2a3 | ||
|
|
5e1bac51e5 | ||
|
|
f1e95d763d | ||
|
|
9140015c42 | ||
|
|
de418c5696 | ||
|
|
5ec5b9811c | ||
|
|
378eaa377e | ||
|
|
d4368be1c3 | ||
|
|
9733acfb5a | ||
|
|
b1d422dcfd | ||
|
|
1231e500a3 | ||
|
|
c508968814 | ||
|
|
402429ac2a | ||
|
|
65ce71b8d4 | ||
|
|
6bfcac4d54 | ||
|
|
f819566c63 | ||
|
|
10d9aa3058 | ||
|
|
a0ca1fdb28 | ||
|
|
10943629d6 | ||
|
|
5a93f80daa | ||
|
|
8260cf7f53 | ||
|
|
9fd38d9b9d | ||
|
|
58dfbc5b6e | ||
|
|
9fd9ae7a95 | ||
|
|
5b0198b2ec | ||
|
|
dc854acb9b | ||
|
|
d054e3dc01 | ||
|
|
95536c3e97 | ||
|
|
1bfedbd4d2 | ||
|
|
cfaf161fc7 | ||
|
|
703406df13 | ||
|
|
753d538140 | ||
|
|
f890dda7e7 | ||
|
|
c870d2ebdc |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Release fragments
|
||||
|
||||
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
|
||||
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
|
||||
并行 PR 争用同一文件。
|
||||
|
||||
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
|
||||
`1234-chat-reply-mentions.md`。文件名必须匹配
|
||||
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
|
||||
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
|
||||
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
|
||||
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
|
||||
严格如下:
|
||||
|
||||
```markdown
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat reply mentions** (#1234) — supports mentioning selected members.
|
||||
```
|
||||
|
||||
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
|
||||
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
|
||||
|
||||
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
|
||||
将未归档 fragments 汇总为唯一的版本章节,并移动到
|
||||
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
|
||||
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
|
||||
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
|
||||
例外是否成立。
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
|
||||
@@ -19,8 +19,10 @@ 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 in-place `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
|
||||
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
|
||||
- [ ] Release-seal validation (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] 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
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# /eval 自助触发允许名单
|
||||
#
|
||||
# 名单内的 GitHub 登录名可对【自己创建的 PR】触发 /eval 评测;
|
||||
# 对任意 PR 触发仍需仓库 write/maintain/admin 权限(维护者背书)。
|
||||
# 授权读取的始终是默认分支上的本文件,PR 无法修改自身授权。
|
||||
#
|
||||
# 变更本文件必须走 PR 评审。每行一个 GitHub login,# 开头为注释。
|
||||
|
||||
aftersss
|
||||
notable-open
|
||||
EdgarWang0925
|
||||
ayunya
|
||||
yutongshe
|
||||
qingyang1014
|
||||
caiTriumph
|
||||
xlb1130
|
||||
Anonymity-0
|
||||
FuShu-Yang
|
||||
guimingyue
|
||||
AlwaysLee
|
||||
TaoJikun
|
||||
zengyoulingzyl-stack
|
||||
liyuan333
|
||||
huangyoo
|
||||
lifeihong
|
||||
nitonitori
|
||||
cywan1998
|
||||
gangwn
|
||||
junlonghuo2
|
||||
aqruan
|
||||
Freda0909
|
||||
ShawnWhite777
|
||||
PeterGuy326
|
||||
abucraft
|
||||
pengzhihan47-star
|
||||
rainyak8
|
||||
gongrongyun
|
||||
huangyuanzhuo-coder
|
||||
ybcstudy
|
||||
bigqy
|
||||
liwang-ai
|
||||
meng93
|
||||
wxianfeng
|
||||
Patrick-Star-CN
|
||||
rossluo28-hz
|
||||
dxy704330469
|
||||
gtezg30062
|
||||
Neige-Premaire
|
||||
zhuoyu20
|
||||
avicii-chen
|
||||
typefield
|
||||
Haofeng0705
|
||||
Huwenjiao
|
||||
liuzeyang
|
||||
maoqxxmm
|
||||
FloralTide
|
||||
lingyun9833
|
||||
dxb121
|
||||
C0922
|
||||
xiaoji121
|
||||
H3java
|
||||
@@ -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;
|
||||
});
|
||||
+41
-12
@@ -111,6 +111,7 @@ jobs:
|
||||
filename.startsWith('internal/app/') ||
|
||||
filename.startsWith('internal/cli/') ||
|
||||
filename.startsWith('internal/cobracmd/') ||
|
||||
filename.startsWith('internal/corecmd/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/i18n/') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
@@ -147,12 +148,12 @@ jobs:
|
||||
filename === '.github/actionlint.yaml' ||
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/app/upgrade') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
filename.startsWith('internal/recovery/') ||
|
||||
filename.startsWith('internal/syncdata/') ||
|
||||
filename.includes('/testdata/') ||
|
||||
filename.startsWith('testdata/') ||
|
||||
@@ -433,6 +434,10 @@ jobs:
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
- name: Test reviewer routing policy
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
test-focused:
|
||||
name: Test (changed packages)
|
||||
needs: lint
|
||||
@@ -1176,7 +1181,7 @@ jobs:
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
COVERAGE_TARGET: "100"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0.1"
|
||||
run: |
|
||||
policy_profile=coverage-policy.txt
|
||||
if [ "$FULL_SUITE" != true ]; then
|
||||
@@ -1283,6 +1288,12 @@ jobs:
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
"$mode" "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Validate release fragment lifecycle
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Validate trusted main CHANGELOG-only push
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
|
||||
env:
|
||||
@@ -1366,29 +1377,47 @@ jobs:
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
candidate_ref="$(git rev-parse 'HEAD^{commit}')"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then
|
||||
echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
|
||||
. ./scripts/release/release-lib.sh
|
||||
stable_ref=""
|
||||
for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do
|
||||
release_is_stable_version "$tag" || continue
|
||||
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
|
||||
continue
|
||||
fi
|
||||
stable_ref="$tag"
|
||||
break
|
||||
done
|
||||
if [ -z "$stable_ref" ]; then
|
||||
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
|
||||
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
printf '%s\n' \
|
||||
"COMPATIBILITY_BASE_REF=$base_ref" \
|
||||
"COMPATIBILITY_STABLE_REF=$stable_ref" \
|
||||
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
- name: Check historical commands, help, and complete CLI compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF" \
|
||||
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
|
||||
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
run: |
|
||||
make schema-compatibility \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF" \
|
||||
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
|
||||
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
name: PR Eval Dispatch
|
||||
|
||||
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 生成可验证的评测请求,报告由 bot 回贴。
|
||||
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
|
||||
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
|
||||
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
|
||||
# 的当前 head(作者自背书,无第三方偷换窗口);受控评测执行端另以
|
||||
# FETCH_HEAD 校验兜底派发后的变更。
|
||||
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
|
||||
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
|
||||
# 触发通道:workflow 先创建占位评论,再上传与本次 run/comment 绑定的
|
||||
# 不可变 manifest artifact,最后把 artifact 指针写回同一评论。评论仅是
|
||||
# 不可信通知;受控评测服务必须验证成功 run、artifact 与 manifest,并在
|
||||
# 触发评测前原子占用 manifest.idempotency_key,重复占用只能 no-op。
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: eval-dispatch-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: Dispatch internal evaluation
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/eval')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out default branch tooling
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify commenter dispatch authorization
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt
|
||||
run: |
|
||||
# 不用 --fail:非协作者查权限返回 404 错误体,交由 guard 走名单分支;硬网络错误降级为空对象同样 fail-closed
|
||||
permission_json="$(curl --silent --show-error \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/collaborators/${COMMENTER}/permission")" || permission_json='{}'
|
||||
printf '%s' "$permission_json" | python3 scripts/ci/eval_dispatch_guard.py permission
|
||||
|
||||
- name: Parse /eval command
|
||||
id: parse
|
||||
continue-on-error: true
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: python3 scripts/ci/eval_comment_parse.py
|
||||
|
||||
- name: Reply usage on parse failure
|
||||
if: steps.parse.outcome == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PARSE_ERROR: ${{ steps.parse.outputs.error }}
|
||||
run: |
|
||||
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$body" \
|
||||
> /dev/null
|
||||
exit 1
|
||||
|
||||
- name: Verify reviewed PR head
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
EXPECTED_PR_NUMBER: ${{ github.event.issue.number }}
|
||||
REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
pr_json="$(curl --fail --silent --show-error \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")"
|
||||
printf '%s' "$pr_json" \
|
||||
| python3 scripts/ci/eval_dispatch_guard.py head \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create dispatch placeholder
|
||||
id: placeholder
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
|
||||
response="$(
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$placeholder_body"
|
||||
)"
|
||||
comment_id="$(
|
||||
printf '%s' "$response" \
|
||||
| jq -er \
|
||||
--arg issue_url "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}" \
|
||||
'select(.issue_url == $issue_url) | .id | tostring | select(test("^[1-9][0-9]*$"))'
|
||||
)"
|
||||
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build dispatch request manifest
|
||||
env:
|
||||
REPOSITORY_ID: '1187709537'
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
SOURCE_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
ACTOR_ID: ${{ github.event.comment.user.id }}
|
||||
ACTOR_LOGIN: ${{ github.event.comment.user.login }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
SOURCE_BODY: ${{ github.event.comment.body }}
|
||||
MANIFEST_PATH: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$REPOSITORY" != "DingTalk-Real-AI/dingtalk-workspace-cli" ]; then
|
||||
echo "unexpected repository: ${REPOSITORY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
for value in \
|
||||
"$REPOSITORY_ID" \
|
||||
"$WORKFLOW_ID" \
|
||||
"$RUN_ID" \
|
||||
"$RUN_ATTEMPT" \
|
||||
"$SOURCE_COMMENT_ID" \
|
||||
"$DISPATCH_COMMENT_ID" \
|
||||
"$ACTOR_ID" \
|
||||
"$PR_NUMBER"; do
|
||||
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "dispatch manifest contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "dispatch manifest contains an invalid PR head SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hash_output="$(printf '%s' "$SOURCE_BODY" | sha256sum)"
|
||||
source_body_sha256="${hash_output%% *}"
|
||||
if [[ ! "$source_body_sha256" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "failed to hash source comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
idempotency_key="${REPOSITORY_ID}:${SOURCE_COMMENT_ID}"
|
||||
|
||||
umask 077
|
||||
jq -n \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg repository "$REPOSITORY" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg source_comment_id "$SOURCE_COMMENT_ID" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg actor_id "$ACTOR_ID" \
|
||||
--arg actor_login "$ACTOR_LOGIN" \
|
||||
--arg pr_number "$PR_NUMBER" \
|
||||
--arg pr_head_sha "$PR_HEAD_SHA" \
|
||||
--arg products "$PRODUCTS" \
|
||||
--arg cases_ref "$CASES_REF" \
|
||||
--arg source_body_sha256 "$source_body_sha256" \
|
||||
--arg idempotency_key "$idempotency_key" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
repository: $repository,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
source_comment_id: $source_comment_id,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
actor_id: $actor_id,
|
||||
actor_login: $actor_login,
|
||||
pr_number: $pr_number,
|
||||
pr_head_sha: $pr_head_sha,
|
||||
products: $products,
|
||||
cases_ref: $cases_ref,
|
||||
source_body_sha256: $source_body_sha256,
|
||||
idempotency_key: $idempotency_key
|
||||
}' > "$MANIFEST_PATH"
|
||||
|
||||
- name: Upload dispatch request manifest
|
||||
id: artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}
|
||||
path: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
overwrite: false
|
||||
|
||||
- name: Finalize dispatch marker
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
REPOSITORY_ID: '1187709537'
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
|
||||
ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$DISPATCH_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || \
|
||||
[[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "artifact marker contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
artifact_digest="${ARTIFACT_DIGEST,,}"
|
||||
if [[ "$artifact_digest" != sha256:* ]]; then
|
||||
artifact_digest="sha256:${artifact_digest}"
|
||||
fi
|
||||
if [[ ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "artifact marker contains an invalid digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
marker_json="$(
|
||||
jq -nc \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg artifact_id "$ARTIFACT_ID" \
|
||||
--arg artifact_digest "$artifact_digest" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
artifact_id: $artifact_id,
|
||||
artifact_digest: $artifact_digest
|
||||
}'
|
||||
)"
|
||||
cases_note=""
|
||||
if [ -n "$CASES_REF" ]; then
|
||||
cases_note=",用例版本 \`${CASES_REF}\`"
|
||||
fi
|
||||
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
|
||||
response="$(
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$body"
|
||||
)"
|
||||
printf '%s' "$response" \
|
||||
| jq -e \
|
||||
--arg comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg body "$body" \
|
||||
'((.id | tostring) == $comment_id) and (.body == $body)' \
|
||||
> /dev/null
|
||||
|
||||
- name: Mark dispatch preparation failure
|
||||
if: ${{ failure() && steps.placeholder.outputs.comment_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
run: |
|
||||
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$failure_body" \
|
||||
> /dev/null \
|
||||
|| true
|
||||
@@ -2793,7 +2793,7 @@ jobs:
|
||||
fi
|
||||
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
|
||||
echo
|
||||
echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish."
|
||||
echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ jobs:
|
||||
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:
|
||||
@@ -29,12 +34,13 @@ jobs:
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const reviewerPool = [
|
||||
'sczheng189',
|
||||
'shangguanxuan633-lab',
|
||||
'audanye-sudo',
|
||||
'wxianfeng',
|
||||
];
|
||||
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({
|
||||
@@ -67,6 +73,21 @@ jobs:
|
||||
: 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 &&
|
||||
@@ -77,13 +98,9 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyRequested =
|
||||
(pullRequest.requested_reviewers || []).length > 0 ||
|
||||
(pullRequest.requested_teams || []).length > 0;
|
||||
if (alreadyRequested) {
|
||||
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
|
||||
return;
|
||||
}
|
||||
const existingRequestedReviewers = new Set(
|
||||
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
|
||||
);
|
||||
|
||||
let reviews;
|
||||
try {
|
||||
@@ -116,22 +133,18 @@ jobs:
|
||||
latestDecisionByLogin.set(login, review);
|
||||
}
|
||||
}
|
||||
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() ===
|
||||
review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
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()),
|
||||
);
|
||||
if (currentHeadDecision) {
|
||||
core.info(
|
||||
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
|
||||
try {
|
||||
@@ -178,20 +191,42 @@ jobs:
|
||||
tieOrder.get(left) - tieOrder.get(right),
|
||||
);
|
||||
|
||||
for (const reviewer of ranked) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
if (
|
||||
(currentPull.requested_reviewers || []).length > 0 ||
|
||||
(currentPull.requested_teams || []).length > 0
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
@@ -202,15 +237,23 @@ jobs:
|
||||
core.info(
|
||||
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
|
||||
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
|
||||
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() {
|
||||
|
||||
@@ -302,9 +302,8 @@ on the leaf:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
|
||||
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 declared `interface_ref` when CLI canonical ≠ MCP path
|
||||
@@ -321,8 +320,10 @@ Skill (evidence only)**.
|
||||
|
||||
Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
||||
for its tools.
|
||||
- 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).
|
||||
@@ -463,6 +464,134 @@ Keep CLI confirmation behavior and Schema metadata consistent, and add a
|
||||
semantic regression test through the final embedded loader/query delivery
|
||||
path; a generator unit test or JSON count alone is insufficient.
|
||||
|
||||
## Unified result Schema and performance
|
||||
|
||||
The unified runtime envelope and the per-command Schema result declaration are
|
||||
related but distinct contracts:
|
||||
|
||||
- Runtime owns the outer machine envelope (`ok`, `outcome`, `data`, `error`,
|
||||
`meta`) and derives it through `internal/output`. Business commands return a
|
||||
`CommandResult`; they must not hand-author the outer JSON shape.
|
||||
- A leaf `Contract.Result` / `contract.ResultSpec` describes the reviewed
|
||||
business value inside `data`. It may declare `outcomes`, `data_schema`, and
|
||||
`sensitive_paths`. `Contract.Pagination` is a separate command capability
|
||||
because pagination is emitted under envelope `meta`, not inside `data`.
|
||||
- `outcomes` is the set of results a command may produce; it is not the outcome
|
||||
of the current invocation. `data_schema` is a JSON Schema object for business
|
||||
data and must not duplicate the framework envelope.
|
||||
- Result declarations are delivered in the full leaf and in the reviewed
|
||||
`--compact` Agent projection. Compact retains the normalized `result` object
|
||||
verbatim but still omits provenance, interface bindings, and other audit-only
|
||||
fields. Product/group summaries remain navigation views and need not repeat
|
||||
every leaf Result. When an Agent needs return-shape facts, query the compact
|
||||
leaf directly; do not load the whole full Catalog.
|
||||
- A missing `result` means “no reviewed return-value declaration is published
|
||||
for this leaf.” It does **not** prove that the runtime is legacy, and it must
|
||||
not be filled by inference from examples, MCP samples, or previous command
|
||||
output. Runtime rollout remains an internal per-command fact.
|
||||
- The public contract has no `contract_version`, no `--output-contract`, and no
|
||||
Agent-selectable protocol alias. Agents continue to request machine output
|
||||
with `--format json`; migrated commands use the unified result directly and
|
||||
unmigrated commands retain their current legacy output.
|
||||
- Existing `dev` / `devapp` pilot coverage is gradual. Active reviewed
|
||||
`devapp` shortcuts are gated on a non-empty Result declaration, while `dev`
|
||||
currently has representative Result coverage. Do not describe that as
|
||||
repository-wide coverage. Any newly activated Agent-visible command should
|
||||
add and test its Result declaration; the remaining pilot gaps should shrink,
|
||||
not expand.
|
||||
|
||||
The compact/full leaf `result` object has one stable shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"outcomes": ["success", "pending", "partial_failure", "failure"],
|
||||
"data_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Stable resource ID"},
|
||||
"name": {"type": "string", "description": "Display name"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensitive_paths": ["credential.secret"]
|
||||
},
|
||||
"pagination": {
|
||||
"kind": "cursor",
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": "meta.pagination",
|
||||
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
|
||||
"next_token_path": "meta.pagination.next_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
| Field | Required | Contract |
|
||||
|---|---|---|
|
||||
| `outcomes` | yes | Non-empty unique subset of `success`, `pending`, `partial_failure`, `failure`; normalization publishes canonical order. |
|
||||
| `data_schema` | yes | One recursive JSON Schema **object** describing only the runtime envelope's `data` value. Every named `properties` child must have a non-empty `description`. It must not duplicate `ok`, `outcome`, `error`, or `meta`. |
|
||||
| `sensitive_paths` | no | Unique safe dot paths relative to `data`; renderers/redaction consumers must not treat them as shell/JQ expressions. |
|
||||
|
||||
Optional members are omitted, never emitted as `null`. A leaf without a
|
||||
reviewed Result omits the entire `result` key. Compact must preserve the same
|
||||
normalized Result value as the full leaf; it must not summarize, infer, rename,
|
||||
or independently rebuild any Result field. Product/group summaries do not
|
||||
aggregate child Result objects.
|
||||
|
||||
`pagination` is a sibling of `result`, not a child. It declares the canonical
|
||||
CLI cursor parameter and the fixed framework paths under `meta.pagination`.
|
||||
Product response fields used to derive that metadata remain mapper internals;
|
||||
they are not part of `result.data_schema`. Do not execute a second request to
|
||||
derive pagination metadata.
|
||||
|
||||
Invalid result declarations fail closed during normalization: unknown or
|
||||
duplicate outcomes, a non-object/multiple `data_schema`, unsafe or duplicate
|
||||
sensitive paths, unsupported pagination kinds, attempts to override framework
|
||||
meta paths, and an invalid cursor parameter must be rejected rather than
|
||||
silently removed.
|
||||
Full-leaf wire round trips must
|
||||
preserve the normalized Result exactly. Do not commit generated Schema JSON as
|
||||
evidence; tests construct contracts in Go and runtime/CI assemble the Catalog
|
||||
from declarations.
|
||||
|
||||
### Performance model and rules
|
||||
|
||||
- Catalog construction is declaration-driven and cached through the existing
|
||||
lazy `sync.Once` delivery path. Do not reassemble or reopen annotations per
|
||||
command invocation, per leaf lookup, or per renderer.
|
||||
- Normalizing one Result declaration is linear in the size of that declaration.
|
||||
Full `schema --all` is linear in tools + parameters + Result schema bytes and
|
||||
is an audit/compatibility export, not the normal Agent discovery path.
|
||||
Overview → compact product/group → compact leaf remains the normal route;
|
||||
only the final leaf carries its Result declaration.
|
||||
- Constructing a `CommandResult` defensively clones result data and validates
|
||||
invariants; rendering is buffer-first and then writes once. Both CPU cost and
|
||||
transient memory are O(payload size), with roughly one additional in-memory
|
||||
rendered copy. This buys immutability and prevents partial JSON leakage, but
|
||||
it is not free.
|
||||
- Large list/search commands must use bounded pages and publish continuation
|
||||
facts. The current emitter buffers one command result/page before publishing;
|
||||
pagination is the memory bound. Continuous event streams are a separate,
|
||||
command-specific protocol and are not described by `ResultSpec`.
|
||||
- A `dual_validate` command must execute the business request exactly once,
|
||||
validate a shadow unified result, and preserve legacy bytes. Never obtain
|
||||
validation by issuing a second network or write request.
|
||||
- Filters and alternate formats are render-time work over the same in-memory
|
||||
result. They must not rerun the business operation or rebuild Schema.
|
||||
- Performance changes must preserve the one-result, buffer-first, fail-closed,
|
||||
and atomic `--output` guarantees. Do not trade correctness for a microbenchmark
|
||||
improvement. For a material hot-path change, benchmark representative small
|
||||
and page-sized payloads and report allocations/bytes as well as latency.
|
||||
|
||||
## Current Schema boundaries
|
||||
|
||||
- `schema list` remains a progressive overview. `schema --all` is the stable
|
||||
@@ -471,13 +600,16 @@ 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, safety/confirmation semantics, and any reviewed `result`
|
||||
contract. 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.
|
||||
|
||||
+224
-6
@@ -6,15 +6,231 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.58-beta.4] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-skill installation and upgrade** — fresh installs, `dws skill setup`,
|
||||
and `dws upgrade` now use the multi-skill layout by default. Existing mono
|
||||
installations migrate during upgrade; mono remains an explicit legacy option.
|
||||
- **Native streaming-card mentions** — `dws chat message send-card` now accepts
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards and forwards them to
|
||||
`create_and_send_card`, matching the existing shortcut behavior without
|
||||
changing single-chat card creation.
|
||||
- **Expanded Minutes workflows** — 27 public Minutes shortcuts now cover
|
||||
upload, download, export, recording, analysis, sharing, and recovery flows;
|
||||
every write command keeps an explicit confirmation requirement.
|
||||
- **Chat command discovery** — 30 existing typed Chat commands are now
|
||||
available in the runtime Schema and Agent catalog, with sensitive writes
|
||||
carrying their required confirmation metadata.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat read results** — typed commands and shortcuts now expose a consistent
|
||||
top-level `messages` list with stable `messageId` and `text` fields while
|
||||
retaining existing response envelopes and fields.
|
||||
- **Wiki feed results** — Wiki feed list output now formats time fields and
|
||||
trims excess fields. Its `--limit` default is 10 and maximum is 20.
|
||||
- **Developer command results** — the `dev` and selected `devapp` commands now
|
||||
use the unified result envelope for consistent success, pending, partial,
|
||||
and failure reporting.
|
||||
- **Evaluation dispatch hardening** — `/eval` now uses a verifiable polling
|
||||
relay instead of direct access from the hosted runner, binding the workflow,
|
||||
comment, PR head, parameters, and result provenance.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Streaming-card update acknowledgement** — accepts the pre-production
|
||||
`success: true` response from `update_streaming_card` as affirmative write
|
||||
evidence while preserving explicit negative, conflicting, and bizId-drift
|
||||
failures, so Agents do not repeat an update that the service already applied.
|
||||
- **Text input bounds** — literal input, stdin, and `@file` inputs now all
|
||||
enforce the same byte limit; file reads validate the opened descriptor and
|
||||
cannot exceed the limit after a path replacement or file growth.
|
||||
- **Evaluation PR comments** — restores `/eval` PR conversation comments with
|
||||
the least required pull-request write permission and actionable GitHub 403
|
||||
diagnostics.
|
||||
|
||||
## [1.0.58-beta.3] - 2026-08-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Aitable workflow execution and history** — adds `dws aitable workflow run` for confirmed asynchronous execution of scheduled or record-triggered workflows, plus `dws aitable workflow history` for status-, time-, and page-filtered execution records. The commands map directly to `aitable/run_workflow` and `aitable/get_flow_record_list`, validate trigger-specific arguments locally, and document the `executionId` / `instanceId` correlation.
|
||||
- **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.
|
||||
- **Personal OA approval events** — personal event consumers now support task
|
||||
creation, completion, redirection, instance start, termination, and
|
||||
completion events, with typed output and matching usage documentation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Machine-readable export and download receipts** — `dws doc export`,
|
||||
`dws drive download`, and `dws drive download --version` now keep progress
|
||||
logs on stderr under `--format json` and emit one JSON result on stdout after
|
||||
a successful local write. The result includes the saved path and byte size;
|
||||
document exports additionally report the node, requested format, job/task
|
||||
ID, and final status.
|
||||
- **IM search and card-write safety** — conversation-scoped search now fails
|
||||
closed when the target cannot be verified, and streaming-card updates require
|
||||
business evidence rather than a transport-only success response.
|
||||
- **Document shortcut reliability** — document write, readback verification,
|
||||
pagination, template/version discovery, export, media, and local-file
|
||||
workflows now preserve compatibility while rejecting ambiguous write results.
|
||||
- **Event runtime-token handoff** — personal `event consume`, `status`,
|
||||
`stop`, and `+listen-im` honor the root `--token` without falling back to a
|
||||
stale OAuth profile. Detached buses negotiate an owner-only, memory-only IPC
|
||||
credential channel; tokens are never placed in child argv, environment,
|
||||
profiles, logs, or run-state files.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Minutes `permission apply --policy` type** — `--policy` is now declared as
|
||||
an `int` flag and its required check uses `Flags().Changed`, matching the
|
||||
numeric-parameter convention. `--help` reports `int` instead of `string`;
|
||||
accepted values (2/3/4) and gateway behavior are unchanged.
|
||||
- **Minutes skill references** — document `permission apply` in both Minutes
|
||||
skill references: list it in the command trees, describe its policy values and
|
||||
how it differs from `permission add`, and add its intent routing.
|
||||
- **Chat paging guidance** — typed chat message commands now document
|
||||
`--page-all`, aggregate result shapes, and cursor behavior in CLI Help and
|
||||
Agent selection examples.
|
||||
- **Calendar skill parity** — mono and multi Calendar references are aligned to
|
||||
prevent documentation drift without changing CLI behavior.
|
||||
- **Release engineering** — CI now shards helper-package changes through the
|
||||
full race suite, widens a flaky stdio idempotency test budget, governs exact
|
||||
reviewed CLI/Schema type migrations, and lets authorized maintainers trigger
|
||||
internal MCP evaluation with a reviewed `/eval` PR comment.
|
||||
|
||||
## [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` 曾因键的书写形态错误两次静默失效,这里不重犯。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Event runtime-token handoff** — personal `event consume`, `status`, `stop`, and `+listen-im` now honor the existing root `--token` instead of falling back to a stale local OAuth profile. Detached personal-event buses negotiate the credential only after an additive capability handshake, receive and rotate it through owner-only local IPC, and keep it in memory; the token is never forwarded through child argv, environment variables, profiles, logs, or run-state files. Existing OAuth and multi-profile behavior is unchanged when `--token` is absent. A new client refuses to send a runtime token to an older bus and leaves its existing consumers and subscriptions untouched; the recovery message asks users to inspect `event status --as user`, preview `event stop --as user --all --dry-run`, and explicitly confirm `event stop --as user --all --yes` before retrying.
|
||||
|
||||
## [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.3` baseline.
|
||||
It includes reviewed document shortcuts and chat reply mentions, together with
|
||||
the v1.0.57 beta-line command-contract, document, chat, OA, Wiki, compatibility,
|
||||
and CI reliability improvements validated through the prerelease channel.
|
||||
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.3** — publishes the validated prerelease baseline as
|
||||
the stable `v1.0.57` release without adding post-beta product changes.
|
||||
- **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
|
||||
|
||||
@@ -92,6 +308,7 @@ and compatibility and CI reliability fixes.
|
||||
### 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
|
||||
|
||||
@@ -104,6 +321,7 @@ and compatibility and CI reliability fixes.
|
||||
|
||||
### 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.
|
||||
|
||||
+15
-4
@@ -68,14 +68,25 @@ coverage is additionally selected for platform-sensitive code.
|
||||
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 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.
|
||||
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.
|
||||
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
|
||||
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
|
||||
the release-seal workflow renders and archives fragments into the versioned
|
||||
changelog section.
|
||||
|
||||
## Submission Flow
|
||||
|
||||
|
||||
@@ -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.57-beta.3"
|
||||
version "1.0.58-beta.4"
|
||||
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.57-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "b1ea300a76654751ea33540d8a244b0c81b5df3947786980f95a5a19362a097a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "68b5f6e38bec994fa4db4bef5db1629c7bce799bb9837c447008c3325fc886e3"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "350e74f1a2611975e476e113e50264975a98185c11ee889a83d9480c0f10181b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c27a9a884650a7a60545d9447f1b966667216e94c333ebf9d69f9b2ead96e04"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-skills.zip"
|
||||
sha256 "abaa8feaa3c61fff048cfd1139e1fc5b91c329eb666d2eb852797a3f5c4c0cac"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
|
||||
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.56"
|
||||
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.56/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
|
||||
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.56/dws-darwin-amd64.tar.gz"
|
||||
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
|
||||
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.56/dws-linux-arm64.tar.gz"
|
||||
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
|
||||
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.56/dws-linux-amd64.tar.gz"
|
||||
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
|
||||
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.56/dws-skills.zip"
|
||||
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
|
||||
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -24,13 +24,13 @@ help:
|
||||
@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"
|
||||
@@ -89,6 +89,7 @@ policy: test-auth-legacy-compat
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.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
|
||||
@@ -102,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
|
||||
@@ -120,7 +133,12 @@ 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
|
||||
@@ -131,6 +149,9 @@ skill-context-budget:
|
||||
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
|
||||
|
||||
@@ -148,9 +169,13 @@ generate-schema:
|
||||
@set -e; \
|
||||
concepts_guard=$$(mktemp); \
|
||||
concepts_schema_guard=$$(mktemp); \
|
||||
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
|
||||
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; \
|
||||
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
|
||||
rm -f internal/cli/schema_meta_index.json; \
|
||||
@@ -166,6 +191,14 @@ generate-schema:
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
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; \
|
||||
}; \
|
||||
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; \
|
||||
|
||||
@@ -70,17 +70,17 @@ 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** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
|
||||
> 🧪 **`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.
|
||||
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
- **Quick install** (one-liner above): non-interactive, installs `mono`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
|
||||
- **Quick install** (one-liner above): non-interactive, installs `multi`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
@@ -371,7 +371,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,32 +380,32 @@ 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/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
# Install skills into current project (defaults to mono)
|
||||
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
@@ -415,22 +415,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
# Interactive: prompts for mode + target agents
|
||||
dws skill setup
|
||||
|
||||
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
# Preview the exact directories that mono setup would back up and replace
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# Install multi skills to a single agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
# Run interactively and confirm the listed directories
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress)
|
||||
# Preview, then install multi skills to a single agent home with interactive confirmation
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| Flag | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
|
||||
| `--source` | path | Local source directory (overrides bundled skills) |
|
||||
| `--yes` | — | Skip confirmation prompts |
|
||||
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
|
||||
|
||||
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
|
||||
|
||||
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
|
||||
|
||||
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
|
||||
|
||||
@@ -443,7 +452,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 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
@@ -484,28 +492,33 @@ For an event-focused installation, use the official convenience installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
|
||||
# Or install the standalone multi skill from an existing dws installation
|
||||
dws skill setup --mode multi -s event
|
||||
```
|
||||
|
||||
```bash
|
||||
# Inspect the public personal event catalog and schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event list --category oa
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
|
||||
# Listen for messages that mention the current user
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
dws event +listen-im --kind at-me -f ndjson
|
||||
|
||||
# Listen for one-to-one messages with a specified user
|
||||
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
|
||||
# Listen for messages from a specified sender
|
||||
dws event +listen-im --kind sender --user <userId> -f ndjson
|
||||
|
||||
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
|
||||
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
dws event +listen-im --kind group --chat-id <openConversationId> -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
|
||||
dws event +listen-im --kind all-direct -f ndjson
|
||||
dws event +listen-im --kind all-group -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
|
||||
@@ -513,14 +526,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
|
||||
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
|
||||
# Listen for messages, reads, and recalls from the same sender in one process
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# Listen for all six public OA approval events in one process
|
||||
dws event consume \
|
||||
user_im_message_receive_o2o \
|
||||
user_im_message_read_o2o \
|
||||
user_im_message_recall_o2o \
|
||||
--user <userId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
@@ -624,7 +642,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
|
||||
@@ -636,9 +654,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
|
||||
```
|
||||
|
||||
@@ -719,7 +737,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
|
||||
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+62
-44
@@ -70,17 +70,17 @@ 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**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -207,7 +207,7 @@ bash verify-all-channels.sh
|
||||
升级过程采用两阶段原子流程,确保一致性:
|
||||
|
||||
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包平铺到已检测到的具体 Agent 目录(例如 `~/.codex/skills/dingtalk-chat`、`~/.claude/skills/dingtalk-chat`)。只有未检测到具体 Agent 时才使用 `~/.agents/skills`;检测到具体 Agent 后会备份迁走旧的 DWS 通用副本,避免同一 Skill 被重复发现。
|
||||
|
||||
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
|
||||
|
||||
@@ -365,7 +365,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,32 +374,32 @@ 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/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目(默认 mono)
|
||||
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
@@ -409,22 +409,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
# 交互式:提示选模式 + 目标 Agent
|
||||
dws skill setup
|
||||
|
||||
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
# 先预览 mono setup 将备份和替换的精确目录
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# 只装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
# 交互执行并确认列出的目录
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本)
|
||||
# 先预览,再交互确认装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本),先预览
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
|
||||
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
|
||||
| `--yes` | — | 跳过确认提示 |
|
||||
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
|
||||
|
||||
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
|
||||
|
||||
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
|
||||
|
||||
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
|
||||
|
||||
@@ -437,7 +446,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 +476,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。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -478,28 +486,33 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
|
||||
# 或在已有 dws 环境中安装独立的 multi skill
|
||||
dws skill setup --mode multi -s event
|
||||
```
|
||||
|
||||
```bash
|
||||
# 查看公开个人事件目录和 schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event list --category oa
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
|
||||
# 监听当前用户被 @ 的消息
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
dws event +listen-im --kind at-me -f ndjson
|
||||
|
||||
# 监听与指定用户的单聊消息
|
||||
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
|
||||
# 监听指定发送人的消息
|
||||
dws event +listen-im --kind sender --user <userId> -f ndjson
|
||||
|
||||
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
|
||||
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
dws event +listen-im --kind group --chat-id <openConversationId> -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 +listen-im --kind all-direct -f ndjson
|
||||
dws event +listen-im --kind all-group -f ndjson
|
||||
|
||||
# 监听指定群标题变更、成员进退群或群解散
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
@@ -507,14 +520,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
|
||||
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 +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# 一个进程监听全部六个公开 OA 审批事件
|
||||
dws event consume \
|
||||
user_im_message_receive_o2o \
|
||||
user_im_message_read_o2o \
|
||||
user_im_message_recall_o2o \
|
||||
--user <userId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
@@ -618,7 +636,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
|
||||
@@ -630,9 +648,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/审计/基线的全量导出
|
||||
```
|
||||
|
||||
@@ -708,7 +726,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
<summary>即将推出</summary>
|
||||
|
||||
- `conference`(视频会议)
|
||||
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
|
||||
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+637
-17
@@ -3,6 +3,7 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const childProcess = require("child_process");
|
||||
@@ -16,6 +17,7 @@ const AGENT_DIRS = [
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".zcode/skills",
|
||||
".github/skills",
|
||||
".windsurf/skills",
|
||||
".augment/skills",
|
||||
@@ -45,6 +47,58 @@ function ensureCleanDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// backupStamp returns the UTC timestamp used for backup directory names,
|
||||
// matching the shell installers' `date -u +%Y%m%d-%H%M%S` layout.
|
||||
function backupStamp() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
|
||||
// <stamp>/<rel-or-basename> instead of destroying it (non-interactive
|
||||
// installs cannot confirm, so removals must stay reversible). Missing paths
|
||||
// are a no-op success. On any backup failure the directory is left in place
|
||||
// and false is returned so callers skip that target rather than silently
|
||||
// deleting data.
|
||||
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
const rel = path.relative(homeDir, dir);
|
||||
const name =
|
||||
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join("-")
|
||||
: path.basename(dir);
|
||||
const stamp = backupStamp();
|
||||
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
|
||||
let targetRoot = path.join(backupRoot, stamp);
|
||||
let target = path.join(targetRoot, name);
|
||||
for (let i = 1; fs.existsSync(target); i++) {
|
||||
if (i > 1000) {
|
||||
console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`);
|
||||
return false;
|
||||
}
|
||||
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
|
||||
target = path.join(targetRoot, name);
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
renameFn(dir, target);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
if (backups) {
|
||||
backups.push({ original: dir, backup: target });
|
||||
}
|
||||
console.log(` × 已备份并移除 ${dir} → ${target}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function findBinary(root) {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
@@ -117,47 +171,587 @@ function copyChildren(srcDir, destDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// publishCacheAtomically prepares a complete sibling tree before replacing a
|
||||
// cache. If copying or publishing fails, the previous cache stays available.
|
||||
// copyFn is injectable so the failure contract can be tested without relying
|
||||
// on platform-specific permission behavior.
|
||||
function publishCacheAtomically(sourceDir, cacheDir, copyFn = copyChildren) {
|
||||
const cacheParent = path.dirname(cacheDir);
|
||||
const cacheName = path.basename(cacheDir);
|
||||
fs.mkdirSync(cacheParent, { recursive: true });
|
||||
|
||||
const stagedDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.tmp-`));
|
||||
let rollbackDir = "";
|
||||
let published = false;
|
||||
try {
|
||||
copyFn(sourceDir, stagedDir);
|
||||
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
rollbackDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.old-`));
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
fs.renameSync(cacheDir, rollbackDir);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(stagedDir, cacheDir);
|
||||
published = true;
|
||||
} catch (publishErr) {
|
||||
if (rollbackDir) {
|
||||
try {
|
||||
fs.renameSync(rollbackDir, cacheDir);
|
||||
rollbackDir = "";
|
||||
} catch (restoreErr) {
|
||||
throw new Error(
|
||||
`failed to publish cache ${cacheDir}: ${publishErr.message}; ` +
|
||||
`failed to restore previous cache from ${rollbackDir}: ${restoreErr.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw publishErr;
|
||||
}
|
||||
|
||||
if (rollbackDir) {
|
||||
try {
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
} catch (cleanupErr) {
|
||||
console.warn(
|
||||
`⚠️ New cache is active, but old cache cleanup failed at ${rollbackDir}: ${cleanupErr.message}`,
|
||||
);
|
||||
}
|
||||
rollbackDir = "";
|
||||
}
|
||||
} finally {
|
||||
if (!published) {
|
||||
fs.rmSync(stagedDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installSkillsToHomes(skillRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const managedNames = readManagedSkillNames(homeDir);
|
||||
let installed = 0;
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
|
||||
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
|
||||
);
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
publishManagedMonoSkillSetAtomically(homeDir, skillRoot, baseDir, victims);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 跳过 ${baseDir}(mono 集合发布失败,已回滚): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
if (index === 0 && specificAgentDirs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(skillRoot, destDir);
|
||||
installed += 1;
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (installed === 0) {
|
||||
copyChildren(skillRoot, path.join(homeDir, ".agents", "skills", "dws"));
|
||||
if (specificAgentDirs.length > 0 && installed > 0) {
|
||||
try {
|
||||
retireGenericSkillRoot(homeDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
throw new Error("未安装任何 mono Skill:所有检测到的 Agent 目标均失败");
|
||||
}
|
||||
if (failed > 0) {
|
||||
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
|
||||
}
|
||||
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
|
||||
}
|
||||
|
||||
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
|
||||
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
|
||||
// bundle carries at least one product skill (a subdir with SKILL.md). An
|
||||
// empty or corrupt multi/ tree must never select the multi branch nor refresh
|
||||
// the multi cache — installing it would wipe existing skills and lay down
|
||||
// nothing.
|
||||
function multiTreeHasSkills(dir) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
|
||||
}
|
||||
|
||||
const MANAGED_SKILL_DIGEST_SCOPE = "skill-directory-v1";
|
||||
// Frozen exact names shipped before centralized ownership metadata. Retired
|
||||
// names stay here so old installs can be migrated without treating every
|
||||
// dingtalk-* directory as DWS-owned.
|
||||
const LEGACY_OFFICIAL_MULTI_SKILLS = new Set([
|
||||
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
|
||||
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
|
||||
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
|
||||
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
|
||||
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
|
||||
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
|
||||
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
|
||||
"dingtalk-todo", "dingtalk-wiki", "dws-shared",
|
||||
]);
|
||||
|
||||
function skillStateDir(homeDir) {
|
||||
return (process.env.DWS_CONFIG_DIR || "").trim() || path.join(homeDir, ".dws");
|
||||
}
|
||||
|
||||
function readManagedSkillNames(homeDir) {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(path.join(skillStateDir(homeDir), "skills-state.json"), "utf8"));
|
||||
return new Set((state.managed_skills || []).map((record) => record.name).filter(Boolean));
|
||||
} catch (_) {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function isManagedMultiSkillDir(dir, managedNames) {
|
||||
const name = path.basename(dir);
|
||||
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
|
||||
}
|
||||
|
||||
function retireGenericSkillRoot(homeDir, managedNames) {
|
||||
const baseDir = path.join(homeDir, ".agents", "skills");
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
const backups = [];
|
||||
try {
|
||||
for (const victim of victims) {
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const restoreErrors = [];
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
|
||||
fs.renameSync(backups[i].backup, backups[i].original);
|
||||
} catch (restoreErr) {
|
||||
restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function skillDirectoryDigest(dir) {
|
||||
const files = [];
|
||||
const visit = (current, prefix) => {
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(full, rel);
|
||||
} else {
|
||||
files.push({ rel, full });
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(dir, "");
|
||||
files.sort((a, b) => Buffer.from(a.rel).compare(Buffer.from(b.rel)));
|
||||
const hash = crypto.createHash("sha256");
|
||||
for (const file of files) {
|
||||
hash.update(file.rel, "utf8");
|
||||
hash.update(Buffer.from([0]));
|
||||
hash.update(fs.readFileSync(file.full));
|
||||
hash.update(Buffer.from([0]));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
// Publish a complete multi-skill set as one transaction. The entire new set
|
||||
// is staged before any Agent-visible directory moves. If a later backup or
|
||||
// publish fails, every partial publication is removed and all old directories
|
||||
// are restored from their exact backup paths.
|
||||
function publishManagedMultiSkillSetAtomically(
|
||||
homeDir,
|
||||
multiRoot,
|
||||
baseDir,
|
||||
skills,
|
||||
victims,
|
||||
options = {},
|
||||
) {
|
||||
const copyFn = options.copyFn || copyChildren;
|
||||
const renameFn = options.renameFn || fs.renameSync;
|
||||
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
|
||||
const staged = [];
|
||||
const backups = [];
|
||||
const published = [];
|
||||
|
||||
const restore = () => {
|
||||
const restoreErrors = [];
|
||||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
removeFn(published[i]);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
const item = backups[i];
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(item.original), { recursive: true });
|
||||
renameFn(item.backup, item.original);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(restoreErrors.join("; "));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (const name of skills) {
|
||||
const stagedDir = path.join(stageRoot, name);
|
||||
copyFn(path.join(multiRoot, name), stagedDir);
|
||||
staged.push({ staged: stagedDir, dest: path.join(baseDir, name) });
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
for (const victim of victims) {
|
||||
const normalized = path.resolve(victim);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of staged) {
|
||||
renameFn(item.staged, item.dest);
|
||||
published.push(item.dest);
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
restore();
|
||||
} catch (restoreErr) {
|
||||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
removeFn(stageRoot);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish mono plus every mutually-exclusive managed multi victim as one
|
||||
// transaction. The complete dws/ tree is staged before any live directory is
|
||||
// moved; a later backup or publish failure restores the exact previous set.
|
||||
function publishManagedMonoSkillSetAtomically(
|
||||
homeDir,
|
||||
monoRoot,
|
||||
baseDir,
|
||||
victims,
|
||||
options = {},
|
||||
) {
|
||||
const copyFn = options.copyFn || copyChildren;
|
||||
const renameFn = options.renameFn || fs.renameSync;
|
||||
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
|
||||
const stagedDir = path.join(stageRoot, "dws");
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
const backups = [];
|
||||
const published = [];
|
||||
|
||||
const restore = () => {
|
||||
const restoreErrors = [];
|
||||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
removeFn(published[i]);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
const item = backups[i];
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(item.original), { recursive: true });
|
||||
renameFn(item.backup, item.original);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(restoreErrors.join("; "));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
copyFn(monoRoot, stagedDir);
|
||||
|
||||
const seen = new Set();
|
||||
for (const victim of victims) {
|
||||
const normalized = path.resolve(victim);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
|
||||
published.push(destDir);
|
||||
renameFn(stagedDir, destDir);
|
||||
} catch (err) {
|
||||
try {
|
||||
restore();
|
||||
} catch (restoreErr) {
|
||||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
removeFn(stageRoot);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSkillsState(homeDir, multiRoot, skills) {
|
||||
const version = process.env.npm_package_version || process.env.DWS_PACKAGE_VERSION || "unknown";
|
||||
const managedSkills = [...skills].sort().map((name) => ({
|
||||
name,
|
||||
version,
|
||||
source: "npm-postinstall",
|
||||
digest: skillDirectoryDigest(path.join(multiRoot, name)),
|
||||
digest_scope: MANAGED_SKILL_DIGEST_SCOPE,
|
||||
}));
|
||||
const state = {
|
||||
version,
|
||||
official_skills: [...skills].sort(),
|
||||
updated_skills: [...skills].sort(),
|
||||
managed_skills: managedSkills,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const stateDir = skillStateDir(homeDir);
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const stage = fs.mkdtempSync(path.join(stateDir, ".skills-state.tmp-"));
|
||||
const stagedFile = path.join(stage, "skills-state.json");
|
||||
const statePath = path.join(stateDir, "skills-state.json");
|
||||
const rollbackPath = path.join(stage, "skills-state.previous.json");
|
||||
let movedPrevious = false;
|
||||
let preserveRecovery = false;
|
||||
try {
|
||||
fs.writeFileSync(stagedFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
if (fs.existsSync(statePath)) {
|
||||
fs.renameSync(statePath, rollbackPath);
|
||||
movedPrevious = true;
|
||||
}
|
||||
try {
|
||||
fs.renameSync(stagedFile, statePath);
|
||||
} catch (err) {
|
||||
if (movedPrevious && !fs.existsSync(statePath)) {
|
||||
try {
|
||||
fs.renameSync(rollbackPath, statePath);
|
||||
movedPrevious = false;
|
||||
} catch (restoreErr) {
|
||||
preserveRecovery = true;
|
||||
throw new Error(
|
||||
`publish skills state failed: ${err.message}; restore also failed: ${restoreErr.message}; previous state retained at ${rollbackPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (!preserveRecovery) {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
|
||||
// every product skill becomes a sibling directory of the agent home. Mutual
|
||||
// exclusion: the mono leftover (dws/) and stale, proven DWS-managed skills not
|
||||
// present in the new bundle are removed first.
|
||||
function installMultiSkillsToHomes(multiRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const skills = fs
|
||||
.readdirSync(multiRoot, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
|
||||
.map((e) => e.name);
|
||||
if (skills.length === 0) {
|
||||
throw new Error(`no product skills found under ${multiRoot}`);
|
||||
}
|
||||
const skillSet = new Set(skills);
|
||||
const managedNames = readManagedSkillNames(homeDir);
|
||||
let installed = 0;
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
|
||||
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
|
||||
);
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
// Mutual exclusion: include the mono leftover and stale managed skills in
|
||||
// the same transaction as every replaced bundled skill.
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
|
||||
!skillSet.has(entry.name)
|
||||
) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
for (const name of skills) {
|
||||
victims.push(path.join(baseDir, name));
|
||||
}
|
||||
try {
|
||||
publishManagedMultiSkillSetAtomically(homeDir, multiRoot, baseDir, skills, victims);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 跳过 ${baseDir}(multi 集合发布失败,已回滚): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
if (index === 0 && specificAgentDirs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (specificAgentDirs.length > 0 && installed > 0) {
|
||||
try {
|
||||
retireGenericSkillRoot(homeDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
throw new Error("未安装任何 multi Skill:所有检测到的 Agent 目标均失败");
|
||||
}
|
||||
if (failed > 0) {
|
||||
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
|
||||
}
|
||||
writeSkillsState(homeDir, multiRoot, skills);
|
||||
}
|
||||
|
||||
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
|
||||
// wins; multi is the default. The --skill-mode flag accepts both the space
|
||||
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
|
||||
function resolveSkillMode() {
|
||||
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
|
||||
if (raw === "mono" || raw === "multi") {
|
||||
return raw;
|
||||
}
|
||||
if (raw !== "") {
|
||||
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
let fromFlag;
|
||||
const flagIndex = process.argv.indexOf("--skill-mode");
|
||||
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
|
||||
fromFlag = process.argv[flagIndex + 1];
|
||||
} else {
|
||||
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
|
||||
if (equalsArg) {
|
||||
fromFlag = equalsArg.slice("--skill-mode=".length);
|
||||
}
|
||||
}
|
||||
if (fromFlag !== undefined) {
|
||||
const mode = fromFlag.trim().toLowerCase();
|
||||
if (mode === "mono" || mode === "multi") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
return "multi";
|
||||
}
|
||||
|
||||
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
|
||||
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
|
||||
// can fall back to a user-local cache when --source is not provided. mono is
|
||||
// already installed into agent homes by installSkillsToHomes; the cache is
|
||||
// purely a source-of-truth for the setup command.
|
||||
// can fall back to a user-local cache when --source is not provided. A cache
|
||||
// is only refreshed when the new bundle actually carries that tree — an
|
||||
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
|
||||
// good cache.
|
||||
function cacheUserSkills(extractedSkillsRoot) {
|
||||
const cacheBase = path.join(os.homedir(), ".dws", "skills");
|
||||
|
||||
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
|
||||
? path.join(extractedSkillsRoot, "mono")
|
||||
: extractedSkillsRoot;
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
publishCacheAtomically(monoSource, monoCache);
|
||||
}
|
||||
|
||||
const multiSource = path.join(extractedSkillsRoot, "multi");
|
||||
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
|
||||
if (multiTreeHasSkills(multiSource)) {
|
||||
const multiCache = path.join(cacheBase, "multi");
|
||||
fs.rmSync(multiCache, { recursive: true, force: true });
|
||||
copyChildren(multiSource, multiCache);
|
||||
publishCacheAtomically(multiSource, multiCache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +785,34 @@ function main() {
|
||||
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
|
||||
? path.join(skillsStaging, "mono")
|
||||
: skillsStaging;
|
||||
installSkillsToHomes(monoRoot);
|
||||
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
|
||||
// multi-only zip monoRoot would degrade to the staging root and copy the
|
||||
// whole bundle (multi/ included) into a dws/ directory — skip instead.
|
||||
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
|
||||
const multiRoot = path.join(skillsStaging, "multi");
|
||||
const skillMode = resolveSkillMode();
|
||||
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
|
||||
console.log(`Skill mode: multi — installing per-product skills`);
|
||||
installMultiSkillsToHomes(multiRoot);
|
||||
} else {
|
||||
if (skillMode === "multi") {
|
||||
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
|
||||
}
|
||||
if (monoHasSkill) {
|
||||
installSkillsToHomes(monoRoot);
|
||||
} else {
|
||||
console.log("mono skill tree not found in bundle; skipping skill install.");
|
||||
}
|
||||
}
|
||||
cacheUserSkills(skillsStaging);
|
||||
}
|
||||
|
||||
main();
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
publishCacheAtomically,
|
||||
publishManagedMonoSkillSetAtomically,
|
||||
publishManagedMultiSkillSetAtomically,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
- `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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+53
-16
@@ -48,8 +48,12 @@ It then runs:
|
||||
--fast-path "$PR_BASE_SHA" HEAD
|
||||
```
|
||||
|
||||
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
|
||||
its policy dependencies in that merge tree are byte-for-byte the current base
|
||||
The exact fast path remains limited to historic one-file maintenance. A
|
||||
release-seal PR uses `--content-only`, which permits the generated
|
||||
`CHANGELOG.md` change together with archival moves from `.changes/` to
|
||||
`.changes/released/`; it receives the normal scoped admission instead of this
|
||||
fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone
|
||||
release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base
|
||||
versions. Validation targets the synthetic merge tree, not the feature-branch
|
||||
tree, so a stale branch cannot supply an older validator or combine with newer
|
||||
base notes into an invalid final CHANGELOG.
|
||||
@@ -79,10 +83,12 @@ 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
|
||||
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.
|
||||
content contract in `Policy` with `--content-only`. That mode accepts only
|
||||
fragment archival moves (`.changes/<name>.md` to
|
||||
`.changes/released/<version>/<name>.md`) alongside the changelog; source and
|
||||
documentation changes are rejected. It still rejects invalid dates or
|
||||
versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section
|
||||
changes, and unsafe tree modes.
|
||||
|
||||
## Risk tiers and downstream boundaries
|
||||
|
||||
@@ -157,20 +163,38 @@ 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/...
|
||||
```
|
||||
|
||||
For an exact CHANGELOG-only branch:
|
||||
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 a release-seal branch that archives rendered fragments:
|
||||
|
||||
```sh
|
||||
base_ref=$(git merge-base HEAD origin/main)
|
||||
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||
./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD
|
||||
```
|
||||
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. For a
|
||||
@@ -186,11 +210,24 @@ 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
|
||||
|
||||
|
||||
@@ -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。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 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。
|
||||
@@ -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,312 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="DWS Drive Shortcut 与 lark-cli 的业务能力、真实数据 E2E 证据和平台边界分析。">
|
||||
<title>Drive Shortcut 能力全景|业务评审版</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #f4f6f2; --surface: #fffefa; --ink: #17251f; --muted: #66746d;
|
||||
--line: #dce2dc; --forest: #154f3d; --green: #17765a; --mint: #dff4e8;
|
||||
--blue: #265f86; --blue-soft: #e7f1f7; --amber: #8c5a09; --amber-soft: #fff2cf;
|
||||
--red: #a43b32; --red-soft: #fde9e5; --shadow: 0 14px 40px rgba(28, 48, 38, .08);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; color: var(--ink); background: var(--paper); font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
code { padding: .12rem .38rem; border: 1px solid #d6e0da; border-radius: 6px; color: #174f3e; background: #f1f7f3; font: 600 .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
|
||||
.wrap { width: min(1180px, calc(100% - 40px)); margin: auto; }
|
||||
.hero { position: relative; overflow: hidden; padding: 64px 0 52px; color: #f7fff9; background: linear-gradient(125deg, #102b22 0%, #154c3c 57%, #1c6b54 100%); }
|
||||
.hero::after { position: absolute; inset: -180px -100px auto auto; width: 540px; height: 540px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; box-shadow: 0 0 0 76px rgba(255,255,255,.035), 0 0 0 152px rgba(255,255,255,.025); content: ""; }
|
||||
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 32px; align-items: end; }
|
||||
.eyebrow, .section-kicker { margin: 0 0 9px; color: #9fd6bd; font-size: 11px; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-size: clamp(40px, 6vw, 68px); line-height: 1.03; letter-spacing: -.045em; }
|
||||
.subtitle { max-width: 760px; margin: 19px 0 0; color: #d3e9de; font-size: 17px; }
|
||||
.meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 21px; }
|
||||
.meta-pill { padding: 6px 10px; border: 1px solid rgba(255,255,255,.18); border-radius: 999px; color: #d5e9df; background: rgba(255,255,255,.07); font-size: 11px; font-weight: 750; }
|
||||
.meta-pill.good { color: #bff2d5; border-color: rgba(139,232,179,.38); }
|
||||
.hero-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.hero-stat { padding: 17px 16px; border: 1px solid rgba(255,255,255,.17); border-radius: 15px; background: rgba(255,255,255,.075); backdrop-filter: blur(8px); }
|
||||
.hero-stat strong { display: block; font-size: 30px; line-height: 1; }
|
||||
.hero-stat span { display: block; margin-top: 7px; color: #cce2d7; font-size: 11px; }
|
||||
.nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--line); background: rgba(255,254,250,.94); backdrop-filter: blur(12px); }
|
||||
.nav .wrap { display: flex; overflow-x: auto; }
|
||||
.nav a { flex: 0 0 auto; padding: 14px 15px; color: #5b6c64; font-size: 12px; font-weight: 800; }
|
||||
.nav a:hover { color: var(--forest); background: #eaf3ee; }
|
||||
main { padding: 38px 0 74px; }
|
||||
section { margin-top: 50px; scroll-margin-top: 72px; }
|
||||
section:first-child { margin-top: 0; }
|
||||
.section-head { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 19px; }
|
||||
h2 { margin: 0; font-size: clamp(25px, 3.2vw, 36px); line-height: 1.16; letter-spacing: -.025em; }
|
||||
h3 { margin: 0 0 7px; font-size: 18px; }
|
||||
.section-desc { max-width: 660px; margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.callout { padding: 19px 21px; border: 1px solid #bdd8cb; border-left: 4px solid var(--green); border-radius: 13px; background: #ecf7f1; box-shadow: 0 6px 20px rgba(28,48,38,.04); }
|
||||
.callout strong { color: #13513d; }
|
||||
.callout.warn { border-color: #ead29a; border-left-color: #b67508; background: #fff8e7; }
|
||||
.callout.warn strong { color: #784b00; }
|
||||
.callout.danger { border-color: #e9b7b1; border-left-color: var(--red); background: var(--red-soft); }
|
||||
.score-grid, .domain-grid, .evidence-grid, .review-grid { display: grid; gap: 13px; }
|
||||
.score-grid { grid-template-columns: repeat(4, 1fr); margin-top: 16px; }
|
||||
.score, .domain-card, .evidence-card, .review-card { border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.score { padding: 19px; }
|
||||
.score strong { display: block; color: var(--forest); font-size: 29px; line-height: 1; }
|
||||
.score span { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
|
||||
.domain-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.domain-card { position: relative; padding: 21px; overflow: hidden; }
|
||||
.domain-card .number { position: absolute; top: 12px; right: 17px; color: #d5e8de; font: 800 42px/1 ui-monospace, monospace; }
|
||||
.domain-card p { min-height: 64px; margin: 8px 0 12px; color: var(--muted); font-size: 13px; }
|
||||
.domain-card small { color: var(--green); font-weight: 800; }
|
||||
.compare { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.compare-top { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.compare-column { padding: 21px; border-right: 1px solid var(--line); }
|
||||
.compare-column:last-child { border-right: 0; }
|
||||
.compare-column p, .compare-column li { color: var(--muted); font-size: 13px; }
|
||||
.compare-column ul { margin: 9px 0 0; padding-left: 18px; }
|
||||
.compare-column.covered { border-top: 5px solid var(--green); }
|
||||
.compare-column.partial { border-top: 5px solid #c78b22; }
|
||||
.compare-column.gap { border-top: 5px solid var(--red); }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
||||
th { color: #617069; background: #f7f8f5; font-size: 11px; font-weight: 900; letter-spacing: .03em; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: #f8fbf8; }
|
||||
.verdict, .badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 900; white-space: nowrap; }
|
||||
.v-covered, .badge.read { color: #116045; background: var(--mint); }
|
||||
.v-ahead, .badge.smart { color: #20577c; background: var(--blue-soft); }
|
||||
.v-partial, .badge.write { color: #7b510a; background: var(--amber-soft); }
|
||||
.v-gap, .badge.high { color: #8f3028; background: var(--red-soft); }
|
||||
.truth-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 14px; }
|
||||
.truth-card { padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.truth-step { display: grid; grid-template-columns: 30px 1fr; gap: 11px; margin-top: 13px; }
|
||||
.truth-step b { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; color: #fff; background: var(--forest); font-size: 12px; }
|
||||
.truth-step strong, .truth-step span { display: block; }
|
||||
.truth-step span { color: var(--muted); font-size: 12px; }
|
||||
.toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) 180px 180px auto; gap: 10px; align-items: center; margin: 18px 0; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
|
||||
input, select { width: 100%; min-height: 42px; padding: 9px 11px; border: 1px solid #ccd7d0; border-radius: 9px; color: var(--ink); background: #fff; font: inherit; }
|
||||
input:focus, select:focus { outline: 3px solid rgba(23,118,90,.13); border-color: var(--green); }
|
||||
.result-count { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
|
||||
.catalog { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.shortcut-row { display: grid; grid-template-columns: 215px minmax(0, 1fr) 200px; gap: 16px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); }
|
||||
.shortcut-row:last-child { border-bottom: 0; }
|
||||
.shortcut-row:hover { background: #f8fbf8; }
|
||||
.command code { font-size: 12px; }
|
||||
.row-main p { margin: 0; font-size: 13px; }
|
||||
.row-main small { color: var(--muted); }
|
||||
.badges { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
|
||||
.hidden-row { display: none; }
|
||||
.evidence-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.evidence-card { padding: 19px; }
|
||||
.evidence-card strong { display: block; color: var(--forest); font-size: 23px; }
|
||||
.evidence-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.timeline { margin-top: 15px; border-left: 2px solid #bdd7ca; }
|
||||
.event { position: relative; padding: 0 0 17px 22px; }
|
||||
.event::before { position: absolute; left: -7px; top: 5px; width: 12px; height: 12px; border: 3px solid var(--paper); border-radius: 50%; background: var(--green); content: ""; }
|
||||
.event b { display: block; }
|
||||
.event span { color: var(--muted); font-size: 12px; }
|
||||
.review-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.review-card { position: relative; padding: 20px; }
|
||||
.review-card .review-num { color: #b8d1c4; font: 800 12px/1 ui-monospace, monospace; letter-spacing: .1em; }
|
||||
.review-card p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
|
||||
footer { margin-top: 52px; padding: 23px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
|
||||
@media (max-width: 900px) { .hero-grid, .truth-grid { grid-template-columns: 1fr; } .score-grid, .domain-grid, .evidence-grid { grid-template-columns: repeat(2, 1fr); } .review-grid { grid-template-columns: 1fr 1fr; } .shortcut-row { grid-template-columns: 180px 1fr; } .badges { grid-column: 1 / -1; justify-content: flex-start; } }
|
||||
@media (max-width: 620px) { .wrap { width: min(100% - 24px, 1180px); } .hero { padding: 44px 0 38px; } .hero-stats, .score-grid, .domain-grid, .evidence-grid, .review-grid, .compare-top { grid-template-columns: 1fr; } .compare-column { border-right: 0; border-bottom: 1px solid var(--line); } .toolbar { grid-template-columns: 1fr; } .result-count { text-align: left; } .shortcut-row { grid-template-columns: 1fr; } }
|
||||
@media print { body { background: #fff; } .hero { color: var(--ink); background: #fff; border-bottom: 2px solid var(--ink); } .subtitle, .meta-pill, .hero-stat span { color: #425249; } .hero-stat { border-color: #aebbb3; } .nav, .toolbar { display: none; } .score, .domain-card, .compare, .truth-card, .catalog, .evidence-card, .review-card { box-shadow: none; break-inside: avoid; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div class="wrap hero-grid">
|
||||
<div>
|
||||
<p class="eyebrow">Business Review · Drive</p>
|
||||
<h1>Drive Shortcut<br>能力全景</h1>
|
||||
<p class="subtitle">从 lark-cli 对齐出发,但不止于命令名:逐项审查输入、校验、多步编排、失败语义、真实字节和平台边界。</p>
|
||||
<div class="meta-row">
|
||||
<span class="meta-pill good">真实账号 E2E 已执行</span>
|
||||
<span class="meta-pill">28 个公开入口</span>
|
||||
<span class="meta-pill">统一 Result / Pagination</span>
|
||||
<span class="meta-pill">报告已移除 PII / 凭证 / 业务正文</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stats" aria-label="关键统计">
|
||||
<div class="hero-stat"><strong>38</strong><span>lark-cli Drive 逐项审查</span></div>
|
||||
<div class="hero-stat"><strong>26</strong><span>已覆盖或跨产品路由</span></div>
|
||||
<div class="hero-stat"><strong>7</strong><span>部分对齐,边界已公开</span></div>
|
||||
<div class="hero-stat"><strong>5</strong><span>客观不可对齐能力</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="nav"><div class="wrap"><a href="#overview">全景</a><a href="#compare">Lark 对齐</a><a href="#ahead">超越项</a><a href="#truth">真实语义</a><a href="#catalog">完整目录</a><a href="#e2e">E2E</a><a href="#review">评审</a></div></nav>
|
||||
|
||||
<main class="wrap">
|
||||
<section id="overview">
|
||||
<div class="section-head"><div><p class="section-kicker">Executive summary</p><h2>28 个公开入口,覆盖文件完整生命周期</h2></div><p class="section-desc">另有 <code>+publish-set</code> 已实现契约和读回逻辑,但真实普通文件与在线文档均被服务端拒绝,因此保持 unavailable,不进入 Agent 公开目录。</p></div>
|
||||
<div class="callout"><strong>结论:</strong>Drive 已从 9 个偏原子入口扩展为 28 个可发现 Shortcut。它不仅补齐 Lark 的核心文件、版本和状态任务,还通过严格响应合同、真实落盘、写后读回、回收恢复和个人收藏形成更可审计的钉盘工作流。</div>
|
||||
<div class="score-grid">
|
||||
<article class="score"><strong>29</strong><span>已审查注册项(含 1 unavailable)</span></article>
|
||||
<article class="score"><strong>25</strong><span>公开主能力 / 语义适配</span></article>
|
||||
<article class="score"><strong>3</strong><span>公开兼容入口</span></article>
|
||||
<article class="score"><strong>3</strong><span>高风险写入口,均需确认</span></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head"><div><p class="section-kicker">Capability map</p><h2>四个业务域</h2></div><p class="section-desc">目录按用户任务组织;兼容命令不重复计为新增能力。</p></div>
|
||||
<div class="domain-grid">
|
||||
<article class="domain-card"><span class="number">09</span><h3>发现与检查</h3><p>严格目录分页、搜索、最近访问,以及元数据、统计和封面聚合检查。</p><small>+list · +search · +recent · +inspect</small></article>
|
||||
<article class="domain-card"><span class="number">09</span><h3>文件生命周期</h3><p>创建目录、上传下载、快捷方式、在线对象复制、移动重命名、删除与恢复。</p><small>+upload · +download · +rename · +recycle-restore</small></article>
|
||||
<article class="domain-card"><span class="number">06</span><h3>个人与公开状态</h3><p>回收站清单、收藏闭环和互联网公开状态的独立安全域。</p><small>+star-list · +star-add · +publish-get</small></article>
|
||||
<article class="domain-card"><span class="number">04</span><h3>历史版本</h3><p>版本列表、精确定位、真实字节下载与高风险回滚读回。</p><small>+version-history · +version-download · +version-revert</small></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="compare">
|
||||
<div class="section-head"><div><p class="section-kicker">Lark alignment</p><h2>对齐业务语义,不追求同名率</h2></div><p class="section-desc">38 项逐项核对。评论、导入导出和成员权限在 DWS 由更成熟的 Doc 或原子权限入口承接,不在 Drive 再复制一套。</p></div>
|
||||
<div class="compare">
|
||||
<div class="compare-top">
|
||||
<article class="compare-column covered"><h3>26 · 已覆盖 / 路由</h3><p>上传下载、目录与快捷方式、版本、移动删除、状态、搜索、评论、导入导出和成员任务均有真实入口。</p></article>
|
||||
<article class="compare-column partial"><h3>7 · 部分对齐</h3><p>预览、resolve/reaction、push/pull、权限申请与 setting 受对象模型或接口粒度约束,明确保留有限语义。</p></article>
|
||||
<article class="compare-column gap"><h3>5 · 客观缺口</h3><p>删除评论恢复、普通文件版本删除、安全标签读写、可靠双向目录同步缺少必要下层接口。</p></article>
|
||||
</div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Lark 任务组</th><th>DWS 主路径</th><th>结论</th><th>关键差异与交付决定</th></tr></thead><tbody>
|
||||
<tr><td>upload / folder / shortcut / download</td><td><code>drive +upload</code> 等</td><td><span class="verdict v-ahead">增强</span></td><td>工作目录边界、OSS PUT、严格 commit、no-clobber、原子落盘、非零字节和读回验证。</td></tr>
|
||||
<tr><td>preview / cover</td><td><code>drive +cover</code></td><td><span class="verdict v-partial">部分</span></td><td>封面/缩略图可读;没有等价的服务端多格式预览转换,不扩大宣称。</td></tr>
|
||||
<tr><td>comments / replies</td><td><code>doc +comment-*</code> / <code>doc +review</code></td><td><span class="verdict v-covered">路由</span></td><td>评论归在线文档协作域;独立 resolve、reaction identity 与删除后恢复仍受接口限制。</td></tr>
|
||||
<tr><td>export / import / task result</td><td><code>doc +export</code> / <code>doc +import</code></td><td><span class="verdict v-ahead">增强</span></td><td>提交、轮询、恢复、安全下载形成类型化闭环,不保留泛化下划线命令。</td></tr>
|
||||
<tr><td>version history / get / revert</td><td><code>drive +version-*</code></td><td><span class="verdict v-ahead">增强</span></td><td>严格分页、精确版本、历史字节落盘、回滚前预检与终态读回;历史版本删除无接口。</td></tr>
|
||||
<tr><td>status / inspect</td><td><code>drive +inspect</code></td><td><span class="verdict v-ahead">超越</span></td><td>元数据为必达结果,统计、公开状态和封面按需 fan-out;可选失败为 partial_success。</td></tr>
|
||||
<tr><td>push / pull / sync</td><td><code>+upload</code> / <code>+download</code> 单文件</td><td><span class="verdict v-partial">部分</span></td><td>不在缺少稳定 hash、rename/delete journal 和冲突向量时制造危险目录同步。</td></tr>
|
||||
<tr><td>member / permission</td><td><code>doc +access-*</code> / <code>drive permission</code></td><td><span class="verdict v-covered">路由</span></td><td>协作者权限与互联网公开是两个安全域;申请权限需真实上下文,未伪装为通用 Shortcut。</td></tr>
|
||||
<tr><td>secure labels</td><td>无等价</td><td><span class="verdict v-gap">缺口</span></td><td>当前 DWS/钉钉下层没有 Drive 安全标签目录和写入接口,不能用普通权限代替。</td></tr>
|
||||
<tr><td>search</td><td><code>drive +search</code> / <code>doc +search</code></td><td><span class="verdict v-ahead">增强</span></td><td>文件与在线文档按域路由;文件搜索严格验证数组、过滤和分页。</td></tr>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
<div class="callout warn" style="margin-top:14px"><strong>普通文件 copy 边界:</strong>钉钉现有复制接口对普通文件产生 <code>.dlink</code>,不是字节独立副本。因此 <code>+copy</code> 只接受在线对象;普通文件快捷入口用 <code>+create-shortcut</code>,独立副本使用 <code>+download</code> 后 <code>+upload</code>。</div>
|
||||
</section>
|
||||
|
||||
<section id="ahead">
|
||||
<div class="section-head"><div><p class="section-kicker">Beyond parity</p><h2>DWS 可主推的八个差异化点</h2></div><p class="section-desc">价值来自正确性与完整闭环,而不是额外注册同义命令。</p></div>
|
||||
<div class="domain-grid">
|
||||
<article class="domain-card"><h3>严格目录语义</h3><p><code>+list</code> / <code>+recent</code> 只有服务端明确返回数组时才接受空集合。</p><small>缺字段 ≠ 空目录</small></article>
|
||||
<article class="domain-card"><h3>聚合检查</h3><p><code>+inspect</code> 一次汇总身份、统计、公开状态和封面,并保留局部失败。</p><small>partial_success 可审计</small></article>
|
||||
<article class="domain-card"><h3>真实文件传输</h3><p>上传执行完整事务;下载验证受控路径、覆盖策略、原子发布和字节。</p><small>不是只返回临时 URL</small></article>
|
||||
<article class="domain-card"><h3>回收恢复闭环</h3><p>从 <code>recycleItemId</code> 恢复后读取真实节点,证明资源确实回到可访问状态。</p><small>恢复后读回</small></article>
|
||||
<article class="domain-card"><h3>个人收藏闭环</h3><p>收藏、列表、取消收藏覆盖完整用户偏好过程,并保留分页。</p><small>add → list → remove</small></article>
|
||||
<article class="domain-card"><h3>版本真实字节</h3><p>除元数据外可下载任意已知历史版本,并用本地字节核验回滚结果。</p><small>version-download</small></article>
|
||||
<article class="domain-card"><h3>重命名终态</h3><p>处理服务端扩展名规则,再读取节点确认最终名称,避免重复扩展名。</p><small>write → read-back</small></article>
|
||||
<article class="domain-card"><h3>公开域诚实降级</h3><p>查询和关闭可验证;开启在 eligible 节点闭环完成前保持 unavailable。</p><small>不把 notSupported 当成功</small></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="truth">
|
||||
<div class="section-head"><div><p class="section-kicker">Truthful execution</p><h2>空数组不再是“看起来成功”</h2></div><p class="section-desc">合法业务空集合可以成功,但必须先证明响应结构、元素类型和分页语义成立。内部错误、缺字段与坏投影必须失败。</p></div>
|
||||
<div class="truth-grid">
|
||||
<article class="truth-card"><h3>四层成功证据</h3>
|
||||
<div class="truth-step"><b>1</b><div><strong>传输成功</strong><span>进程成功,MCP / HTTP 没有显式错误。</span></div></div>
|
||||
<div class="truth-step"><b>2</b><div><strong>响应合同</strong><span>对象、数组、success 标志和元素类型与命令声明一致。</span></div></div>
|
||||
<div class="truth-step"><b>3</b><div><strong>业务终态</strong><span>写命令必须获得新 ID、终态证据或后续元数据读回。</span></div></div>
|
||||
<div class="truth-step"><b>4</b><div><strong>产物校验</strong><span>下载必须落盘、非零字节;关键链路比较大小和 SHA-256。</span></div></div>
|
||||
</article>
|
||||
<article class="truth-card"><h3>明确失败的情况</h3>
|
||||
<ul><li>空响应、缺少预期集合字段或集合类型错误。</li><li>集合存在坏元素,不能投影时静默丢弃。</li><li><code>success=false</code>、写响应没有 ID 或读回不一致。</li><li>inspect 的可选分支失败却返回整体 success。</li><li>下载得到空文件、越界路径或覆盖既有文件。</li><li>普通文件 copy 返回快捷链接却声称独立副本。</li></ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="catalog">
|
||||
<div class="section-head"><div><p class="section-kicker">Full catalog</p><h2>28 个公开 Shortcut 完整目录</h2></div><p class="section-desc">16 个只读、9 个普通写、3 个高风险写;3 个历史入口保留兼容但不作为新 Agent 主路径。</p></div>
|
||||
<div class="toolbar"><input id="q" type="search" placeholder="搜索命令或用途,例如 版本、回收、+inspect…" aria-label="搜索 Shortcut"><select id="domain"><option value="all">全部业务域</option><option value="discover">发现与检查</option><option value="lifecycle">文件生命周期</option><option value="personal">个人与公开</option><option value="version">历史版本</option></select><select id="risk"><option value="all">全部风险</option><option value="read">只读</option><option value="write">普通写</option><option value="high">高风险写</option></select><span id="result-count" class="result-count">显示 28 / 28</span></div>
|
||||
<div class="catalog">
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +list</code></div><div class="row-main"><p>严格分页列出目录,保留游标,区分显式空目录和畸形响应。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +inspect</code></div><div class="row-main"><p>聚合元数据与可选统计、公开状态、封面;局部失败如实报告。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +info</code></div><div class="row-main"><p>历史元数据兼容入口;新场景优先使用 +inspect。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search</code></div><div class="row-main"><p>按关键词、类型、扩展名、创建人、时间和分页搜索钉盘文件。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +find-file</code></div><div class="row-main"><p>历史文件搜索兼容入口;新场景优先使用 +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search-docs</code></div><div class="row-main"><p>历史跨域搜索入口;新的在线文档搜索路由 doc +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +recent</code></div><div class="row-main"><p>读取最近访问或编辑列表,支持创建人筛选并保留分页。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +stats</code></div><div class="row-main"><p>读取访问、编辑、评论、点赞、预览和下载统计。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +cover</code></div><div class="row-main"><p>读取封面或缩略图;不宣称服务端多格式预览。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +upload</code></div><div class="row-main"><p>上传凭证、OSS PUT、严格提交和远端元数据读回的一体化事务。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="read"><div class="command"><code>dws drive +download</code></div><div class="row-main"><p>安全落盘、no-clobber、原子发布并验证非零字节。</p><small>文件生命周期</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-folder</code></div><div class="row-main"><p>创建文件夹后要求新 ID,并读回名称验证。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-shortcut</code></div><div class="row-main"><p>创建快捷方式并读回,明确区别于独立副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +copy</code></div><div class="row-main"><p>复制在线对象;普通文件预检拒绝,避免把 .dlink 当副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +move</code></div><div class="row-main"><p>移动到指定文件夹或知识库位置,语义与 copy/shortcut 消歧。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +rename</code></div><div class="row-main"><p>重命名后读取真实节点,验证最终名称和扩展名。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="high"><div class="command"><code>dws drive +delete</code></div><div class="row-main"><p>将确认过的节点移入回收站,要求 success=true 终态证据。</p><small>文件生命周期</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +recycle-restore</code></div><div class="row-main"><p>按回收项 ID 恢复,并读回恢复后的节点。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +recycle-list</code></div><div class="row-main"><p>严格分页列出回收项并稳定投影 recycleItemId。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +star-list</code></div><div class="row-main"><p>严格分页列出当前用户收藏并保留游标。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-add</code></div><div class="row-main"><p>以幂等用户偏好语义收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-remove</code></div><div class="row-main"><p>以幂等用户偏好语义取消收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +publish-get</code></div><div class="row-main"><p>只读查询互联网公开状态,不沿用错误的写风险标签。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="high"><div class="command"><code>dws drive +publish-unset</code></div><div class="row-main"><p>关闭互联网公开并读回验证外链状态。</p><small>个人与公开</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-history</code></div><div class="row-main"><p>严格分页列出普通文件历史版本。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-get</code></div><div class="row-main"><p>按正整数版本号精确匹配,零命中显式失败。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-download</code></div><div class="row-main"><p>预检版本后安全下载历史字节,要求非零产物。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="high"><div class="command"><code>dws drive +version-revert</code></div><div class="row-main"><p>验证版本存在后回滚,并读取当前节点终态。</p><small>历史版本</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
</div>
|
||||
<div class="callout warn" style="margin-top:14px"><strong>未公开入口:</strong><code>+publish-set</code> 的安全契约和 set→get 读回代码已存在,但真实后端返回 <code>operation.notSupported</code>。在找到 eligible 节点并完成 set→get→unset 闭环前,不进入公开 Agent catalog。</div>
|
||||
</section>
|
||||
|
||||
<section id="e2e">
|
||||
<div class="section-head"><div><p class="section-kicker">Real-data E2E</p><h2>真实数据验证,不用空结果证明成功</h2></div><p class="section-desc">测试在隔离目录创建临时资源,覆盖读取、写入、下载、版本、收藏、回收和清理。资源 ID、账号、URL、上传凭证、绝对路径和业务正文均未进入报告。</p></div>
|
||||
<div class="evidence-grid">
|
||||
<article class="evidence-card"><strong>39,838 B</strong><p>真实文件上传后下载字节数;与源文件 SHA-256 完全一致。</p></article>
|
||||
<article class="evidence-card"><strong>2 versions</strong><p>覆盖写入生成两个版本;精确查询、历史下载和回滚全部读回。</p></article>
|
||||
<article class="evidence-card"><strong>4 / 5</strong><p>隔离夹具中搜索命中 4 项、最近列表命中 5 项,证明非空投影链路。</p></article>
|
||||
<article class="evidence-card"><strong>0 remain</strong><p>测试结束后隔离根目录无残留;临时资源进入回收站并完成本地清理。</p></article>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="event"><b>创建与发现</b><span>创建两个隔离目录并读回;+list 命中真实节点,由此发现并修复 dentryId 与 32 字符 fileId 混用。</span></div>
|
||||
<div class="event"><b>上传与下载</b><span>真实 OSS 上传、远端元数据读回、下载、no-clobber 二次路径、大小与 SHA-256 一致性全部通过。</span></div>
|
||||
<div class="event"><b>检查与个人状态</b><span>+inspect(含 stats / publish / cover)、+stats、+cover、收藏 add→list→remove 通过。</span></div>
|
||||
<div class="event"><b>版本闭环</b><span>覆盖文件产生两个版本;history/get/download/revert 通过,回滚后最新字节与原始内容一致。</span></div>
|
||||
<div class="event"><b>复制、移动与命名</b><span>在线文档 copy 通过;普通文件 .dlink 被修正为预检拒绝;move 往返、rename 扩展名规范化通过。</span></div>
|
||||
<div class="event"><b>删除与恢复</b><span>delete→recycle-list→recycle-restore 通过,真实回收响应字段已按后端形态修正。</span></div>
|
||||
<div class="event"><b>平台负向证据</b><span>publish-set 对普通文件和在线文档均明确返回不支持,因此保持 unavailable;没有把失败改写成空对象成功。</span></div>
|
||||
<div class="event"><b>清理</b><span>隔离目录进入回收站,根目录残留计数为零;本地下载产物删除。</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="review">
|
||||
<div class="section-head"><div><p class="section-kicker">Review prompts</p><h2>建议业务评审重点确认</h2></div><p class="section-desc">这些是需要接受的产品边界,不是被空结果遮蔽的实现问题。</p></div>
|
||||
<div class="review-grid">
|
||||
<article class="review-card"><span class="review-num">01</span><h3>是否接受 26 / 7 / 5 结论?</h3><p>按用户任务计覆盖、部分与缺口,不用同名命令数量代替语义保真。</p></article>
|
||||
<article class="review-card"><span class="review-num">02</span><h3>普通文件 copy 是否足够清晰?</h3><p>服务端无法提供原子独立副本;快捷方式与下载后上传两条替代路径已明确。</p></article>
|
||||
<article class="review-card"><span class="review-num">03</span><h3>是否拒绝不可靠目录 sync?</h3><p>缺稳定 hash、删除/重命名日志和冲突向量时,不发布可能覆盖数据的双向同步。</p></article>
|
||||
<article class="review-card"><span class="review-num">04</span><h3>跨产品路由是否合理?</h3><p>评论、导入导出和协作者权限优先复用 Doc 成熟入口,不在 Drive 制造同义表面。</p></article>
|
||||
<article class="review-card"><span class="review-num">05</span><h3>publish-set 是否继续 unavailable?</h3><p>建议维持,直到真实 eligible 节点完成开启、查询、关闭的可恢复闭环。</p></article>
|
||||
<article class="review-card"><span class="review-num">06</span><h3>下一批后端解锁优先级?</h3><p>建议依次评估普通文件原子 copy、同步所需版本信号、安全标签和评论恢复接口。</p></article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><div class="wrap">依据:DWS 最终 Shortcut catalog / Schema、Drive 实现与测试、真实账号 E2E、lark-cli Drive registrations 与实现。范围仅含 Drive Shortcut 及必要跨产品路由;不包含原子命令总表。所有业务标识、凭证、签名 URL、用户信息和正文均已脱敏。</div></footer>
|
||||
<script>
|
||||
const q = document.querySelector('#q');
|
||||
const domain = document.querySelector('#domain');
|
||||
const risk = document.querySelector('#risk');
|
||||
const rows = [...document.querySelectorAll('[data-tool]')];
|
||||
const count = document.querySelector('#result-count');
|
||||
function filterTools() {
|
||||
const needle = q.value.trim().toLocaleLowerCase('zh-CN');
|
||||
let visible = 0;
|
||||
rows.forEach((row) => {
|
||||
const show = (!needle || row.textContent.toLocaleLowerCase('zh-CN').includes(needle)) && (domain.value === 'all' || row.dataset.domain === domain.value) && (risk.value === 'all' || row.dataset.risk === risk.value);
|
||||
row.classList.toggle('hidden-row', !show);
|
||||
if (show) visible += 1;
|
||||
});
|
||||
count.textContent = `显示 ${visible} / ${rows.length}`;
|
||||
}
|
||||
[q, domain, risk].forEach((control) => control.addEventListener('input', filterTools));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
# Drive Shortcut 对齐与超越 Lark CLI
|
||||
|
||||
## 目标与判定口径
|
||||
|
||||
本轮以 Lark CLI `drive` 的 38 个 shortcut 为对照,但不把“同名命令数量”当完成标准。对齐按用户任务判定:
|
||||
|
||||
1. `drive +...` 有更稳定的 Agent 主入口时,提供 Shortcut,并发布 Selection、Safety、Result 与 Pagination。
|
||||
2. 钉钉已经在其他产品提供更成熟入口时,Skill 明确跨产品路由,不在 Drive 重复实现。
|
||||
3. 只有原子能力且 Shortcut 不增加校验、编排或投影价值时,保留 Runtime Schema leaf,不制造同义别名。
|
||||
4. 下层接口不存在或无法满足相同语义时,明确记录 gap;不得用空数组、空对象或只返回任务提交结果伪装完成。
|
||||
|
||||
成功判定统一为:进程成功 + 统一结果 `ok=true/outcome=success` + 必要业务字段 + 真实数据读回或本地字节校验。服务端显式返回空数组可以是合法业务空结果;空响应、缺少数组、数组类型错误、坏元素、`success=false`、写入缺少终态证据都必须失败。
|
||||
|
||||
## Lark 38 项映射
|
||||
|
||||
| Lark Drive shortcut | DWS 路由 | 结论与原因 |
|
||||
|---|---|---|
|
||||
| `+upload` | `drive +upload` | 对齐并增强:工作目录边界、OSS PUT、严格 commit、元数据读回。 |
|
||||
| `+create-folder` | `drive +create-folder` | 对齐并增强:要求新 fileId 和名称读回。 |
|
||||
| `+create-shortcut` | `drive +create-shortcut` | 对齐并增强:明确 shortcut≠copy,创建后读回。 |
|
||||
| `+download` | `drive +download` | 对齐并增强:真实落盘、no-clobber、原子发布、非零字节。 |
|
||||
| `+preview` | `drive +cover`(有限) | 不完全对齐:钉钉当前只提供封面/缩略图读取,没有等价的服务端多格式预览转换接口。 |
|
||||
| `+cover` | `drive +cover` | 对齐:严格读取封面/缩略图对象。 |
|
||||
| `+add-comment` | `doc +comment-create` | 用户任务对齐;评论归在线文档协作域,Drive 不复制一套。 |
|
||||
| `+list-comments` | `doc +comment-list` | 用户任务对齐;Doc 已有类型、状态与分页。 |
|
||||
| `+batch-query-comments` | `doc +review` / `doc +comment-list` | 超越:可聚合未解决评论与确定性正文上下文;跨文档批量仍由调用方按节点编排。 |
|
||||
| `+resolve-comment` | `doc +comment-update`(有限) | 部分对齐:DWS 可更新评论,但当前下层未声明独立 resolve 状态接口。 |
|
||||
| `+restore-comment` | 无等价 | gap:钉钉当前下层未暴露恢复已删除评论的等价能力。 |
|
||||
| `+add-reply` | `doc +comment-reply` | 对齐。 |
|
||||
| `+list-replies` | `doc +comment-list` | 用户任务对齐:评论列表返回回复上下文;无独立 Drive reply 目录。 |
|
||||
| `+update-reply` | `doc +comment-update` | 对齐到评论/回复统一更新语义。 |
|
||||
| `+delete-reply` | `doc +comment-delete` | 对齐到评论/回复统一删除语义,高风险确认。 |
|
||||
| `+react-reply` | `doc +comment-reply`(有限) | 部分对齐:支持表情回复;不声称拥有 Lark 的独立 reaction identity。 |
|
||||
| `+export` | `doc +export` | 用户任务对齐并增强:提交、轮询、安全下载一体化。 |
|
||||
| `+export-download` | `doc +export` / `doc +export-get` | 超越:常规一体化,`+export-get` 仅作中断恢复。 |
|
||||
| `+import` | `doc +import` | 用户任务对齐并增强:转换白名单、上传 fallback、轮询终态。 |
|
||||
| `+version-history` | `drive +version-history` | 对齐并增强:严格空结果与分页。 |
|
||||
| `+version-get` | `drive +version-get` | 对齐并增强:精确版本号,零命中失败。 |
|
||||
| `+version-revert` | `drive +version-revert` | 对齐并增强:版本预检、高风险确认、节点读回。 |
|
||||
| `+version-delete` | 无等价 | gap:钉钉当前普通文件版本接口没有删除历史版本能力。 |
|
||||
| `+move` | `drive +move` | 对齐;与 copy/shortcut 明确消歧并发布确认。 |
|
||||
| `+delete` | `drive +delete` | 对齐;移入回收站、高风险确认、终态证据。 |
|
||||
| `+status` | `drive +inspect` | 超越:远端身份、统计、公开状态和封面按需聚合;不伪装成本地同步状态。 |
|
||||
| `+push` | `drive +upload`(单文件) | 部分对齐:单文件上传可靠;没有可靠的目录 diff、冲突和远端删除传播语义,因此不提供同名批量 push。 |
|
||||
| `+pull` | `drive +download`(单文件) | 部分对齐:单文件下载可靠;目录级增量拉取需稳定路径、hash 与冲突策略,当前接口不完整。 |
|
||||
| `+sync` | 无等价 | gap:在缺少稳定远端内容 hash、rename/delete journal 和冲突版本向量时,双向同步会有数据覆盖风险。 |
|
||||
| `+task_result` | `doc +export-get` / 导入任务恢复入口 | 用户任务对齐;DWS 按任务所属产品提供类型化恢复入口,不保留 Lark 的下划线泛化命令。 |
|
||||
| `+apply-permission` | `drive permission apply` raw leaf | 下层能力存在但未提升为 Shortcut:需要真实申请上下文和权限夹具,无法在通用 E2E 中安全创建。 |
|
||||
| `+member-add` | `doc +access-grant` | 用户任务对齐并增强:解析接收人、批量 ledger、首次写入前停止。 |
|
||||
| `+member-list` | `drive permission list` / `doc +inspect --include-permissions` | 对齐;常规 Agent 场景优先 Doc 聚合检查。 |
|
||||
| `+permission-get-setting` | `drive permission list` + `drive +publish-get` | 部分对齐:协作者与互联网公开是两个独立安全域,没有一个与 Lark setting 完全同构的钉钉接口。 |
|
||||
| `+secure-label-list` | 无等价 | gap:当前 DWS/钉钉下层没有可声明的 Drive 安全标签目录接口。 |
|
||||
| `+secure-label-update` | 无等价 | gap:没有安全标签写接口,不能用普通权限或公开状态替代。 |
|
||||
| `+search` | `drive +search` | 对齐并增强:过滤、严格数组和分页;在线文档搜索路由 `doc +search`。 |
|
||||
| `+inspect` | `drive +inspect` | 对齐并增强:必达元数据 + 可选聚合,部分失败不伪装成功。 |
|
||||
|
||||
## DWS 超出 Lark Drive 的可挖掘能力
|
||||
|
||||
- `+list`:严格目录分页,而不是把缺字段当空目录。
|
||||
- `+recent`:最近访问/编辑与创建人筛选。
|
||||
- `+stats`:阅读、编辑、评论、点赞、预览和下载统计。
|
||||
- `+recycle-list` / `+recycle-restore`:显式回收项身份与恢复后读回。
|
||||
- `+star-list` / `+star-add` / `+star-remove`:个人收藏完整闭环。
|
||||
- `+publish-get` / `+publish-unset`:互联网公开独立安全域与关闭后读回;`+publish-set` 保留为 unavailable 诊断入口。
|
||||
- `+version-download`:历史版本真实字节下载与本地 artifact 校验。
|
||||
- `+rename`:写后读回验证。
|
||||
|
||||
普通钉盘文件的独立 `copy` 是额外确认出的部分 gap:钉钉当前 `doc/copy_document` 对该对象会生成 `.dlink`,不是字节独立副本。`drive +copy` 因此只接受在线对象;普通文件需要快捷入口时用 `+create-shortcut`,需要独立副本时用 `+download` 后 `+upload`。这不是完整的服务端原子 copy,对大文件也不能宣称完全等价。
|
||||
|
||||
互联网公开开启也是账号/对象能力 gap:真实普通文件与在线文档夹具都由服务端返回 `operation.notSupported`。`+publish-get` 与关闭语义可验证,但 `+publish-set` 在找到 eligible 节点完成 set→get→unset 闭环前保持 `unavailable` 且不进入公开 Agent catalog。
|
||||
|
||||
## 端到端门禁
|
||||
|
||||
每个公开 Drive shortcut 必须至少覆盖:
|
||||
|
||||
- Cobra 参数、静态确认、Shortcut Execute、MCP 调度和最终输出;
|
||||
- 明确业务空集合、空响应、缺字段、错误类型、坏元素、`success=false`;
|
||||
- 写入的 ID/终态证据与读回不一致;
|
||||
- 下载的本地路径边界、no-clobber、真实字节数;
|
||||
- 真实账号数据:读命令必须命中已知非空夹具或明确验证合法空集合;写命令必须创建隔离资源、读回、必要时下载比对字节并清理。
|
||||
|
||||
发布前运行 `make build`、完整 Go 测试、Schema 生成/漂移/策略检查,并保存不含账号业务内容的结构化 E2E 汇总。
|
||||
@@ -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 five **contract** items below. Reconnect
|
||||
Scope of this branch: the six **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
@@ -159,6 +159,46 @@ marker; reconnecting an established Stream remains a separate mechanism.
|
||||
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
|
||||
operational recovery instructions.
|
||||
|
||||
### 6. Host runtime-token handoff
|
||||
|
||||
When the root command carries an explicit host-supplied `--token`, personal
|
||||
event control requests and the foreground Stream use that token with higher
|
||||
priority than local OAuth. A detached bus receives it only through the
|
||||
owner-only local IPC transport:
|
||||
|
||||
1. The child starts in runtime-token mode with non-sensitive identity and
|
||||
ticket metadata only; neither its argv nor environment contains the token.
|
||||
2. The consumer sends `Hello` with `credential_mode=runtime_token`.
|
||||
3. The bus advertises the additive `runtime_token_v1` capability and its
|
||||
in-memory credential generation in `HelloAck`.
|
||||
4. Only after that capability is confirmed does the consumer send a bounded
|
||||
`credential_update` frame. The bus applies it with generation CAS, replies
|
||||
with `credential_update_ack`, and registers the consumer only on success.
|
||||
|
||||
The bus blocks ticket acquisition until the first runtime credential arrives.
|
||||
A later invocation may rotate Token A to Token B on a compatible existing bus;
|
||||
the current WebSocket remains connected and the next ticket request or natural
|
||||
reconnect uses B. If a 401 rejects the current runtime token, only an already
|
||||
installed newer generation is retried; the runtime path never refreshes or
|
||||
falls back to a local OAuth profile and never suggests `dws auth login`.
|
||||
|
||||
Clients do not send a token to a bus that lacks the capability, do not stop
|
||||
other consumers automatically, and fail before printing the ready marker. With
|
||||
no explicit `--token`, the original OAuth, refresh, profile, and old-client to
|
||||
new-bus protocol behavior remains unchanged.
|
||||
|
||||
**Verification**
|
||||
- T6a: a stale local Token A and root Token B produce control and ticket
|
||||
requests authenticated only with B.
|
||||
- T6b: compatible bus reuse supports A-to-B rotation and generation conflicts;
|
||||
401 retries only an already-installed newer runtime token.
|
||||
- T6c: an old bus receives no credential and remains running; the new consumer
|
||||
exits before its ready marker.
|
||||
- T6d: a canary credential is absent from child argv/environment, dry-run,
|
||||
stdout/stderr, `bus.meta`, `bus.log`, run state, and returned errors.
|
||||
- T6e: no-token OAuth, refresh, multi-profile, marker/cache, and bus-reuse tests
|
||||
continue to pass.
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# 独立 `meta.pagination` Schema 方案
|
||||
|
||||
## 1. 目标结构
|
||||
|
||||
业务结果与分页控制信息分层:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"outcome": "success",
|
||||
"data": {
|
||||
"items": [{"id": "a"}]
|
||||
},
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"endpoint_exhausted": false,
|
||||
"next_token": "cursor-2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对应 compact/full leaf Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"outcomes": ["success", "failure"],
|
||||
"data_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "当前页业务记录",
|
||||
"items": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"kind": "cursor",
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": "meta.pagination",
|
||||
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
|
||||
"next_token_path": "meta.pagination.next_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`result` 只描述 `data`;`pagination` 是与 `result` 同级的命令能力声明。
|
||||
|
||||
## 2. 分页状态
|
||||
|
||||
| 状态 | `endpoint_exhausted` | `next_token` | Agent 行为 |
|
||||
|---|---:|---|---|
|
||||
| 可续跑 | `false` | 必须非空 | 将 token 传给 `--<cursor_parameter>` |
|
||||
| 已耗尽 | `true` | 必须省略 | 停止翻页 |
|
||||
|
||||
`endpoint_exhausted:true` 只表示观察到 Endpoint 分页耗尽,不表示搜索索引
|
||||
健康、数据全量覆盖或业务对象不存在。
|
||||
|
||||
## 3. 映射规则
|
||||
|
||||
产品 mapper 可以读取服务端原始 `hasMore/nextCursor`、`has_more/page_token`
|
||||
等字段,但统一 CLI 输出只公布 `meta.pagination`:
|
||||
|
||||
- 服务端表示还有下一页且 cursor 非空 → `endpoint_exhausted:false` + token。
|
||||
- 服务端表示没有下一页 → `endpoint_exhausted:true`,不带 token。
|
||||
- 表示还有下一页但 cursor 缺失、类型错误或证据冲突 → typed
|
||||
`pagination_inconsistent`,禁止伪装终页。
|
||||
- mapper 使用同一份上游响应构造 `data` 与 `meta`,不得重新请求。
|
||||
|
||||
原始分页控制字段不进入新的 `result.data_schema`。未迁移命令保持 legacy;
|
||||
已迁移命令按命令独立切换和回滚,不通过 Agent 参数选择协议。
|
||||
|
||||
## 4. Schema 规则
|
||||
|
||||
- `kind` 当前只允许 `cursor`。
|
||||
- `cursor_parameter` 是真实 canonical CLI flag 名,不带 `--`,并必须存在于
|
||||
同一 leaf 的 `parameters`。
|
||||
- 三个 meta path 由框架固定生成,产品不能覆盖。
|
||||
- compact/full leaf 同时包含相同的 `result` 和 `pagination`。
|
||||
- product/group 导航摘要不复制分页对象;Agent 需要时查询具体 compact leaf。
|
||||
- 没有 `pagination` 表示该命令尚未发布经评审的分页能力,Agent 不得猜测。
|
||||
|
||||
## 5. 渐进接入
|
||||
|
||||
1. **legacy_only**:保持原输出,不公布分页声明。
|
||||
2. **dual_validate**:业务执行一次;影子构造并校验 `meta.pagination`,外部
|
||||
legacy 字节不变。
|
||||
3. **unified_active**:输出独立 `meta.pagination`,Schema 公布同级
|
||||
`pagination` 声明。
|
||||
4. **unified_stable**:Skill、示例和 Agent 审计均只读取 meta 分页。
|
||||
|
||||
不增加 `contract_version`、`--output-contract` 或分页协议别名。
|
||||
|
||||
## 6. 验收
|
||||
|
||||
每个分页命令至少验证:
|
||||
|
||||
1. 有下一页时 `endpoint_exhausted:false` 且 token 非空。
|
||||
2. 终页和空终页为 `endpoint_exhausted:true` 且无 token。
|
||||
3. 分页矛盾产生 typed failure,不 panic、不静默停止。
|
||||
4. `cursor_parameter` 在 Help/Schema 中真实存在。
|
||||
5. compact/full 的 `result`、`pagination` 分别 JSON 等价。
|
||||
6. `data_schema` 不包含分页控制字段。
|
||||
7. 运行时 `data` 不包含迁移后的分页控制字段。
|
||||
8. dual validate 与 active 都只消费一次上游响应。
|
||||
9. Agent 逐命令扫描结果进入评测台账;不提交生成 Schema JSON fixture。
|
||||
|
||||
### DevApp 首批落地
|
||||
|
||||
以下 8 个终结命令已发布独立 `pagination` Schema;运行时统一输出只在
|
||||
`meta.pagination` 返回分页控制信息:
|
||||
|
||||
- `dev app list`
|
||||
- `dev app permission list`
|
||||
- `dev app event list`
|
||||
- `dev app version list`
|
||||
- `devapp +list`
|
||||
- `devapp +permission-list`
|
||||
- `devapp +event-list`
|
||||
- `devapp +version-list`
|
||||
|
||||
两套既有命令前缀继续保留。原子命令的业务记录字段为 `data.items`;Shortcut
|
||||
保留既有业务投影(例如 `data.apps`、`data.permissions`、`data.events`、
|
||||
`data.versions` 以及 `data.count`),但两套入口都不再在业务数据中公布
|
||||
`hasMore/nextCursor`。
|
||||
|
||||
## 7. 对齐依据
|
||||
|
||||
GWS 用请求参数和 response schema 描述分页事实;Lark 在统一输出层维护分页
|
||||
元数据。DWS 采用更明确的分层:业务 `data` 保真承载记录,框架 `meta` 承载
|
||||
续跑状态,Schema 用独立能力把 token 与下一次 CLI 参数连接起来。
|
||||
File diff suppressed because it is too large
Load Diff
+11
-10
@@ -94,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 / 试运行
|
||||
@@ -119,27 +119,28 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
|
||||
|
||||
```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` 本身不会读取或搜索业务内容。
|
||||
|
||||
+13
-2
@@ -17,7 +17,8 @@
|
||||
|
||||
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`。
|
||||
3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的
|
||||
release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。
|
||||
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 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
@@ -94,7 +95,8 @@ main 上的候选代码 + beta CHANGELOG
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
|
||||
如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的
|
||||
fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1
|
||||
@@ -132,6 +134,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
|
||||
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
|
||||
|
||||
### Release fragments
|
||||
|
||||
普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在
|
||||
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
|
||||
[`.changes/README.md`](../.changes/README.md)。预发封板时
|
||||
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
|
||||
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
|
||||
|
||||
## 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 证据。
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# RFC:DWS 预制 Skill 安装、升级与模式迁移
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| 状态 | Accepted / as implemented |
|
||||
| 生效范围 | DWS CLI、升级器、npm 与平台安装脚本 |
|
||||
| 事实源 | 本 RFC 与当前代码;两者冲突时以代码和测试为准 |
|
||||
| 关联合同 | [Skill 内容框架](skill-content-framework.md)、[Mono↔Multi 内容质检](skill-mono-multi-qa.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
DWS 同时通过 CLI、升级器、npm、Shell 和 PowerShell 分发预制 Skill。multi
|
||||
成为默认布局后,所有入口必须对安装集合、模式互斥、失败退出、缓存发布和目录
|
||||
所有权保持一致。此前分散的调研、迁移计划、阶段性 roadmap 和 rollout 文档容易
|
||||
相互冲突;本 RFC 将最终行为收敛为一个长期合同。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 新装与升级默认使用 multi 布局,mono 在兼容期内保留显式 opt-in。
|
||||
- 每次升级使用当前版本的官方清单全量覆盖预制 Skill。
|
||||
- 删除或替换任何目录前先创建可恢复备份,备份失败不修改该 Agent 目标。
|
||||
- 只清理能够证明由 DWS 管理的目录,不通过名称前缀推断所有权。
|
||||
- 所有安装入口对部分失败返回非零状态,不误报整体成功。
|
||||
- 安装预览、确认和实际执行使用同一份计划。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不建设独立的 `dws skill mode status|set|rollback` 产品面。
|
||||
- 不持久化用户对预制 Skill 的本地删除或排除意图。
|
||||
- 不提供跨所有 Agent 目标的事务式回滚。
|
||||
- 不把市场 Skill 纳入预制 Skill 的升级和清理范围。
|
||||
|
||||
## 3. 业内调研
|
||||
|
||||
对主流 CLI 与 Agent Skill 分发方式的公开实现进行归纳后,可以得到以下共性:
|
||||
|
||||
| 观察 | 对 DWS 的启示 |
|
||||
|---|---|
|
||||
| 多个产品能力通常以同级 Skill 目录安装,由 Agent 按目录发现 | multi 使用平铺的产品 Skill,并保留一个共享 Skill 承载公共协议 |
|
||||
| CLI 本体安装和 Agent Skill 安装是两个生命周期 | DWS 可以在 CLI 安装、setup 和 upgrade 中触发 Skill 同步,但二者的失败与状态必须分别报告 |
|
||||
| 生态安装器通常天然采用 multi,不提供 mono/multi 状态机 | DWS 的模式切换保持为重新执行 setup,不新增长期驻留的 mode lifecycle |
|
||||
| 市场 Skill 与 CLI 预制 Skill 可能落在同一 Agent 根目录 | 必须使用统一所有权元数据识别受管目录,名称前缀不能作为删除依据 |
|
||||
| 多 Skill 更新常以新清单刷新官方集合 | DWS 使用当前 bundle 官方清单全量覆盖,新增 Skill 自动加入,本地删除不视为持久化排除 |
|
||||
| 制品可能需要同时服务无运行时依赖、离线和多镜像环境 | DWS 保留 embed、zip 和平台安装脚本,不把单一生态包管理器设为唯一入口 |
|
||||
| 中断的复制和原地覆盖容易破坏最后一个可用版本 | 缓存与 Go upgrade 的 Agent 目标采用 staging publish;发布失败自动恢复该目标的完整旧集合 |
|
||||
| Agent 通常以 `SKILL.md` 为入口,其他文件按引用或工具规则按需读取 | 安装元数据使用不被内容引用的隐藏文件,并保证其内容不包含 Agent 指令 |
|
||||
|
||||
本节只保留可复用的工程结论,不记录具体产品、仓库、版本或逐项能力对照,也不构成
|
||||
DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约束和本 RFC 的行为合同为准。
|
||||
|
||||
## 4. 布局合同
|
||||
|
||||
| 模式 | Agent 目录布局 | 选择方式 |
|
||||
|---|---|---|
|
||||
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
|
||||
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
|
||||
|
||||
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
|
||||
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
|
||||
不可恢复的删除。
|
||||
|
||||
## 5. 官方集合与升级策略
|
||||
|
||||
当前版本 bundle 中的 multi 目录清单是升级集合的唯一权威来源。普通 upgrade 和
|
||||
`--force` 都安装并覆盖该版本的全部官方预制 Skill:
|
||||
|
||||
- 本地删除的预制 Skill 会在下一次升级恢复;
|
||||
- setup 时通过 `--exclude` 暂时排除的 Skill 会在下一次升级恢复;
|
||||
- 新版本新增的官方 Skill 会自动安装;
|
||||
- 用户对预制 Skill 的本地修改会被官方版本覆盖;
|
||||
- `dingtalk-shared` 始终随官方集合安装。
|
||||
|
||||
`~/.dws/skills-state.json`(设置 `DWS_CONFIG_DIR` 时位于该目录)不参与安装集合
|
||||
求解,也不保存排除策略。它既记录结果快照,也集中记录 multi Skill 的所有权和
|
||||
provenance,供安全清理、诊断与后续迁移使用。
|
||||
|
||||
## 6. 目录所有权
|
||||
|
||||
每次 multi setup 或 upgrade 全部成功后,DWS 在统一的
|
||||
`~/.dws/skills-state.json` 中写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v0.2.14",
|
||||
"official_skills": ["dingtalk-aitable"],
|
||||
"updated_skills": ["dingtalk-aitable"],
|
||||
"managed_skills": [
|
||||
{
|
||||
"name": "dingtalk-aitable",
|
||||
"version": "v0.2.14",
|
||||
"source": "dws-upgrade",
|
||||
"digest": "sha256:<64 个十六进制字符>",
|
||||
"digest_scope": "skill-directory-v1"
|
||||
}
|
||||
],
|
||||
"updated_at": "2026-08-11T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
每条 `managed_skills` 记录代表一个由 DWS 管理的官方 Skill。`version` 记录安装该
|
||||
副本的 DWS/发布包版本,`source` 记录安装入口,`digest` 是对 bundle 中 Skill 目录
|
||||
全部普通文件按相对路径排序后计算的内容摘要。摘要用于诊断和来源追踪,不作为后续
|
||||
升级的完整性门禁;用户修改 Skill 内容后,DWS 仍保有明确管理权并能在下一次升级时
|
||||
覆盖恢复。
|
||||
|
||||
清理 stale Skill 或切换到 mono 时,只接受以下所有权证据:
|
||||
|
||||
1. Skill 名称存在于统一状态的 `managed_skills` 中;
|
||||
2. 统一状态上线前曾发布过的官方 Skill 精确名称集合。
|
||||
|
||||
历史集合是冻结的迁移清单,包含 `dws-shared` 以及已退役、折叠或仍在发布的旧官方
|
||||
目录名。仅有 `dingtalk-*` 前缀不构成所有权证据。因此,市场或用户创建的
|
||||
`dingtalk-custom` 等非官方精确名称目录不会被迁走。
|
||||
|
||||
### 6.1 对 Agent 的影响
|
||||
|
||||
Skill 目录内不再放置 DWS 所有权文件,也不增加非通用 frontmatter 字段。支持的
|
||||
Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agent Skill 目录之外,
|
||||
不会成为提示词上下文或影响 Agent 行为。
|
||||
|
||||
## 7. Setup:Plan → Confirm → Execute
|
||||
|
||||
`dws skill setup` 分为三个阶段:
|
||||
|
||||
1. **Plan**:只读计算目标、安装集合以及所有待备份路径;
|
||||
2. **Confirm**:`--dry-run` 和交互确认渲染同一份计划;
|
||||
3. **Execute**:确认后严格执行计划中的备份和安装。
|
||||
|
||||
安全要求:
|
||||
|
||||
- 非交互环境未传 `--yes` 时拒绝执行;
|
||||
- 用户拒绝确认时必须零文件写入;
|
||||
- 备份失败时跳过整个 Agent 目标,不开始铺设相反布局;
|
||||
- 同一目标先完成所有必要备份,再复制新集合;
|
||||
- multi Skill 必须在同级 staging 中完成复制,再原子发布到正式目录;
|
||||
- 任意 `skipped > 0` 都返回非零退出码,并且不写入完整成功快照;
|
||||
- 一个 Agent 目标失败不阻止其他目标尝试,但最终结果仍为失败。
|
||||
|
||||
## 8. Upgrade 与恢复语义
|
||||
|
||||
升级器对每个 Agent 目标执行:
|
||||
|
||||
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
|
||||
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
|
||||
|
||||
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
|
||||
2. 在目标文件系统的 staging 中复制完整新集合;
|
||||
3. staging 全部成功后,才将旧集合移入备份目录;
|
||||
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
|
||||
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
|
||||
|
||||
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
|
||||
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
|
||||
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
|
||||
“不提供跨所有 Agent 目标的事务式回滚”非目标保持一致。
|
||||
|
||||
## 9. 备份合同
|
||||
|
||||
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
|
||||
- 主要操作:同一文件系统内使用 rename 移动;
|
||||
- 失败语义:备份失败时原目录保持不变,目标安装失败;
|
||||
- 可见性:计划和执行日志显示原路径与备份路径;
|
||||
- 保留策略:自动修剪,仅保留最近 5 批。
|
||||
|
||||
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
|
||||
`dws skill setup --mode mono`。
|
||||
|
||||
## 10. 缓存与制品
|
||||
|
||||
发布制品和二进制内嵌内容同时携带 mono 与 multi 源树。`~/.dws/skills/` 只是
|
||||
setup 在未显式指定 `--source` 时的本地回退缓存。
|
||||
|
||||
缓存刷新必须采用同级 staging + publish:
|
||||
|
||||
1. 在 staging 中完整复制并验证新树;
|
||||
2. 发布前保留旧缓存;
|
||||
3. 通过 rename 发布新缓存;
|
||||
4. 复制或发布失败时保留或恢复旧缓存;
|
||||
5. 空、缺失或损坏的 bundle 不能擦除有效缓存。
|
||||
|
||||
## 11. 安装入口一致性
|
||||
|
||||
以下入口都遵守本 RFC:
|
||||
|
||||
| 入口 | 默认模式 | 失败合同 |
|
||||
|---|---|---|
|
||||
| `dws skill setup` | multi | 部分失败返回非零;不写完整成功状态 |
|
||||
| `dws upgrade` | bundle 含 multi 时安装 multi | 目标失败返回失败;下次全量重试 |
|
||||
| `scripts/install.sh` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
|
||||
|
||||
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
|
||||
|
||||
## 12. 验收与回归门禁
|
||||
|
||||
合入和后续修改至少覆盖:
|
||||
|
||||
- mono → multi、multi → mono 互斥切换;
|
||||
- 状态上线前的官方 multi 目录切换 mono 时能够被精确迁移;
|
||||
- 未登记的同前缀市场/用户 Skill 在刷新和切换后仍存在;
|
||||
- 统一状态中登记的过期官方 Skill 被备份并移除;
|
||||
- 备份、复制、统一状态写入、缓存 publish 故障注入;
|
||||
- 非交互确认拒绝与显式 `--yes`;
|
||||
- 部分失败返回非零且不写错误状态快照;
|
||||
- 复制失败不留下 Agent 可见的残缺官方目录;
|
||||
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
|
||||
- Windows、macOS、Linux 的路径和覆盖率门禁;
|
||||
- npm、Shell、PowerShell 与包管理器安装冒烟。
|
||||
|
||||
## 13. 后续演进
|
||||
|
||||
- 收敛各安装入口中的 Agent home 清单,减少跨语言复制;
|
||||
- 如确有运维需求,可单独设计备份查看和显式恢复命令;
|
||||
- mono 的物理删除必须作为独立变更,在 multi 内容、安装入口和迁移回归稳定后推进;
|
||||
- `managed_skills` 字段若演进,必须同步更新所有安装入口和跨平台回归。
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
|
||||
@@ -231,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 冲突是契约漂移,不能静默猜测:
|
||||
@@ -246,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 导出
|
||||
```
|
||||
|
||||
@@ -257,7 +258,7 @@ 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 查询业务数据。
|
||||
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# lark-cli Shortcut 深度对齐矩阵
|
||||
|
||||
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
|
||||
|
||||
## 2026-07-13 最新源码复核
|
||||
|
||||
对比基线:
|
||||
|
||||
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
|
||||
- lark-cli:`main@e96c4fa5`
|
||||
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
|
||||
|
||||
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
|
||||
|
||||
### 当前命令面快照
|
||||
|
||||
| 指标 | 数量 | 说明 |
|
||||
|---|---:|---|
|
||||
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
|
||||
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
|
||||
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
|
||||
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
|
||||
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
|
||||
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
|
||||
|
||||
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
|
||||
|
||||
| 产品映射 | DWS | lark | 同名 |
|
||||
|---|---:|---:|---:|
|
||||
| aitable ↔ base | 82 | 87 | 31 |
|
||||
| calendar ↔ calendar | 23 | 10 | 3 |
|
||||
| chat ↔ im | 89 | 21 | 2 |
|
||||
| contact ↔ contact | 16 | 2 | 1 |
|
||||
| devapp ↔ apps | 30 | 63 | 3 |
|
||||
| doc ↔ doc | 19 | 14 | 1 |
|
||||
| drive ↔ drive | 9 | 26 | 3 |
|
||||
| mail ↔ mail | 10 | 21 | 0 |
|
||||
| minutes ↔ minutes | 13 | 9 | 1 |
|
||||
| sheet ↔ sheets | 2 | 42 | 0 |
|
||||
| todo ↔ task | 13 | 17 | 2 |
|
||||
| wiki ↔ wiki | 7 | 12 | 3 |
|
||||
|
||||
### 最新优先差距
|
||||
|
||||
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
|
||||
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
|
||||
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
|
||||
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
|
||||
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
|
||||
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
|
||||
|
||||
### 不建议机械追平
|
||||
|
||||
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
|
||||
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
|
||||
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
|
||||
|
||||
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
|
||||
|
||||
## 汇总(361 条 lark shortcut)
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **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 条,已建 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 + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(5)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+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投影 |
|
||||
| `+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 富化、资源下载 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
|
||||
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
|
||||
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
|
||||
|
||||
### calendar → calendar(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
|
||||
|
||||
### doc (docs) → doc(2)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
|
||||
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
|
||||
|
||||
### drive → drive(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
|
||||
|
||||
### mail → mail(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
|
||||
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
|
||||
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
|
||||
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
|
||||
|
||||
### wiki → wiki(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
|
||||
|
||||
### minutes → minutes(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
|
||||
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
|
||||
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
|
||||
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
|
||||
|
||||
### base → aitable(10)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
|
||||
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
|
||||
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
|
||||
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
|
||||
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
|
||||
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
|
||||
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
|
||||
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
|
||||
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
|
||||
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
|
||||
|
||||
### sheets → sheet(14)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
|
||||
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
|
||||
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
|
||||
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
|
||||
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
|
||||
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
|
||||
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
|
||||
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
|
||||
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
|
||||
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
|
||||
|
||||
### apps → devapp(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
|
||||
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
|
||||
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **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
|
||||
- **doc (docs)**: +history-revert
|
||||
- **drive**: +upload +search +inspect
|
||||
- **mail**: +triage
|
||||
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
|
||||
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
|
||||
+1277
-101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
# DWS Skill 内容框架合同
|
||||
|
||||
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
|
||||
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
|
||||
> 安装、升级与模式迁移见
|
||||
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.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. 变更流程
|
||||
|
||||
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,74 @@
|
||||
# Mono↔Multi Skill 内容质检规格
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
> 安装、升级与模式迁移见
|
||||
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
|
||||
|
||||
## 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/…)。
|
||||
@@ -0,0 +1,108 @@
|
||||
# DWS 统一命令框架设计概要
|
||||
|
||||
> 状态:Framework core 已实现,dingtalk-dev/devapp 首批命令渐进接入中。本文定义框架能力、集成边界和首批 pilot 的发布纪律;其余产品命令迁移、Skill 更新和真实服务复验继续由后续 PR 独立完成。
|
||||
|
||||
## 1. 产品裁决
|
||||
|
||||
1. 不公开 `--output-contract`,也不增加任何等价别名。
|
||||
2. Agent 继续只使用既有 `--format json`。
|
||||
3. 每条 terminal command 在一个 release 中只有一个 active wire contract:已迁移命令直接使用统一结果,未迁移命令保持 legacy。
|
||||
4. contract 不由用户参数、环境变量、会话能力协商或 Agent 选择。
|
||||
5. 回滚是命令声明与发布行为,不改变消费者 argv。
|
||||
6. 本 PR 只迁移完成命令级兼容审计的 dingtalk-dev/devapp pilot;其他命令路径、参数和输出保持不变。
|
||||
|
||||
## 2. 渐进迁移
|
||||
|
||||
内部状态机:
|
||||
|
||||
```text
|
||||
legacy_only -> dual_validate -> unified_active -> unified_stable -> unified_only
|
||||
```
|
||||
|
||||
- `legacy_only`:只构造、输出 legacy。
|
||||
- `dual_validate`:业务只执行一次;外部仍逐字输出 legacy;同一内存结果 shadow-build 统一结果并严格校验。
|
||||
- `unified_active`:`--format json` 直接返回统一结果信封,可按发布声明回退。
|
||||
- `unified_stable`:完成真实 Agent 消费观察和兼容窗口。
|
||||
- `unified_only`:清理仅服务 legacy 的产品 renderer。
|
||||
|
||||
状态是每条 terminal command 的内部发布元数据。Help、Skill、Agent Schema 不展示迁移状态,也不让消费者选择协议。
|
||||
|
||||
## 3. 统一结果
|
||||
|
||||
统一命令框架表达四类结果:
|
||||
|
||||
```text
|
||||
success 请求完成且命令认为操作已完成
|
||||
pending 请求被受理,但异步操作尚未终结
|
||||
partial_failure 批量操作有成功项,也有失败或未知项
|
||||
failure 请求或操作失败
|
||||
```
|
||||
|
||||
JSON 基本形态:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"outcome": "success",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
硬不变量:
|
||||
|
||||
```text
|
||||
ok == (outcome in {success, pending})
|
||||
process rc == 0 <=> ok == true
|
||||
top-level error present <=> outcome == failure
|
||||
one invocation emits exactly one primary result
|
||||
```
|
||||
|
||||
框架负责 L1 request outcome 和 L2 operation outcome 的统一表达;L3 verification 必须由产品命令基于业务事实实现,框架不得自动推断 `changed/verified`。
|
||||
|
||||
## 4. 输出与错误纪律
|
||||
|
||||
- 统一 JSON primary result 写 stdout;stderr 只写诊断。普通命令不把
|
||||
NDJSON 作为通用结果契约;持续事件流若需要逐事件输出,由 event 命令
|
||||
自己声明专用流协议。
|
||||
- 分页统一输出到信封 `meta.pagination`,并在命令 Schema 中作为与 `result`
|
||||
同级的 `pagination` 能力声明;`result.data_schema` 只描述业务 data,不再
|
||||
混入分页控制字段。
|
||||
- 日志不得污染 stdout。
|
||||
- `ok`、`retryable`、`dry_run` 等必须是 JSON boolean。
|
||||
- 失败由框架根据 typed error 映射退出码;产品代码不能自报任意 rc。
|
||||
- `partial_failure` 保留 `succeeded[]/failed[]/unknown[]`,使用非零 rc 7。
|
||||
- `pending` 必须提供 operation id、state 和可执行的 `next_command`。
|
||||
- `endpoint_exhausted` 只表示观察到当前 endpoint 分页耗尽;false 必须带 `next_token`,不得扩大成索引健康或业务数据完整。
|
||||
- dry-run 是已经完成的无副作用预览,表达为 `success + dry_run:true`,不是 `pending`。
|
||||
|
||||
## 5. 重试与超时边界
|
||||
|
||||
- 框架只统一表达 `retryable`、`retry_after_seconds` 和 `execution_started`,不自动决定业务操作能否安全重放。
|
||||
- 写调用的模糊失败、HTTP timeout 和异步等待预算属于 transport/产品集成范围,不在本 PR 改动。
|
||||
- 产品迁移必须证明其重试声明与幂等性、安全等级一致。
|
||||
|
||||
## 6. 集成范围
|
||||
|
||||
- 产品命令通过 `corecmd.ResultInvoke` 构造 `CommandResult`,由 root 单一出口渲染。
|
||||
- 首批 dingtalk-dev/devapp 命令用于验证原子命令与 shortcut 的接入缝;未进入 pilot 的 shortcut、长连接、批量写和异步任务各自需要独立集成 PR。框架 core 不替产品推断 success、pending、partial 或分页事实。
|
||||
- 每条 terminal command 独立 rollout;不能整域一次切换,也不能通过 Agent 参数选择协议。
|
||||
- 已有命令在进入 `unified_active` 前必须保留 legacy byte golden,并完成真实 Agent 语义扫描。
|
||||
|
||||
## 7. 对齐原则
|
||||
|
||||
- 对齐 Lark CLI:统一 envelope/emitter、typed error、partial、pending、分页窄语义和强类型结果。
|
||||
- 对齐 GWS:机器结果稳定结构化、日志与数据分流、消费者不协商协议版本。
|
||||
- DWS 保留差异:声明式 Agent Schema、安全门禁、静态命令与 shortcut 共存,以及四 outcome 模型。
|
||||
|
||||
## 8. 发布门禁
|
||||
|
||||
命令晋级 `unified_active` 前至少满足:
|
||||
|
||||
1. success/failure/dry-run golden;批量或异步命令另有 partial/pending golden。
|
||||
2. 业务请求 exactly once;dual validation 不得二次调用服务端。
|
||||
3. legacy 命令 stdout/stderr/rc 字节级回归不变。
|
||||
4. Help、Schema 和全仓示例不存在协议选择参数。
|
||||
5. `--format json` 输出单个合法统一结果文档,stdout 无日志污染。
|
||||
6. typed error、进程 rc 与信封 `error.exit_code` 一致。
|
||||
7. 安全声明、确认门禁与 dry-run 运行时行为同源。
|
||||
8. Agent 语义扫描记录命令级迁移证据;发布回滚无需修改 Agent argv。
|
||||
@@ -14,7 +14,7 @@ require (
|
||||
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/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
|
||||
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 h1:5WwR5TV6A12taXMH7SggT8yCMMJMF9jWE7Wj+4AuHck=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
|
||||
@@ -335,12 +335,6 @@ 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() != "" {
|
||||
@@ -360,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)
|
||||
@@ -389,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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 (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageReceiptActionsBindToRunnableCommands(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
}{
|
||||
{
|
||||
name: "send receipt awaiting status",
|
||||
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
|
||||
"openTaskId": "task-pending",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "send receipt ready for message actions",
|
||||
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
|
||||
"openTaskId": "task-ready",
|
||||
"openMessageId": "message-ready",
|
||||
"openConversationId": "conversation-ready",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "send status awaiting message reference",
|
||||
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
|
||||
"status": "PENDING",
|
||||
}, "task-pending"),
|
||||
},
|
||||
{
|
||||
name: "send status ready for message actions",
|
||||
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
|
||||
"openTaskId": "task-ready",
|
||||
"openMessageId": "message-ready",
|
||||
"openConversationId": "conversation-ready",
|
||||
"status": "SUCCESS",
|
||||
}, "task-ready"),
|
||||
},
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
actions, ok := testCase.payload["nextActions"].([]map[string]any)
|
||||
if !ok || len(actions) == 0 {
|
||||
t.Fatalf("nextActions = %#v, want non-empty []map[string]any", testCase.payload["nextActions"])
|
||||
}
|
||||
|
||||
for index, action := range actions {
|
||||
cliPath, ok := action["cliPath"].(string)
|
||||
if !ok || strings.TrimSpace(cliPath) == "" {
|
||||
t.Fatalf("nextActions[%d].cliPath = %#v, want non-empty string", index, action["cliPath"])
|
||||
}
|
||||
|
||||
command, remaining, err := root.Find(strings.Fields(cliPath))
|
||||
if err != nil {
|
||||
t.Fatalf("nextActions[%d].cliPath %q does not bind: %v", index, cliPath, err)
|
||||
}
|
||||
if command == nil || len(remaining) != 0 || !command.Runnable() {
|
||||
t.Fatalf("nextActions[%d].cliPath %q resolved to command=%v remaining=%v runnable=%v", index, cliPath, command, remaining, command != nil && command.Runnable())
|
||||
}
|
||||
|
||||
arguments, ok := action["arguments"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nextActions[%d].arguments = %#v, want map[string]any", index, action["arguments"])
|
||||
}
|
||||
for name := range arguments {
|
||||
if command.Flags().Lookup(name) == nil && command.InheritedFlags().Lookup(name) == nil {
|
||||
t.Errorf("nextActions[%d] argument %q is not a flag of runnable command %q", index, name, cliPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -32,8 +32,8 @@ 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/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -227,115 +227,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 || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("missing recovery endpoint error = %v", err)
|
||||
} else {
|
||||
var apiErr *apperrors.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
|
||||
t.Fatalf("missing recovery endpoint classification = %#v", err)
|
||||
}
|
||||
}
|
||||
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
|
||||
runtime := &recoveryRuntime{}
|
||||
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
|
||||
t.Fatalf("recovery endpoint override = %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() {
|
||||
@@ -511,59 +402,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) })
|
||||
@@ -760,7 +598,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)
|
||||
@@ -1747,9 +1585,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)
|
||||
}
|
||||
@@ -2168,7 +2003,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)
|
||||
@@ -2193,12 +2028,16 @@ 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)
|
||||
}
|
||||
state, readable, err := skillstate.Read(home)
|
||||
if err != nil || !readable || len(state.OfficialSkills) != 3 || len(state.UpdatedSkills) != 2 {
|
||||
t.Fatalf("setup state = %#v, readable=%v, err=%v", state, readable, err)
|
||||
}
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
|
||||
t.Fatalf("multi dry run = %q, %v", output, err)
|
||||
}
|
||||
@@ -2214,13 +2053,16 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("invalid setup %#v succeeded", args)
|
||||
}
|
||||
}
|
||||
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("default mono setup: %v", err)
|
||||
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("mono setup: %v", err)
|
||||
}
|
||||
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
|
||||
t.Fatalf("default mode should be multi: %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -2238,7 +2080,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"})
|
||||
@@ -2248,7 +2090,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing multi source succeeded")
|
||||
}
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
|
||||
t.Fatalf("default setup mode = %q, %v", mode, err)
|
||||
}
|
||||
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
|
||||
@@ -2283,7 +2125,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
for _, tc := range []struct{ path, mode string }{{"", skillSetupModeMono}, {mono, skillSetupModeMono}, {filepath.Dir(multi), skillSetupModeMulti}, {root, "bad"}} {
|
||||
_ = isSkillSourceRoot(tc.path, tc.mode)
|
||||
}
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
setTestHome(t, t.TempDir())
|
||||
for _, tc := range []struct{ target, mode string }{{"agents", skillSetupModeMono}, {"agents", skillSetupModeMulti}, {"all", skillSetupModeMono}, {"missing", skillSetupModeMono}} {
|
||||
_, _ = resolveSkillSetupTargets(tc.target, tc.mode)
|
||||
}
|
||||
@@ -2291,8 +2133,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
_ = agentHomeForMode("base", skillSetupModeMulti)
|
||||
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
|
||||
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
|
||||
_ = mutualExclusionVictims(root, mode)
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
|
||||
_, _ = mutualExclusionVictims(root, mode)
|
||||
}
|
||||
if isCharDevice(nil) || isInteractiveTerminal() {
|
||||
t.Fatal("test process unexpectedly interactive")
|
||||
@@ -2300,17 +2142,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
|
||||
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
multiDest := filepath.Join(t.TempDir(), "agent")
|
||||
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
|
||||
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
|
||||
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
|
||||
|
||||
badParent := filepath.Join(t.TempDir(), "file")
|
||||
_ = os.WriteFile(badParent, []byte("x"), 0o600)
|
||||
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
|
||||
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
|
||||
t.Fatal("copy missing directory succeeded")
|
||||
}
|
||||
|
||||
@@ -302,7 +302,6 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(&GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"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/registry"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
@@ -74,18 +75,18 @@ var (
|
||||
|
||||
// newEventCommand returns the `event` parent command and all its subcommands.
|
||||
// Wired into root.go's utilityCommands list.
|
||||
func newEventCommand() *cobra.Command {
|
||||
func newEventCommand(globalFlags ...*GlobalFlags) *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: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
|
||||
AgentSummary: "实时监听当前用户相关的个人 IM 与 OA 审批事件,并管理订阅生命周期",
|
||||
UseWhen: []string{
|
||||
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
|
||||
"需要实时监听未来发生的个人消息、消息动作、群生命周期或 OA 审批任务/实例事件,或管理个人事件订阅生命周期",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
|
||||
"查历史聊天或主动发消息用 chat;查询或处理审批实例/任务用 oa;配置开放平台应用事件回调用 dev app event",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -99,12 +100,12 @@ func newEventCommand() *cobra.Command {
|
||||
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newEventListenIMCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventListenIMCommand(globalFlags...),
|
||||
newEventConsumeCommand(globalFlags...),
|
||||
newEventListCommand(),
|
||||
newEventSchemaCommand(),
|
||||
newEventStatusCommand(),
|
||||
newEventStopCommand(),
|
||||
newEventStatusCommandWithFlags(globalFlags...),
|
||||
newEventStopCommandWithFlags(globalFlags...),
|
||||
newEventBusCommand(),
|
||||
)
|
||||
return cmd
|
||||
@@ -114,7 +115,7 @@ func newEventCommand() *cobra.Command {
|
||||
// event consume
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newEventConsumeCommand() *cobra.Command {
|
||||
func newEventConsumeCommand(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
eventTypes []string
|
||||
filter string
|
||||
@@ -170,6 +171,8 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
return err
|
||||
}
|
||||
if as == "user" {
|
||||
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
|
||||
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
|
||||
personalOpts.EventKeys = dedupePersonalEventKeys(args)
|
||||
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
|
||||
personalOpts.Flatten = flatten
|
||||
@@ -333,7 +336,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
f.BoolVar(&force, "force", false,
|
||||
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
|
||||
f.BoolVar(&dryRun, "dry-run", false,
|
||||
"仅打印解析后的配置,不连接 bus / 云端")
|
||||
"仅打印解析后的配置;不创建订阅、不连接 bus;复用 --subscribe-id 时会只读查询控制面")
|
||||
f.BoolVar(&foreground, "foreground", false,
|
||||
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
|
||||
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
|
||||
@@ -398,22 +401,21 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
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",
|
||||
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"用户明确要求监听当前身份的所有单聊或所有群消息",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
|
||||
"同一目标、同一过滤条件需要同时监听多个兼容事件",
|
||||
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"普通 @我、指定发送人/群、全部单聊/群聊及 message/reaction/read/recall 监听优先使用 event +listen-im",
|
||||
"只查历史聊天记录时用 chat 查询命令",
|
||||
"查询、同意、拒绝、转交、撤销或发起审批时用 oa;配置应用事件回调时用 dev app event",
|
||||
"只看事件目录/字段时用 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",
|
||||
"dws event consume user_oa_approval_task_created user_oa_approval_instance_finished --flatten --duration 10m --format ndjson",
|
||||
"dws event consume user_im_group_member_added --group cid-example --flatten --max-events 1 --format ndjson",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -564,6 +566,8 @@ func newEventBusCommand() *cobra.Command {
|
||||
clientIDOverride string
|
||||
idleTimeout time.Duration
|
||||
sourceKindRaw string
|
||||
runtimeTokenMode bool
|
||||
identityHashFlag string
|
||||
streamOpts eventStreamTicketOptions
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
@@ -600,23 +604,45 @@ func newEventBusCommand() *cobra.Command {
|
||||
sourceKind = dwsevent.SourceKindAppStream
|
||||
}
|
||||
if sourceKind == dwsevent.SourceKindPersonalStream {
|
||||
identity, err := eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
|
||||
if err != nil {
|
||||
return failEarly(fmt.Errorf("event _bus: %w", err))
|
||||
var (
|
||||
identity personal.Identity
|
||||
identityHash string
|
||||
)
|
||||
if runtimeTokenMode {
|
||||
identityHash = strings.TrimSpace(identityHashFlag)
|
||||
if !validPersonalIdentityHash(identityHash) {
|
||||
return failEarly(errors.New("event _bus: --identity-hash must be a 16-character hexadecimal identity hash in runtime token mode"))
|
||||
}
|
||||
if strings.TrimSpace(clientIDOverride) == "" {
|
||||
return failEarly(errors.New("event _bus: --client-id is required in runtime token mode"))
|
||||
}
|
||||
identity = personal.Identity{
|
||||
ClientID: strings.TrimSpace(clientIDOverride),
|
||||
SourceID: personalEventStreamSourceID(streamOpts.SourceID),
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
identity, err = eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
|
||||
if err != nil {
|
||||
return failEarly(fmt.Errorf("event _bus: %w", err))
|
||||
}
|
||||
if clientIDOverride != "" {
|
||||
identity.ClientID = clientIDOverride
|
||||
}
|
||||
identityHash = dwsevent.IdentityHash(identity.Key())
|
||||
}
|
||||
if clientIDOverride != "" {
|
||||
identity.ClientID = clientIDOverride
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
credentialBroker := newPersonalCredentialBroker(configDir, runtimeTokenMode, runtimeTokenMode)
|
||||
src, err := eventNewPersonalSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: streamOpts.Mode,
|
||||
TicketURL: streamOpts.TicketURL,
|
||||
ClientIDOverride: clientIDOverride,
|
||||
CredentialBroker: credentialBroker,
|
||||
RuntimeTokenMode: runtimeTokenMode,
|
||||
})
|
||||
if err != nil {
|
||||
return failEarly(err)
|
||||
@@ -629,17 +655,18 @@ func newEventBusCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
IdleTimeout: idleTimeout,
|
||||
ReadyPipe: readyPipe,
|
||||
Logger: slog.Default(),
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
IdleTimeout: idleTimeout,
|
||||
ReadyPipe: readyPipe,
|
||||
Logger: slog.Default(),
|
||||
CredentialBroker: credentialBroker,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
return eventBusRun(ctx, busCfg)
|
||||
@@ -699,12 +726,18 @@ func newEventBusCommand() *cobra.Command {
|
||||
"exit after this long with zero consumers (0 = disabled)")
|
||||
cmd.Flags().StringVar(&sourceKindRaw, "source-kind", string(dwsevent.SourceKindAppStream),
|
||||
"event source kind: app_stream|personal_stream")
|
||||
cmd.Flags().BoolVar(&runtimeTokenMode, "runtime-token-mode", false,
|
||||
"use an owner-injected in-memory runtime credential")
|
||||
cmd.Flags().StringVar(&identityHashFlag, "identity-hash", "",
|
||||
"pre-resolved non-sensitive personal identity hash")
|
||||
cmd.Flags().StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
|
||||
"用户 Stream 建联模式:空=SDK app credential;normal/custom=portal 取票")
|
||||
cmd.Flags().StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
|
||||
"用户 Stream sourceId;personal_stream 开源版默认 open")
|
||||
cmd.Flags().StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
|
||||
"用户 Stream 取票 URL;personal_stream 默认由 MCP base URL 派生")
|
||||
_ = cmd.Flags().MarkHidden("runtime-token-mode")
|
||||
_ = cmd.Flags().MarkHidden("identity-hash")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -823,6 +856,10 @@ func newEventListCommand() *cobra.Command {
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newEventStatusCommand() *cobra.Command {
|
||||
return newEventStatusCommandWithFlags()
|
||||
}
|
||||
|
||||
func newEventStatusCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
all bool
|
||||
allEditions bool
|
||||
@@ -848,6 +885,8 @@ func newEventStatusCommand() *cobra.Command {
|
||||
return fmt.Errorf("event status: %w", err)
|
||||
}
|
||||
personalOpts.Format = formatRaw
|
||||
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
|
||||
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
|
||||
return eventRunPersonalStatus(c, personalOpts)
|
||||
}
|
||||
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||
@@ -1139,6 +1178,10 @@ func renderStatusBlock(w io.Writer, qs busctl.EntryStatus) {
|
||||
}
|
||||
|
||||
func newEventStopCommand() *cobra.Command {
|
||||
return newEventStopCommandWithFlags()
|
||||
}
|
||||
|
||||
func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
var asIdentity string
|
||||
var opts personalStopOptions
|
||||
cmd := &cobra.Command{
|
||||
@@ -1159,6 +1202,8 @@ func newEventStopCommand() *cobra.Command {
|
||||
}
|
||||
if as == "user" {
|
||||
opts.SubscribeID = firstArg(args)
|
||||
opts.ExplicitToken = eventExplicitToken(globalFlags)
|
||||
opts.ClientIDOverride = eventExplicitClientID(globalFlags)
|
||||
if eventStopDryRun(c) {
|
||||
return writeEventStopDryRun(c, as, opts)
|
||||
}
|
||||
@@ -1262,6 +1307,20 @@ func eventStopDryRun(cmd *cobra.Command) bool {
|
||||
return value
|
||||
}
|
||||
|
||||
func eventExplicitToken(globalFlags []*GlobalFlags) string {
|
||||
if len(globalFlags) == 0 || globalFlags[0] == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(globalFlags[0].Token)
|
||||
}
|
||||
|
||||
func eventExplicitClientID(globalFlags []*GlobalFlags) string {
|
||||
if len(globalFlags) == 0 || globalFlags[0] == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(globalFlags[0].ClientID)
|
||||
}
|
||||
|
||||
func writeEventStopDryRun(cmd *cobra.Command, identity string, opts personalStopOptions) error {
|
||||
payload := map[string]any{
|
||||
"dry_run": true,
|
||||
|
||||
@@ -65,13 +65,13 @@ func (eventTargetReader) CallMCPData(product, tool string, params map[string]any
|
||||
|
||||
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
|
||||
|
||||
func newEventListenIMCommand() *cobra.Command {
|
||||
func newEventListenIMCommand(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
var opts listenIMOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "+listen-im",
|
||||
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
|
||||
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
|
||||
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
|
||||
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期;本命令只处理 IM,不接收 OA 审批事件。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
@@ -91,6 +91,8 @@ func newEventListenIMCommand() *cobra.Command {
|
||||
StreamTicketMode: opts.StreamTicketMode,
|
||||
StreamTicketURL: opts.StreamTicketURL,
|
||||
StreamSourceID: opts.StreamSourceID,
|
||||
ExplicitToken: eventExplicitToken(globalFlags),
|
||||
ClientIDOverride: eventExplicitClientID(globalFlags),
|
||||
Common: commonConsumeOptions{
|
||||
FormatRaw: "ndjson",
|
||||
MaxEvents: opts.MaxEvents,
|
||||
@@ -143,12 +145,12 @@ func newEventListenIMCommand() *cobra.Command {
|
||||
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 消息事件",
|
||||
AgentSummary: "按 @我、发送人、群或全量范围监听普通 IM message/reaction/read/recall 事件",
|
||||
UseWhen: []string{
|
||||
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
|
||||
"OA 审批事件、群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层控制使用 event consume;只查历史消息使用 chat 查询入口",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws event +listen-im --kind at-me --max-events 1",
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
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/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
@@ -184,6 +185,25 @@ func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseRuntimeTokenFailure releases the in-flight claim without recording a
|
||||
// cross-invocation hold. A host may supply a fresh token on the very next
|
||||
// command, which must be allowed to retry immediately.
|
||||
func (r *personalSubscriptionAttemptReservation) releaseRuntimeTokenFailure() error {
|
||||
if r == nil {
|
||||
return runtimecred.ErrRuntimeTokenRejected
|
||||
}
|
||||
if r.store == nil || r.claim == nil {
|
||||
return personalSubscriptionGuardError(errors.Join(
|
||||
runtimecred.ErrRuntimeTokenRejected,
|
||||
errors.New("personal event: subscription attempt reservation is incomplete"),
|
||||
))
|
||||
}
|
||||
if err := r.store.Release(r.claim); err != nil {
|
||||
return personalSubscriptionGuardError(errors.Join(runtimecred.ErrRuntimeTokenRejected, err))
|
||||
}
|
||||
return runtimecred.ErrRuntimeTokenRejected
|
||||
}
|
||||
|
||||
func (r *personalSubscriptionAttemptReservation) completeFailure(
|
||||
ctx context.Context,
|
||||
failedIndex int,
|
||||
|
||||
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount != 16 {
|
||||
t.Fatalf("public personal events = %d, want 16", publicCount)
|
||||
if publicCount != 22 {
|
||||
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
|
||||
}
|
||||
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
|
||||
if !ruleTypes[ruleType] {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
@@ -39,6 +41,7 @@ import (
|
||||
"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/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
@@ -81,6 +84,8 @@ type personalConsumeOptions struct {
|
||||
StreamTicketMode string
|
||||
StreamTicketURL string
|
||||
StreamSourceID string
|
||||
ExplicitToken string
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
type personalListOptions struct {
|
||||
@@ -91,19 +96,23 @@ type personalListOptions struct {
|
||||
}
|
||||
|
||||
type personalStatusOptions struct {
|
||||
EventKey string
|
||||
Status string
|
||||
SubscribeID string
|
||||
Format string
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
EventKey string
|
||||
Status string
|
||||
SubscribeID string
|
||||
Format string
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
ExplicitToken string
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
type personalStopOptions struct {
|
||||
SubscribeID string
|
||||
All bool
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
SubscribeID string
|
||||
All bool
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
ExplicitToken string
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
type personalStreamSourceOptions struct {
|
||||
@@ -112,6 +121,8 @@ type personalStreamSourceOptions struct {
|
||||
TicketMode string
|
||||
TicketURL string
|
||||
ClientIDOverride string
|
||||
CredentialBroker *runtimecred.Broker
|
||||
RuntimeTokenMode bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -141,10 +152,19 @@ var (
|
||||
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
|
||||
personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken
|
||||
personalLoadTokenData = authpkg.LoadTokenData
|
||||
personalLoadProfiles = authpkg.LoadProfiles
|
||||
personalClientID = authpkg.ClientID
|
||||
personalRuntimeEventClientID = runtimePersonalEventClientID
|
||||
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
|
||||
)
|
||||
|
||||
func runtimePersonalEventClientID() string {
|
||||
if clientID := strings.TrimSpace(edition.Get().AuthClientID); clientID != "" {
|
||||
return clientID
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("DWS_CLIENT_ID"))
|
||||
}
|
||||
|
||||
func newEventSchemaCommand() *cobra.Command {
|
||||
var asIdentity string
|
||||
var formatRaw string
|
||||
@@ -201,12 +221,15 @@ func newEventSchemaCommand() *cobra.Command {
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
|
||||
UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"},
|
||||
AvoidWhen: []string{
|
||||
"查询 CLI 命令参数契约时用顶层 dws schema",
|
||||
"要实际收事件时用 event consume",
|
||||
},
|
||||
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
|
||||
Examples: []string{
|
||||
"dws event schema user_im_message_receive_at --flatten --format json",
|
||||
"dws event schema user_oa_approval_task_created --flatten --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -262,6 +285,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
if err := validatePersonalOAOptions(opts.EventKey, opts); 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
|
||||
@@ -276,7 +302,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
@@ -284,23 +310,41 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||
spawnProfileSelector := ""
|
||||
if strings.TrimSpace(opts.ExplicitToken) == "" {
|
||||
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
|
||||
}
|
||||
spawnArgs := personalBusSpawnArgsForToken(
|
||||
identity,
|
||||
identityHash,
|
||||
opts.StreamTicketMode,
|
||||
opts.StreamTicketURL,
|
||||
spawnProfileSelector,
|
||||
opts.ExplicitToken,
|
||||
)
|
||||
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
|
||||
if opts.Common.DryRun {
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
if err := validatePersonalSubscriptionOptions(opts); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
} else {
|
||||
_, eventKey, _, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
opts.EventKey = eventKey
|
||||
}
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
|
||||
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -327,7 +371,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
|
||||
SpawnExtraArgs: spawnArgs,
|
||||
RuntimeToken: strings.TrimSpace(opts.ExplicitToken),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -356,20 +401,31 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundSource *source.PersonalSource
|
||||
var (
|
||||
foregroundSource *source.PersonalSource
|
||||
foregroundBroker *runtimecred.Broker
|
||||
)
|
||||
if opts.Common.Foreground {
|
||||
explicitToken := strings.TrimSpace(opts.ExplicitToken)
|
||||
foregroundBroker = newPersonalCredentialBroker(configDir, explicitToken != "", false)
|
||||
if explicitToken != "" {
|
||||
if _, err := foregroundBroker.Update(0, explicitToken); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
CredentialBroker: foregroundBroker,
|
||||
RuntimeTokenMode: explicitToken != "",
|
||||
})
|
||||
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(
|
||||
@@ -385,6 +441,10 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
}
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(err) {
|
||||
err = attempt.releaseRuntimeTokenFailure()
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
err = attempt.completeFailure(ctx, 0, 0, err, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
@@ -408,9 +468,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
cleanup := func(cleanupCtx context.Context) {
|
||||
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
ownsSubscription := selfCreated || opts.Ephemeral
|
||||
var cleanupOnce sync.Once
|
||||
cleanupOwnedSubscription := func(cleanupCtx context.Context) {
|
||||
if !ownsSubscription {
|
||||
return
|
||||
}
|
||||
cleanupOnce.Do(func() {
|
||||
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
})
|
||||
}
|
||||
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
@@ -421,19 +489,19 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
wrapped := fmt.Errorf("save run state: %w", err)
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, wrapped) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
if attempt != nil {
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, wrapped) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
classification := personalSubscriptionLocalFailure()
|
||||
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
|
||||
cleanup(cleanupCtx)
|
||||
}
|
||||
cleanupOwnedSubscription(cleanupCtx)
|
||||
return fmt.Errorf("event consume --as user: %w", wrapped)
|
||||
}
|
||||
if err := attempt.completeSuccess(); err != nil {
|
||||
cleanup(context.Background())
|
||||
cleanupOwnedSubscription(context.Background())
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
// Ownership-based cleanup: a subscription this run CREATED is
|
||||
@@ -442,9 +510,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup(context.Background())
|
||||
if ownsSubscription {
|
||||
defer cleanupOwnedSubscription(context.Background())
|
||||
}
|
||||
|
||||
cfg.EventKey = eventKey
|
||||
@@ -456,27 +523,20 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
}
|
||||
if opts.Common.Foreground {
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: foregroundSource,
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: foregroundSource,
|
||||
CredentialBroker: foregroundBroker,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = personalBusRun(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
return personalBusRun(ctx, busCfg)
|
||||
}
|
||||
err = personalConsumeRun(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
return personalConsumeRun(ctx, cfg)
|
||||
}
|
||||
|
||||
type personalMultiSubscription struct {
|
||||
@@ -505,7 +565,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
|
||||
ctx := c.Context()
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
@@ -513,7 +573,10 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||
spawnProfileSelector := ""
|
||||
if strings.TrimSpace(opts.ExplicitToken) == "" {
|
||||
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
|
||||
}
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
@@ -522,7 +585,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
|
||||
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -548,8 +611,9 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
|
||||
return nil
|
||||
}
|
||||
baseCfg.RuntimeToken = strings.TrimSpace(opts.ExplicitToken)
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
|
||||
attempt, err := reservePersonalSubscriptionAttempts(
|
||||
workDir,
|
||||
client,
|
||||
@@ -586,6 +650,10 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
|
||||
if personalSubscriptionCanceled(ctx, cause) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(cause) {
|
||||
cleanup(cleanupCtx)
|
||||
return attempt.releaseRuntimeTokenFailure()
|
||||
}
|
||||
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
|
||||
@@ -688,6 +756,9 @@ func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsume
|
||||
if !def.Public {
|
||||
return nil, personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch def.RuleType {
|
||||
case "singleChat", "sender":
|
||||
hasUserScope = true
|
||||
@@ -814,6 +885,9 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
|
||||
}
|
||||
|
||||
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
@@ -826,6 +900,37 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func validatePersonalOAOptions(eventKey string, opts personalConsumeOptions) error {
|
||||
changed := personalOAOptionNames(opts)
|
||||
if len(changed) == 0 {
|
||||
return nil
|
||||
}
|
||||
def, ok := personalLookupDefinition(strings.TrimSpace(eventKey))
|
||||
if !ok || def.Category != "oa" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s not supported for OA event %s", strings.Join(changed, ", "), eventKey)
|
||||
}
|
||||
|
||||
func personalOAOptionNames(opts personalConsumeOptions) []string {
|
||||
var changed []string
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "--user", value: opts.UserID},
|
||||
{name: "--open-dingtalk-id", value: opts.OpenDingTalkID},
|
||||
{name: "--group", value: opts.GroupID},
|
||||
{name: "--query", value: opts.QueryCSV},
|
||||
{name: "--filter-json", value: opts.FilterJSON},
|
||||
} {
|
||||
if strings.TrimSpace(item.value) != "" {
|
||||
changed = append(changed, item.name)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
type personalPreparedSubscription struct {
|
||||
EventKey string
|
||||
RuleType string
|
||||
@@ -839,6 +944,9 @@ func preparePersonalSubscription(identity personal.Identity, opts personalConsum
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
@@ -885,13 +993,32 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
|
||||
if sub == nil {
|
||||
return nil, "", "", errors.New("personal event: server returned an empty subscription")
|
||||
}
|
||||
requestedEventKey := strings.TrimSpace(opts.EventKey)
|
||||
actualEventKey := strings.TrimSpace(sub.EventKey)
|
||||
if requestedEventKey != "" && actualEventKey != "" && requestedEventKey != actualEventKey {
|
||||
return nil, "", "", fmt.Errorf(
|
||||
"event_key %q does not match reused subscription %q event_key %q",
|
||||
requestedEventKey,
|
||||
strings.TrimSpace(opts.SubscribeID),
|
||||
actualEventKey,
|
||||
)
|
||||
}
|
||||
eventKey := actualEventKey
|
||||
if eventKey == "" {
|
||||
eventKey = requestedEventKey
|
||||
}
|
||||
if eventKey == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(eventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
|
||||
if ruleType == "" {
|
||||
if def, ok := personal.Lookup(eventKey); ok {
|
||||
@@ -914,7 +1041,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event status --as user: %w", err)
|
||||
}
|
||||
@@ -946,7 +1073,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
||||
if status == "" || status == "all" {
|
||||
status = ""
|
||||
}
|
||||
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
|
||||
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken), ctx, personal.ListOptions{
|
||||
Status: status,
|
||||
EventKey: opts.EventKey,
|
||||
SubscribeID: opts.SubscribeID,
|
||||
@@ -967,6 +1094,15 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalRuntimeTokenControlRejection(err error) bool {
|
||||
var apiErr *personal.APIError
|
||||
if !errors.As(err, &apiErr) || apiErr == nil {
|
||||
return false
|
||||
}
|
||||
return apiErr.HTTPStatus == http.StatusUnauthorized ||
|
||||
strings.EqualFold(strings.TrimSpace(apiErr.Code), "RUNTIME_TOKEN_REJECTED")
|
||||
}
|
||||
|
||||
func ensurePublicPersonalEvent(eventKey string) error {
|
||||
eventKey = strings.TrimSpace(eventKey)
|
||||
if eventKey == "" {
|
||||
@@ -1049,7 +1185,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
@@ -1061,7 +1197,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
|
||||
for _, id := range subscribeIDs {
|
||||
if err := personalDeleteSubscription(client, ctx, id); err != nil {
|
||||
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
||||
@@ -1177,6 +1313,138 @@ func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, bu
|
||||
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
|
||||
}
|
||||
|
||||
func resolvePersonalEventIdentityForToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
|
||||
explicitToken = strings.TrimSpace(explicitToken)
|
||||
if explicitToken == "" {
|
||||
return personalResolveEventIdentity(ctx, configDir, sourceIDOverride)
|
||||
}
|
||||
clientIDOverride := ""
|
||||
if len(clientIDOverrides) > 0 {
|
||||
clientIDOverride = strings.TrimSpace(clientIDOverrides[0])
|
||||
}
|
||||
return resolvePersonalEventIdentityWithToken(ctx, configDir, sourceIDOverride, explicitToken, clientIDOverride)
|
||||
}
|
||||
|
||||
// resolvePersonalEventIdentityWithToken resolves only non-sensitive identity
|
||||
// metadata around a caller-supplied bearer token. It intentionally does not
|
||||
// call LoadTokenData or any refresh-capable token resolver: an explicit root
|
||||
// --token must never be replaced with, persisted into, or used to refresh a
|
||||
// local OAuth profile.
|
||||
func resolvePersonalEventIdentityWithToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
|
||||
explicitToken = strings.TrimSpace(explicitToken)
|
||||
if explicitToken == "" {
|
||||
return resolvePersonalEventIdentity(ctx, configDir, sourceIDOverride)
|
||||
}
|
||||
if strings.Contains(strings.TrimSpace(authpkg.RuntimeProfile()), ",") {
|
||||
return personal.Identity{}, fmt.Errorf("personal events require exactly one --profile")
|
||||
}
|
||||
|
||||
corpID := resolveRuntimeDefault(ctx, "$corpId")
|
||||
userID := resolveRuntimeDefault(ctx, "$currentUserId")
|
||||
clientID := ""
|
||||
if len(clientIDOverrides) > 0 {
|
||||
clientID = strings.TrimSpace(clientIDOverrides[0])
|
||||
}
|
||||
if clientID == "" {
|
||||
// An edition hook or explicit environment value is runtime identity,
|
||||
// not persisted app state. Resolve it before profiles.json so a complete
|
||||
// host context never depends on local OAuth metadata health.
|
||||
clientID = strings.TrimSpace(personalRuntimeEventClientID())
|
||||
}
|
||||
explicitProfile := strings.TrimSpace(authpkg.RuntimeProfile()) != ""
|
||||
if explicitProfile || corpID == "" || userID == "" || clientID == "" {
|
||||
profile, err := personalEventProfileMetadata(configDir)
|
||||
if err != nil {
|
||||
// A user-selected --profile remains a strict contract. Without an
|
||||
// explicit selector, profiles.json is optional metadata for a
|
||||
// host-managed bearer: malformed or stale persisted state must not
|
||||
// override complete runtime defaults or prevent the later global
|
||||
// client-id fallback.
|
||||
if explicitProfile {
|
||||
return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err)
|
||||
}
|
||||
profile = nil
|
||||
}
|
||||
if profile != nil {
|
||||
if corpID == "" {
|
||||
corpID = strings.TrimSpace(profile.CorpID)
|
||||
}
|
||||
if userID == "" {
|
||||
userID = strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = strings.TrimSpace(profile.ClientID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if clientID == "" {
|
||||
// Persisted/global app credentials are only a fallback after the
|
||||
// selected profile, so an old app config cannot override profile.ClientID.
|
||||
clientID = strings.TrimSpace(personalClientID())
|
||||
}
|
||||
if clientID == "" {
|
||||
if id, _, _, _, resolveErr := personalResolveAppCredentialsStrict(configDir); resolveErr == nil {
|
||||
clientID = strings.TrimSpace(id)
|
||||
}
|
||||
}
|
||||
if clientID == "" {
|
||||
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
|
||||
}
|
||||
|
||||
sourceID := strings.TrimSpace(sourceIDOverride)
|
||||
if sourceID == "" {
|
||||
sourceID = personalEventStreamSourceID("")
|
||||
}
|
||||
localSubject := ""
|
||||
if corpID == "" || userID == "" {
|
||||
localSubject = personalTokenSubject("access", explicitToken)
|
||||
}
|
||||
return personal.Identity{
|
||||
LocalSubject: localSubject,
|
||||
CorpID: corpID,
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
SourceID: sourceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func personalEventProfileMetadata(configDir string) (*authpkg.Profile, error) {
|
||||
cfg, err := personalLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector := strings.TrimSpace(authpkg.RuntimeProfile())
|
||||
explicitSelector := selector != ""
|
||||
if strings.Contains(selector, ",") {
|
||||
return nil, fmt.Errorf("personal events require exactly one --profile")
|
||||
}
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
if explicitSelector {
|
||||
return nil, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if selector == "" {
|
||||
selector = strings.TrimSpace(cfg.CurrentProfile)
|
||||
}
|
||||
if selector == "" {
|
||||
return nil, nil
|
||||
}
|
||||
profile, err := selectPersonalEventProfileMetadata(cfg, selector, make(map[string]struct{}))
|
||||
if err != nil && !explicitSelector {
|
||||
// A stale persisted CurrentProfile must not make a host-provided bearer
|
||||
// unusable. Runtime defaults and the one-way local subject are sufficient
|
||||
// to isolate the event bus without consulting local OAuth credentials.
|
||||
return nil, nil
|
||||
}
|
||||
return profile, err
|
||||
}
|
||||
|
||||
func selectPersonalEventProfileMetadata(cfg *authpkg.ProfilesConfig, selector string, visited map[string]struct{}) (*authpkg.Profile, error) {
|
||||
_ = visited // retained for the focused compatibility seam used by app tests.
|
||||
return authpkg.ResolveProfileMetadata(cfg, strings.TrimSpace(selector))
|
||||
}
|
||||
|
||||
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
|
||||
accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
if err != nil {
|
||||
@@ -1231,7 +1499,11 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
|
||||
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity, explicitTokens ...string) *personal.Client {
|
||||
explicitToken := ""
|
||||
if len(explicitTokens) > 0 {
|
||||
explicitToken = strings.TrimSpace(explicitTokens[0])
|
||||
}
|
||||
identity.AccessToken = ""
|
||||
client := personal.NewClient(baseURL, identity)
|
||||
version := strings.TrimSpace(RawVersion())
|
||||
@@ -1240,12 +1512,146 @@ func newPersonalEventControlClient(configDir, baseURL string, identity personal.
|
||||
}
|
||||
client.ClientVersion = version
|
||||
client.UserAgent = "dws-cli/" + version
|
||||
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
if explicitToken != "" {
|
||||
client.AccessTokenProvider = func(context.Context) (string, error) { return explicitToken, nil }
|
||||
client.HTTPClient.Transport = runtimeTokenControlTransport{base: http.DefaultTransport, token: explicitToken}
|
||||
client.HTTPClient.CheckRedirect = runtimeTokenRedirectPolicy
|
||||
} else {
|
||||
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// runtimeTokenRedirectPolicy prevents Go's redirect machinery from copying
|
||||
// DWS's custom x-user-access-token header to another authority. Returning
|
||||
// ErrUseLastResponse keeps the 3xx response available to the caller without a
|
||||
// url.Error that could echo an attacker-controlled Location value.
|
||||
func runtimeTokenRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 || req == nil || req.URL == nil || via[0] == nil || via[0].URL == nil {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
origin := via[0].URL
|
||||
if !strings.EqualFold(strings.TrimSpace(req.URL.Host), strings.TrimSpace(origin.Host)) {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if strings.EqualFold(origin.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const runtimeTokenControlErrorBody = `{"code":"RUNTIME_TOKEN_REJECTED","message":"event runtime token was rejected; retry with a fresh host credential"}`
|
||||
|
||||
// runtimeTokenControlTransport scrubs an explicit bearer from every response
|
||||
// body and diagnostic header before the control client decodes or logs it. A
|
||||
// 401 is replaced with a fixed rejection envelope so untrusted response text
|
||||
// can never escape through stderr or debug logs.
|
||||
type runtimeTokenControlTransport struct {
|
||||
base http.RoundTripper
|
||||
token string
|
||||
}
|
||||
|
||||
func (t runtimeTokenControlTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
base := t.base
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
resp, err := base.RoundTrip(req)
|
||||
if err != nil {
|
||||
if token := strings.TrimSpace(t.token); token != "" && strings.Contains(err.Error(), token) {
|
||||
return nil, errors.New("personal event: runtime-token control request failed")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if resp == nil {
|
||||
return resp, err
|
||||
}
|
||||
token := strings.TrimSpace(t.token)
|
||||
for key, values := range resp.Header {
|
||||
for i := range values {
|
||||
if token != "" {
|
||||
values[i] = strings.ReplaceAll(values[i], token, "<redacted-runtime-token>")
|
||||
}
|
||||
}
|
||||
resp.Header[key] = values
|
||||
}
|
||||
var responseBody []byte
|
||||
if resp.Body != nil {
|
||||
responseBody, err = io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, errors.New("personal event: read runtime-token control response")
|
||||
}
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
responseBody = []byte(runtimeTokenControlErrorBody)
|
||||
} else if token != "" {
|
||||
responseBody = redactRuntimeTokenResponseBody(responseBody, token)
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(responseBody))
|
||||
resp.ContentLength = int64(len(responseBody))
|
||||
if resp.Header == nil {
|
||||
resp.Header = make(http.Header)
|
||||
}
|
||||
resp.Header.Set("Content-Type", "application/json")
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(responseBody)))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func redactRuntimeTokenResponseBody(data []byte, token string) []byte {
|
||||
token = strings.TrimSpace(token)
|
||||
if len(data) == 0 || token == "" {
|
||||
return data
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
var decoded any
|
||||
if err := decoder.Decode(&decoded); err == nil {
|
||||
var trailing any
|
||||
if trailingErr := decoder.Decode(&trailing); errors.Is(trailingErr, io.EOF) {
|
||||
if redacted, changed := redactRuntimeTokenJSONValue(decoded, token); changed {
|
||||
if encoded, marshalErr := json.Marshal(redacted); marshalErr == nil {
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bytes.ReplaceAll(data, []byte(token), []byte("<redacted-runtime-token>"))
|
||||
}
|
||||
|
||||
func redactRuntimeTokenJSONValue(value any, token string) (any, bool) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
redacted := strings.ReplaceAll(typed, token, "<redacted-runtime-token>")
|
||||
return redacted, redacted != typed
|
||||
case []any:
|
||||
changed := false
|
||||
for i := range typed {
|
||||
var itemChanged bool
|
||||
typed[i], itemChanged = redactRuntimeTokenJSONValue(typed[i], token)
|
||||
changed = changed || itemChanged
|
||||
}
|
||||
return typed, changed
|
||||
case map[string]any:
|
||||
changed := false
|
||||
redactedMap := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
redactedKey := strings.ReplaceAll(key, token, "<redacted-runtime-token>")
|
||||
redacted, itemChanged := redactRuntimeTokenJSONValue(item, token)
|
||||
redactedMap[redactedKey] = redacted
|
||||
changed = changed || itemChanged || redactedKey != key
|
||||
}
|
||||
if !changed {
|
||||
return typed, false
|
||||
}
|
||||
return redactedMap, true
|
||||
default:
|
||||
return value, false
|
||||
}
|
||||
}
|
||||
|
||||
func personalTokenSubject(kind, token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
@@ -1255,6 +1661,15 @@ func personalTokenSubject(kind, token string) string {
|
||||
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func validPersonalIdentityHash(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != 16 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func resolveRuntimeDefault(ctx context.Context, key string) string {
|
||||
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
|
||||
if fn := fnMap()[key]; fn != nil {
|
||||
@@ -1292,20 +1707,42 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
|
||||
}
|
||||
clientSecret = secret
|
||||
}
|
||||
credentialBroker := opts.CredentialBroker
|
||||
if credentialBroker == nil {
|
||||
credentialBroker = newPersonalCredentialBroker(opts.ConfigDir, false, false)
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
if opts.RuntimeTokenMode {
|
||||
httpClient.CheckRedirect = runtimeTokenRedirectPolicy
|
||||
}
|
||||
_ = ctx
|
||||
return source.NewPersonal(source.PersonalConfig{
|
||||
AccessTokenProvider: func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "")
|
||||
return credentialBroker.Resolve(ctx)
|
||||
},
|
||||
ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) {
|
||||
return personalForceRefreshRejectedToken(ctx, opts.ConfigDir, rejectedToken)
|
||||
return credentialBroker.RefreshRejected(ctx, rejectedToken)
|
||||
},
|
||||
ClassifyRetryReject: credentialBroker.ClassifyRejectedAfterRetry,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
SourceID: opts.Identity.SourceID,
|
||||
TicketURL: ticketURL,
|
||||
TicketMode: mode,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
}
|
||||
|
||||
func newPersonalCredentialBroker(configDir string, requireSeed, requireActivation bool) *runtimecred.Broker {
|
||||
return runtimecred.New(runtimecred.Config{
|
||||
RequireSeed: requireSeed,
|
||||
RequireActivation: requireActivation,
|
||||
LocalResolve: func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
},
|
||||
LocalRefresh: func(ctx context.Context, rejectedToken string) (string, error) {
|
||||
return personalForceRefreshRejectedToken(ctx, configDir, rejectedToken)
|
||||
},
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
SourceID: opts.Identity.SourceID,
|
||||
TicketURL: ticketURL,
|
||||
TicketMode: mode,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1370,6 +1807,25 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
|
||||
return args
|
||||
}
|
||||
|
||||
func personalBusSpawnArgsForToken(identity personal.Identity, identityHash, ticketMode, ticketURL, profileSelector, explicitToken string) []string {
|
||||
if strings.TrimSpace(explicitToken) == "" {
|
||||
return personalBusSpawnArgs(identity, ticketMode, ticketURL, profileSelector)
|
||||
}
|
||||
args := []string{
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--runtime-token-mode",
|
||||
"--identity-hash", strings.TrimSpace(identityHash),
|
||||
"--stream-source-id", strings.TrimSpace(identity.SourceID),
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", strings.TrimSpace(ticketMode))
|
||||
}
|
||||
if strings.TrimSpace(ticketURL) != "" {
|
||||
args = append(args, "--stream-ticket-url", strings.TrimSpace(ticketURL))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func personalEventTypes(eventKey string, explicit []string) []string {
|
||||
if len(explicit) > 0 {
|
||||
return explicit
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -36,18 +37,20 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
|
||||
}
|
||||
}
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldCreate := personalCreateSubscription
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalCreateSubscription = oldCreate
|
||||
})
|
||||
testseam.Protect(t, &personalGetSubscription)
|
||||
testseam.Protect(t, &personalCreateSubscription)
|
||||
client := personal.NewClient("https://example.test", personal.Identity{})
|
||||
wantErr := errors.New("subscription")
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("get subscription error = %v", err)
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil || !strings.Contains(err.Error(), "empty subscription") {
|
||||
t.Fatalf("nil subscription = %v", err)
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
|
||||
list := newEventListCommand()
|
||||
list.SilenceUsage = true
|
||||
list.SilenceErrors = true
|
||||
var listOut bytes.Buffer
|
||||
list.SetOut(&listOut)
|
||||
list.SetArgs([]string{"--category", "oa"})
|
||||
if err := list.Execute(); err != nil {
|
||||
t.Fatalf("event list --category oa error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
eventKey string
|
||||
properties []string
|
||||
}{
|
||||
{
|
||||
eventKey: personal.EventOAApprovalTaskCreated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalTaskFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalTaskRedirected,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceStarted,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "result", "create_time", "finish_time",
|
||||
"event_time",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
eventKey := tt.eventKey
|
||||
if !strings.Contains(listOut.String(), eventKey) {
|
||||
t.Fatalf("OA event list missing %s:\n%s", eventKey, listOut.String())
|
||||
}
|
||||
|
||||
schema := newEventSchemaCommand()
|
||||
schema.SilenceUsage = true
|
||||
schema.SilenceErrors = true
|
||||
var schemaOut bytes.Buffer
|
||||
schema.SetOut(&schemaOut)
|
||||
schema.SetArgs([]string{eventKey, "--flatten"})
|
||||
if err := schema.Execute(); err != nil {
|
||||
t.Fatalf("event schema %s --flatten error = %v", eventKey, err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("decode schema for %s: %v\n%s", eventKey, err, schemaOut.String())
|
||||
}
|
||||
if doc["event_key"] != eventKey || doc["rule_type"] != "all" || doc["jq_root_path"] != "." {
|
||||
t.Fatalf("schema document for %s = %#v", eventKey, doc)
|
||||
}
|
||||
schemaBody, ok := doc["schema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema body for %s = %#v", eventKey, doc["schema"])
|
||||
}
|
||||
properties, ok := schemaBody["properties"].(map[string]any)
|
||||
if !ok || len(properties) != len(tt.properties) {
|
||||
t.Fatalf("schema properties for %s = %#v, want %d fields", eventKey, schemaBody["properties"], len(tt.properties))
|
||||
}
|
||||
for _, name := range tt.properties {
|
||||
if _, ok := properties[name].(map[string]any); !ok {
|
||||
t.Fatalf("schema property %s for %s = %#v", name, eventKey, properties[name])
|
||||
}
|
||||
}
|
||||
if _, ok := properties["payload"]; ok {
|
||||
t.Fatalf("schema for %s exposed generic payload: %#v", eventKey, properties)
|
||||
}
|
||||
}
|
||||
if strings.Contains(listOut.String(), personal.EventMention) {
|
||||
t.Fatalf("OA category list leaked IM event:\n%s", listOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalGetSubscription = oldGet
|
||||
})
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
LocalSubject: "subject",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
}, nil
|
||||
}
|
||||
personalGetSubscription = func(_ *personal.Client, _ context.Context, subscribeID string) (*personal.Subscription, error) {
|
||||
switch subscribeID {
|
||||
case "oa-sub-task":
|
||||
return &personal.Subscription{
|
||||
SubscribeID: subscribeID,
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
case "im-sub-at":
|
||||
return &personal.Subscription{
|
||||
SubscribeID: subscribeID,
|
||||
EventKey: personal.EventMention,
|
||||
RuleType: "at",
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected subscription lookup %q", subscribeID)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
oaEvents := []string{
|
||||
personal.EventOAApprovalTaskCreated,
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
for _, eventKey := range oaEvents {
|
||||
t.Run(eventKey+"/dry-run", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{eventKey, "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("OA dry-run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "event_types : "+eventKey) {
|
||||
t.Fatalf("OA dry-run does not select %s:\n%s", eventKey, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--user", "user-1"},
|
||||
{"--open-dingtalk-id", "open-user-1"},
|
||||
{"--group", "cid-1"},
|
||||
{"--query", "urgent"},
|
||||
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
|
||||
} {
|
||||
name := strings.TrimPrefix(args[0], "--")
|
||||
t.Run(eventKey+"/reject-"+name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(append([]string{eventKey}, append(args, "--dry-run")...))
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") {
|
||||
t.Fatalf("OA consume %s error = %v, want unsupported option", args[0], err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("multi-dry-run", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs(append(append([]string(nil), oaEvents...), "--dry-run"))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("multi OA dry-run error = %v", err)
|
||||
}
|
||||
for _, eventKey := range oaEvents {
|
||||
want := "event_key=" + eventKey + " rule_type=all rule_param={}"
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("multi OA dry-run missing %q:\n%s", want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
reuseOverrides := [][]string{
|
||||
{"--user", "user-1"},
|
||||
{"--open-dingtalk-id", "open-user-1"},
|
||||
{"--group", "cid-1"},
|
||||
{"--query", "urgent"},
|
||||
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
|
||||
}
|
||||
t.Run("reuse-dry-run/implicit-event-key/resolves-oa-event", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("implicit reused OA dry-run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "event_types : "+personal.EventOAApprovalTaskCreated) {
|
||||
t.Fatalf("implicit reused OA dry-run did not resolve event key:\n%s", stderr.String())
|
||||
}
|
||||
})
|
||||
for _, explicitEventKey := range []bool{true, false} {
|
||||
mode := "implicit-event-key"
|
||||
if explicitEventKey {
|
||||
mode = "explicit-event-key"
|
||||
}
|
||||
for _, override := range reuseOverrides {
|
||||
flag := override[0]
|
||||
t.Run("reuse-dry-run/"+mode+"/"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
|
||||
args := make([]string, 0, 6)
|
||||
if explicitEventKey {
|
||||
args = append(args, personal.EventOAApprovalTaskCreated)
|
||||
}
|
||||
args = append(args, "--subscribe-id", "oa-sub-task", flag, override[1], "--dry-run")
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), flag+" not supported for OA event") {
|
||||
t.Fatalf("%s reused OA dry-run %s error = %v", mode, flag, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("reuse-dry-run/implicit-im-remains-supported", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--subscribe-id", "im-sub-at", "--query", "urgent", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("implicit reused IM dry-run error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, override := range reuseOverrides {
|
||||
flag, value := override[0], override[1]
|
||||
t.Run("multi-reject-"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
args := append([]string(nil), oaEvents...)
|
||||
args = append(args, flag, value, "--dry-run")
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported for OA event") {
|
||||
t.Fatalf("multi OA consume %s error = %v", flag, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "message query remains supported",
|
||||
args: []string{personal.EventMention, "--query", "urgent", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "single group lifecycle filter remains supported",
|
||||
args: []string{
|
||||
personal.EventGroupUpdated,
|
||||
"--group", "cid-1",
|
||||
"--filter-json", `{"field":"future","op":"eq","value":"value"}`,
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(test.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("existing IM consume behavior changed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalOAValidationBranches(t *testing.T) {
|
||||
invalid := personalConsumeOptions{
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
UserID: "user-1",
|
||||
}
|
||||
if err := validatePersonalSubscriptionOptions(invalid); err == nil ||
|
||||
!strings.Contains(err.Error(), "--user not supported for OA event") {
|
||||
t.Fatalf("validatePersonalSubscriptionOptions() error = %v", err)
|
||||
}
|
||||
if _, err := preparePersonalSubscription(personal.Identity{}, invalid); err == nil ||
|
||||
!strings.Contains(err.Error(), "--user not supported for OA event") {
|
||||
t.Fatalf("preparePersonalSubscription() error = %v", err)
|
||||
}
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "oa-sub-without-event-key",
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
_, _, _, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
personalConsumeOptions{
|
||||
SubscribeID: "oa-sub-without-event-key",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
UserID: "user-1",
|
||||
},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--user not supported for OA event") {
|
||||
t.Fatalf("ensurePersonalSubscription() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *testing.T) {
|
||||
restoreMany := installPersonalManySeams(t)
|
||||
defer restoreMany()
|
||||
oldCreate := personalCreateSubscription
|
||||
defer func() { personalCreateSubscription = oldCreate }()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
identity := personal.Identity{
|
||||
AccessToken: "token",
|
||||
LocalSubject: "subject",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
var requests []personal.CreateSubscriptionRequest
|
||||
personalCreateSubscription = func(_ *personal.Client, _ context.Context, req personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
|
||||
requests = append(requests, req)
|
||||
return &personal.Subscription{SubscribeID: "sub-" + req.EventKey}, nil
|
||||
}
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
var states []personal.RunState
|
||||
personalUpsertRunState = func(_ string, state personal.RunState) error {
|
||||
states = append(states, state)
|
||||
return nil
|
||||
}
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
runManyCalls := 0
|
||||
var gotSpecs []consume.ConsumerSpec
|
||||
personalConsumeRunMany = func(_ context.Context, _ consume.Config, specs []consume.ConsumerSpec) error {
|
||||
runManyCalls++
|
||||
gotSpecs = append([]consume.ConsumerSpec(nil), specs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
eventKeys := []string{
|
||||
personal.EventOAApprovalTaskCreated,
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
if err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: eventKeys,
|
||||
Flatten: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("multi OA consume error = %v", err)
|
||||
}
|
||||
if runManyCalls != 1 {
|
||||
t.Fatalf("RunMany calls = %d, want one shared-bus consume call", runManyCalls)
|
||||
}
|
||||
if len(requests) != len(eventKeys) || len(states) != len(eventKeys) || len(gotSpecs) != len(eventKeys) {
|
||||
t.Fatalf("requests=%d states=%d specs=%d, want %d each", len(requests), len(states), len(gotSpecs), len(eventKeys))
|
||||
}
|
||||
for i, eventKey := range eventKeys {
|
||||
req := requests[i]
|
||||
if req.EventKey != eventKey || req.RuleType != "all" || req.RuleParam == nil || len(req.RuleParam) != 0 || req.Filter != nil {
|
||||
t.Fatalf("subscription request[%d] = %#v, want %s all/{}", i, req, eventKey)
|
||||
}
|
||||
if states[i].EventKey != eventKey || states[i].RuleType != "all" {
|
||||
t.Fatalf("run state[%d] = %#v", i, states[i])
|
||||
}
|
||||
wantSpec := consume.ConsumerSpec{
|
||||
EventKey: eventKey,
|
||||
EventTypes: []string{eventKey},
|
||||
SubscribeID: "sub-" + eventKey,
|
||||
ReadySubscribeID: "sub-" + eventKey,
|
||||
}
|
||||
if !reflect.DeepEqual(gotSpecs[i], wantSpec) {
|
||||
t.Fatalf("consumer spec[%d] = %#v, want %#v", i, gotSpecs[i], wantSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAReusedSubscriptionRejectsDefinitionOverridesAtRuntime(t *testing.T) {
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
getCalls := 0
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
getCalls++
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "oa-sub-task",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(*personalConsumeOptions)
|
||||
}{
|
||||
{name: "user", set: func(opts *personalConsumeOptions) { opts.UserID = "user-1" }},
|
||||
{name: "open-dingtalk-id", set: func(opts *personalConsumeOptions) { opts.OpenDingTalkID = "open-user-1" }},
|
||||
{name: "group", set: func(opts *personalConsumeOptions) { opts.GroupID = "cid-1" }},
|
||||
{name: "query", set: func(opts *personalConsumeOptions) { opts.QueryCSV = "urgent" }},
|
||||
{name: "filter-json", set: func(opts *personalConsumeOptions) { opts.FilterJSON = `{"field":"content","op":"eq","value":"urgent"}` }},
|
||||
}
|
||||
for _, explicitEventKey := range []bool{true, false} {
|
||||
mode := "implicit-event-key"
|
||||
if explicitEventKey {
|
||||
mode = "explicit-event-key"
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(mode+"/"+test.name, func(t *testing.T) {
|
||||
opts := personalConsumeOptions{SubscribeID: "oa-sub-task"}
|
||||
if explicitEventKey {
|
||||
opts.EventKey = personal.EventOAApprovalTaskCreated
|
||||
}
|
||||
test.set(&opts)
|
||||
before := getCalls
|
||||
_, _, _, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
opts,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--"+test.name+" not supported for OA event") {
|
||||
t.Fatalf("reused OA subscription %s error = %v", test.name, err)
|
||||
}
|
||||
if getCalls != before+1 {
|
||||
t.Fatalf("subscription lookup calls = %d, want %d", getCalls, before+1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAImplicitReuseRuntimeLooksUpEventBeforeValidation(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalGetSubscription = oldGet
|
||||
})
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
LocalSubject: "subject",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
}, nil
|
||||
}
|
||||
getCalls := 0
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
getCalls++
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "oa-sub-task",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--group", "cid-1"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--group not supported for OA event "+personal.EventOAApprovalTaskCreated) {
|
||||
t.Fatalf("implicit reused OA runtime error = %v", err)
|
||||
}
|
||||
if getCalls != 1 {
|
||||
t.Fatalf("subscription lookup calls = %d, want 1", getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalIMReusedSubscriptionWithExistingOverridesRemainsSupported(t *testing.T) {
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "im-sub",
|
||||
EventKey: personal.EventSingleChat,
|
||||
RuleType: "singleChat",
|
||||
}, nil
|
||||
}
|
||||
|
||||
sub, eventKey, ruleType, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
personalConsumeOptions{
|
||||
SubscribeID: "im-sub",
|
||||
EventKey: personal.EventSingleChat,
|
||||
UserID: "user-1",
|
||||
QueryCSV: "urgent",
|
||||
FilterJSON: `{"field":"content","op":"eq","value":"urgent"}`,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("reused IM subscription error = %v", err)
|
||||
}
|
||||
if sub.SubscribeID != "im-sub" || eventKey != personal.EventSingleChat || ruleType != "singleChat" {
|
||||
t.Fatalf("reused IM subscription = %#v, event=%q rule=%q", sub, eventKey, ruleType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAStatusAndStopCommandWiring(t *testing.T) {
|
||||
oldStatus := eventRunPersonalStatus
|
||||
oldStop := eventRunPersonalStop
|
||||
t.Cleanup(func() {
|
||||
eventRunPersonalStatus = oldStatus
|
||||
eventRunPersonalStop = oldStop
|
||||
})
|
||||
|
||||
var statusOpts personalStatusOptions
|
||||
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
|
||||
statusOpts = opts
|
||||
return nil
|
||||
}
|
||||
status := newEventStatusCommand()
|
||||
status.SilenceUsage = true
|
||||
status.SilenceErrors = true
|
||||
status.SetOut(io.Discard)
|
||||
status.SetErr(io.Discard)
|
||||
status.SetArgs([]string{
|
||||
"--event", personal.EventOAApprovalTaskCreated,
|
||||
"--subscribe-id", "oa-sub-task",
|
||||
"--status", "all",
|
||||
})
|
||||
if err := status.Execute(); err != nil {
|
||||
t.Fatalf("OA event status error = %v", err)
|
||||
}
|
||||
if statusOpts.EventKey != personal.EventOAApprovalTaskCreated ||
|
||||
statusOpts.SubscribeID != "oa-sub-task" ||
|
||||
statusOpts.Status != "all" {
|
||||
t.Fatalf("OA status options = %#v", statusOpts)
|
||||
}
|
||||
|
||||
var stopOpts personalStopOptions
|
||||
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
|
||||
stopOpts = opts
|
||||
return nil
|
||||
}
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
event := &cobra.Command{Use: "event"}
|
||||
event.AddCommand(newEventStopCommand())
|
||||
root.AddCommand(event)
|
||||
root.SetArgs([]string{"event", "stop", "oa-sub-task", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("OA event stop error = %v", err)
|
||||
}
|
||||
if stopOpts.SubscribeID != "oa-sub-task" || stopOpts.All {
|
||||
t.Fatalf("OA stop options = %#v", stopOpts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"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/source"
|
||||
)
|
||||
|
||||
func TestPersonalConsumeCleanupOwnershipRuntimeMatrix(t *testing.T) {
|
||||
runErr := errors.New("runtime failed")
|
||||
for _, foreground := range []bool{false, true} {
|
||||
for _, selfCreated := range []bool{false, true} {
|
||||
for _, ephemeral := range []bool{false, true} {
|
||||
for _, failRuntime := range []bool{false, true} {
|
||||
name := strings.Join([]string{
|
||||
map[bool]string{false: "background", true: "foreground"}[foreground],
|
||||
map[bool]string{false: "reused", true: "self-created"}[selfCreated],
|
||||
map[bool]string{false: "persistent", true: "ephemeral"}[ephemeral],
|
||||
map[bool]string{false: "success", true: "error"}[failRuntime],
|
||||
}, "/")
|
||||
t.Run(name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldNewSource := personalNewStreamSource
|
||||
oldBusRun := personalBusRun
|
||||
oldConsumeRun := personalConsumeRun
|
||||
t.Cleanup(func() {
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
personalConsumeRun = oldConsumeRun
|
||||
})
|
||||
|
||||
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) {
|
||||
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
|
||||
deleteCalls := 0
|
||||
removeCalls := 0
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
|
||||
deleteCalls++
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error {
|
||||
removeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
personalBusRun = func(context.Context, bus.Config) error {
|
||||
if failRuntime {
|
||||
return runErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
personalConsumeRun = func(context.Context, consume.Config) error {
|
||||
if failRuntime {
|
||||
return runErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
Ephemeral: ephemeral,
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
Common: commonConsumeOptions{
|
||||
Foreground: foreground,
|
||||
},
|
||||
}
|
||||
if !selfCreated {
|
||||
opts.SubscribeID = "sub-one"
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts)
|
||||
if failRuntime {
|
||||
if !errors.Is(err, runErr) {
|
||||
t.Fatalf("runtime error = %v, want %v", err, runErr)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("consume error = %v", err)
|
||||
}
|
||||
|
||||
wantCleanup := 0
|
||||
if selfCreated || ephemeral {
|
||||
wantCleanup = 1
|
||||
}
|
||||
if deleteCalls != wantCleanup || removeCalls != wantCleanup {
|
||||
t.Fatalf(
|
||||
"cleanup delete/remove = %d/%d, want %d/%d",
|
||||
deleteCalls,
|
||||
removeCalls,
|
||||
wantCleanup,
|
||||
wantCleanup,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalConsumeCleanupOwnershipOnRunStateFailure(t *testing.T) {
|
||||
stateErr := errors.New("save state failed")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
selfCreated bool
|
||||
ephemeral bool
|
||||
wantCleanup int
|
||||
}{
|
||||
{name: "self-created", selfCreated: true, wantCleanup: 1},
|
||||
{name: "reused persistent", wantCleanup: 0},
|
||||
{name: "reused ephemeral", ephemeral: true, wantCleanup: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
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", ClientID: "client", SourceID: "open"}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(
|
||||
context.Context,
|
||||
*personal.Client,
|
||||
personal.Identity,
|
||||
personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return stateErr }
|
||||
deleteCalls := 0
|
||||
removeCalls := 0
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
|
||||
deleteCalls++
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error {
|
||||
removeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
Ephemeral: test.ephemeral,
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
}
|
||||
if !test.selfCreated {
|
||||
opts.SubscribeID = "sub-one"
|
||||
}
|
||||
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts); !errors.Is(err, stateErr) {
|
||||
t.Fatalf("state error = %v, want %v", err, stateErr)
|
||||
}
|
||||
if deleteCalls != test.wantCleanup || removeCalls != test.wantCleanup {
|
||||
t.Fatalf(
|
||||
"cleanup delete/remove = %d/%d, want %d/%d",
|
||||
deleteCalls,
|
||||
removeCalls,
|
||||
test.wantCleanup,
|
||||
test.wantCleanup,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalReusedSubscriptionEventKeyResolution(t *testing.T) {
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
requested string
|
||||
actual string
|
||||
wantKey string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "matching key uses actual",
|
||||
requested: personal.EventMention,
|
||||
actual: personal.EventMention,
|
||||
wantKey: personal.EventMention,
|
||||
},
|
||||
{
|
||||
name: "implicit key uses actual",
|
||||
actual: personal.EventOAApprovalTaskCreated,
|
||||
wantKey: personal.EventOAApprovalTaskCreated,
|
||||
},
|
||||
{
|
||||
name: "missing actual falls back to requested",
|
||||
requested: personal.EventOAApprovalTaskCreated,
|
||||
wantKey: personal.EventOAApprovalTaskCreated,
|
||||
},
|
||||
{
|
||||
name: "requested IM mismatches actual OA",
|
||||
requested: personal.EventMention,
|
||||
actual: personal.EventOAApprovalTaskCreated,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "requested OA mismatches actual IM",
|
||||
requested: personal.EventOAApprovalTaskCreated,
|
||||
actual: personal.EventMention,
|
||||
wantErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "sub-one",
|
||||
EventKey: test.actual,
|
||||
}, nil
|
||||
}
|
||||
_, eventKey, _, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
personalConsumeOptions{SubscribeID: "sub-one", EventKey: test.requested},
|
||||
)
|
||||
if test.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
|
||||
t.Fatalf("mismatch error = %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.requested) || !strings.Contains(err.Error(), test.actual) {
|
||||
t.Fatalf("mismatch error does not identify both keys: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolve reused subscription: %v", err)
|
||||
}
|
||||
if eventKey != test.wantKey {
|
||||
t.Fatalf("resolved event key = %q, want %q", eventKey, test.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalReusedSubscriptionMismatchStopsDryRunAndRuntime(t *testing.T) {
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
dryRun bool
|
||||
foreground bool
|
||||
}{
|
||||
{name: "dry-run", dryRun: true},
|
||||
{name: "background"},
|
||||
{name: "foreground", foreground: true},
|
||||
} {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldNewSource := personalNewStreamSource
|
||||
oldBusRun := personalBusRun
|
||||
oldConsumeRun := personalConsumeRun
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
personalConsumeRun = oldConsumeRun
|
||||
})
|
||||
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
|
||||
}
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "sub-one",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
|
||||
upsertCalls := 0
|
||||
consumeCalls := 0
|
||||
busCalls := 0
|
||||
personalUpsertRunState = func(string, personal.RunState) error {
|
||||
upsertCalls++
|
||||
return nil
|
||||
}
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
personalBusRun = func(context.Context, bus.Config) error {
|
||||
busCalls++
|
||||
return nil
|
||||
}
|
||||
personalConsumeRun = func(context.Context, consume.Config) error {
|
||||
consumeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
SubscribeID: "sub-one",
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
Common: commonConsumeOptions{
|
||||
DryRun: mode.dryRun,
|
||||
Foreground: mode.foreground,
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
|
||||
t.Fatalf("mismatch error = %v", err)
|
||||
}
|
||||
if upsertCalls != 0 || consumeCalls != 0 || busCalls != 0 {
|
||||
t.Fatalf(
|
||||
"mismatch reached upsert/consumer/bus = %d/%d/%d",
|
||||
upsertCalls,
|
||||
consumeCalls,
|
||||
busCalls,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalReusedSubscriptionUsesActualKeyInDryRunAndRuntime(t *testing.T) {
|
||||
for _, dryRun := range []bool{true, false} {
|
||||
name := map[bool]string{false: "runtime", true: "dry-run"}[dryRun]
|
||||
t.Run(name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldConsumeRun := personalConsumeRun
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalConsumeRun = oldConsumeRun
|
||||
})
|
||||
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
|
||||
}
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "sub-oa",
|
||||
EventKey: personal.EventOAApprovalInstanceFinished,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
personalUpsertRunState = func(_ string, state personal.RunState) error {
|
||||
if state.EventKey != personal.EventOAApprovalInstanceFinished {
|
||||
t.Fatalf("run state event key = %q", state.EventKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var got consume.Config
|
||||
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
|
||||
got = cfg
|
||||
return nil
|
||||
}
|
||||
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
SubscribeID: "sub-oa",
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
Common: commonConsumeOptions{
|
||||
DryRun: dryRun,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("reuse subscription: %v", err)
|
||||
}
|
||||
if got.EventKey != personal.EventOAApprovalInstanceFinished ||
|
||||
len(got.EventTypes) != 1 || got.EventTypes[0] != personal.EventOAApprovalInstanceFinished ||
|
||||
got.SubscribeID != "sub-oa" {
|
||||
t.Fatalf("consume config = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeDryRunHelpDescribesReuseLookup(t *testing.T) {
|
||||
usage := newEventConsumeCommand().Flags().Lookup("dry-run").Usage
|
||||
for _, want := range []string{"不创建订阅", "不连接 bus", "复用 --subscribe-id", "只读查询控制面"} {
|
||||
if !strings.Contains(usage, want) {
|
||||
t.Fatalf("dry-run help %q missing %q", usage, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/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/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeTokenDetachedChildEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_CHILD"
|
||||
runtimeTokenDetachedWorkDirEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_WORKDIR"
|
||||
runtimeTokenDetachedEndpointEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_ENDPOINT"
|
||||
runtimeTokenDetachedEvidenceEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_EVIDENCE"
|
||||
runtimeTokenDetachedCanaryA = "dws-runtime-e2e-A-9f34c8d10b7e"
|
||||
runtimeTokenDetachedCanaryB = "dws-runtime-e2e-B-2ad761e5c490"
|
||||
runtimeTokenDetachedClientID = "runtime-e2e-client"
|
||||
runtimeTokenDetachedIdentityHash = "90abcdef12345678"
|
||||
runtimeTokenDetachedSourceID = "runtime-e2e-source"
|
||||
)
|
||||
|
||||
// runRuntimeTokenDetachedE2EChild is called at the very start of TestMain.
|
||||
// busctl.Spawn executes this test binary with production-style `event _bus`
|
||||
// arguments; the env marker lets the child run a real bus daemon before the Go
|
||||
// test runner attempts to parse those CLI arguments.
|
||||
func runRuntimeTokenDetachedE2EChild() (int, bool) {
|
||||
if os.Getenv(runtimeTokenDetachedChildEnv) != "1" {
|
||||
return 0, false
|
||||
}
|
||||
workDir := strings.TrimSpace(os.Getenv(runtimeTokenDetachedWorkDirEnv))
|
||||
endpoint := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEndpointEnv))
|
||||
evidence := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEvidenceEnv))
|
||||
if workDir == "" || endpoint == "" || evidence == "" {
|
||||
return 91, true
|
||||
}
|
||||
|
||||
argvClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Args, "\x00"))
|
||||
envClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Environ(), "\x00"))
|
||||
if err := appendRuntimeTokenDetachedEvidence(evidence,
|
||||
fmt.Sprintf("child_start argv_clean=%t env_clean=%t", argvClean, envClean)); err != nil {
|
||||
return 92, true
|
||||
}
|
||||
if !argvClean || !envClean {
|
||||
return 93, true
|
||||
}
|
||||
|
||||
logFile, err := os.OpenFile(filepath.Join(workDir, "bus.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return 94, true
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
err = bus.Run(ctx, bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: runtimeTokenDetachedIdentityHash,
|
||||
SourceID: runtimeTokenDetachedSourceID,
|
||||
Edition: "open",
|
||||
SDKVersion: "runtime-e2e",
|
||||
Source: &runtimeTokenDetachedSource{broker: broker, evidence: evidence},
|
||||
CredentialBroker: broker,
|
||||
ReadyPipe: busctl.ReadyFDFromEnv(),
|
||||
Logger: slog.New(slog.NewTextHandler(logFile, nil)),
|
||||
})
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=false")
|
||||
return 95, true
|
||||
}
|
||||
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=true")
|
||||
return 0, true
|
||||
}
|
||||
|
||||
type runtimeTokenDetachedSource struct {
|
||||
broker *runtimecred.Broker
|
||||
evidence string
|
||||
}
|
||||
|
||||
// Start models the credential-sensitive part of a reconnecting Stream source
|
||||
// without network access. It resolves A for the first connection, waits until a
|
||||
// second consumer rotates the broker to B, then exercises the exact 401 path:
|
||||
// RefreshRejected(A) must return B and must not fall back to local OAuth.
|
||||
func (s *runtimeTokenDetachedSource) Start(ctx context.Context, _ dwsevent.EmitFn) error {
|
||||
first, err := s.broker.Resolve(ctx)
|
||||
if err != nil {
|
||||
return errors.New("runtime e2e: initial credential unavailable")
|
||||
}
|
||||
if first != runtimeTokenDetachedCanaryA || s.broker.Generation() != 1 {
|
||||
return errors.New("runtime e2e: initial credential mismatch")
|
||||
}
|
||||
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "resolved_a=true generation=1"); err != nil {
|
||||
return errors.New("runtime e2e: record initial connection")
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(5 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for s.broker.Generation() < 2 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
rotated, err := s.broker.RefreshRejected(ctx, first)
|
||||
if err != nil || rotated != runtimeTokenDetachedCanaryB {
|
||||
return errors.New("runtime e2e: rotated credential unavailable")
|
||||
}
|
||||
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "rejected_a=true resolved_b=true reconnect=true generation=2"); err != nil {
|
||||
return errors.New("runtime e2e: record reconnect")
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func appendRuntimeTokenDetachedEvidence(path, line string) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprintln(f, line)
|
||||
return err
|
||||
}
|
||||
|
||||
func runtimeTokenDetachedContainsCanary(value string) bool {
|
||||
return strings.Contains(value, runtimeTokenDetachedCanaryA) ||
|
||||
strings.Contains(value, runtimeTokenDetachedCanaryB)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnixDetachedRuntimeTokenLifecycleAndCanaryLeakScan(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("real detached-process lifecycle is Unix-only; Windows named-pipe code is cross-compiled separately")
|
||||
}
|
||||
|
||||
root, err := os.MkdirTemp("/tmp", "dws-runtime-token-e2e-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
workDir := filepath.Join(root, "events", "open", string(dwsevent.SourceKindPersonalStream), runtimeTokenDetachedIdentityHash)
|
||||
if err := os.MkdirAll(workDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindPersonalStream, runtimeTokenDetachedIdentityHash)
|
||||
evidencePath := filepath.Join(workDir, "runtime-e2e.evidence")
|
||||
|
||||
identity := personal.Identity{
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
SourceID: runtimeTokenDetachedSourceID,
|
||||
CorpID: "runtime-e2e-corp",
|
||||
UserID: "runtime-e2e-user",
|
||||
}
|
||||
spawnArgs := personalBusSpawnArgsForToken(identity, runtimeTokenDetachedIdentityHash, "", "", "corp:user", runtimeTokenDetachedCanaryA)
|
||||
assertRuntimeTokenDetachedClean(t, "spawn argv", []byte(strings.Join(spawnArgs, "\x00")))
|
||||
|
||||
childEnv := append([]string{}, os.Environ()...)
|
||||
childEnv = append(childEnv,
|
||||
runtimeTokenDetachedChildEnv+"=1",
|
||||
runtimeTokenDetachedWorkDirEnv+"="+workDir,
|
||||
runtimeTokenDetachedEndpointEnv+"="+endpoint,
|
||||
runtimeTokenDetachedEvidenceEnv+"="+evidencePath,
|
||||
)
|
||||
assertRuntimeTokenDetachedClean(t, "spawn environment", []byte(strings.Join(childEnv, "\x00")))
|
||||
|
||||
pid, err := busctl.Spawn(busctl.SpawnConfig{
|
||||
ExecPath: os.Args[0],
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
ExtraArgs: spawnArgs,
|
||||
Env: childEnv,
|
||||
})
|
||||
if err != nil {
|
||||
failRuntimeTokenDetachedError(t, "spawn detached runtime bus", err)
|
||||
}
|
||||
stopped := false
|
||||
t.Cleanup(func() {
|
||||
if !stopped {
|
||||
_ = busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 2 * time.Second})
|
||||
if proc, findErr := os.FindProcess(pid); findErr == nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
waitRuntimeTokenDetachedFile(t, evidencePath, "child_start argv_clean=true env_clean=true", 3*time.Second)
|
||||
|
||||
var stdoutA, stderrA bytes.Buffer
|
||||
err = consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
|
||||
workDir, endpoint, "sub-runtime-a", runtimeTokenDetachedCanaryA, 500*time.Millisecond, &stdoutA, &stderrA,
|
||||
))
|
||||
if err != nil {
|
||||
failRuntimeTokenDetachedError(t, "consume token A", err)
|
||||
}
|
||||
waitRuntimeTokenDetachedFile(t, evidencePath, "resolved_a=true generation=1", 3*time.Second)
|
||||
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: "sub-runtime-b",
|
||||
EventKey: personal.EventMention,
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
SourceID: runtimeTokenDetachedSourceID,
|
||||
IdentityHash: runtimeTokenDetachedIdentityHash,
|
||||
}); err != nil {
|
||||
failRuntimeTokenDetachedError(t, "persist non-sensitive run state", err)
|
||||
}
|
||||
|
||||
var stdoutB, stderrB bytes.Buffer
|
||||
consumeDone := make(chan error, 1)
|
||||
go func() {
|
||||
consumeDone <- consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
|
||||
workDir, endpoint, "sub-runtime-b", runtimeTokenDetachedCanaryB, 5*time.Second, &stdoutB, &stderrB,
|
||||
))
|
||||
}()
|
||||
|
||||
status := waitRuntimeTokenDetachedStatus(t, endpoint, "sub-runtime-b", 3*time.Second)
|
||||
if status.Bus.PID != pid || status.Bus.IdentityHash != runtimeTokenDetachedIdentityHash {
|
||||
t.Fatalf("status bus identity = %#v, want pid=%d identity=%s", status.Bus, pid, runtimeTokenDetachedIdentityHash)
|
||||
}
|
||||
waitRuntimeTokenDetachedFile(t, evidencePath, "rejected_a=true resolved_b=true reconnect=true generation=2", 3*time.Second)
|
||||
|
||||
stopResp, err := busctl.StopConsumers(endpoint, []string{"sub-runtime-b"})
|
||||
if err != nil {
|
||||
failRuntimeTokenDetachedError(t, "targeted consumer stop", err)
|
||||
}
|
||||
if len(stopResp.Stopped) != 1 || stopResp.Stopped[0] != "sub-runtime-b" {
|
||||
t.Fatalf("targeted stop response = %#v", stopResp)
|
||||
}
|
||||
select {
|
||||
case err := <-consumeDone:
|
||||
if err != nil {
|
||||
failRuntimeTokenDetachedError(t, "consume token B after targeted stop", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("token B consumer did not exit after targeted stop")
|
||||
}
|
||||
status = waitRuntimeTokenDetachedStatus(t, endpoint, "", 3*time.Second)
|
||||
if len(status.Consumers) != 0 {
|
||||
t.Fatalf("status consumers after stop = %#v", status.Consumers)
|
||||
}
|
||||
|
||||
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 4 * time.Second}); err != nil {
|
||||
failRuntimeTokenDetachedError(t, "stop detached bus", err)
|
||||
}
|
||||
stopped = true
|
||||
waitRuntimeTokenDetachedFile(t, evidencePath, "bus_exit clean=true", 3*time.Second)
|
||||
|
||||
statusJSON, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopJSON, err := json.Marshal(stopResp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, artifact := range map[string][]byte{
|
||||
"consume A stdout": stdoutA.Bytes(),
|
||||
"consume A stderr": stderrA.Bytes(),
|
||||
"consume B stdout": stdoutB.Bytes(),
|
||||
"consume B stderr": stderrB.Bytes(),
|
||||
"status response": statusJSON,
|
||||
"stop response": stopJSON,
|
||||
} {
|
||||
assertRuntimeTokenDetachedClean(t, name, artifact)
|
||||
}
|
||||
assertRuntimeTokenDetachedTreeClean(t, root)
|
||||
|
||||
for _, required := range []string{
|
||||
filepath.Join(workDir, bus.MetaFileName),
|
||||
filepath.Join(workDir, "bus.log"),
|
||||
filepath.Join(workDir, personal.StateFileName),
|
||||
evidencePath,
|
||||
} {
|
||||
if info, statErr := os.Stat(required); statErr != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("expected runtime artifact %s: info=%v err=%v", required, info, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeTokenDetachedConsumeConfig(workDir, endpoint, subscribeID, token string, duration time.Duration, stdout, stderr *bytes.Buffer) consume.Config {
|
||||
return consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: runtimeTokenDetachedClientID,
|
||||
RuntimeToken: token,
|
||||
EventTypes: []string{personal.EventMention},
|
||||
EventKey: personal.EventMention,
|
||||
SubscribeID: subscribeID,
|
||||
ReadySubscribeID: subscribeID,
|
||||
Duration: duration,
|
||||
Format: consume.FormatNDJSON,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
}
|
||||
}
|
||||
|
||||
func waitRuntimeTokenDetachedFile(t *testing.T, path, want string, timeout time.Duration) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && strings.Contains(string(data), want) {
|
||||
return string(data)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if runtimeTokenDetachedContainsCanary(string(data)) {
|
||||
t.Fatalf("runtime credential leaked into child evidence while waiting for %q", want)
|
||||
}
|
||||
t.Fatalf("evidence %s missing %q: data=%q err=%v", path, want, data, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitRuntimeTokenDetachedStatus(t *testing.T, endpoint, subscribeID string, timeout time.Duration) *transport.StatusResp {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
status, err := busctl.QueryStatus(endpoint)
|
||||
if err == nil {
|
||||
if subscribeID == "" && len(status.Consumers) == 0 {
|
||||
return status
|
||||
}
|
||||
for _, consumer := range status.Consumers {
|
||||
if consumer.SubscribeID == subscribeID {
|
||||
return status
|
||||
}
|
||||
}
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("status never reached subscribe_id=%q: %v", subscribeID, lastErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertRuntimeTokenDetachedTreeClean(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() || !entry.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assertRuntimeTokenDetachedClean(t, path, data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("scan runtime artifacts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRuntimeTokenDetachedClean(t *testing.T, name string, artifact []byte) {
|
||||
t.Helper()
|
||||
if runtimeTokenDetachedContainsCanary(string(artifact)) {
|
||||
t.Fatalf("runtime credential leaked into %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func failRuntimeTokenDetachedError(t *testing.T, step string, err error) {
|
||||
t.Helper()
|
||||
if err != nil && runtimeTokenDetachedContainsCanary(err.Error()) {
|
||||
t.Fatalf("%s failed and exposed a runtime credential", step)
|
||||
}
|
||||
t.Fatalf("%s: %v", step, err)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"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/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenBusRejectsIncompleteIdentity(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid identity hash",
|
||||
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "not-a-hash", "--client-id", "client"},
|
||||
want: "16-character hexadecimal identity hash",
|
||||
},
|
||||
{
|
||||
name: "missing client id",
|
||||
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "0123456789abcdef"},
|
||||
want: "--client-id is required",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newEventBusCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute() error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type eventRuntimeTokenReleaseErrorStore struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (*eventRuntimeTokenReleaseErrorStore) Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*eventRuntimeTokenReleaseErrorStore) CompleteSuccess(*personal.AttemptClaim) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*eventRuntimeTokenReleaseErrorStore) CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error) {
|
||||
return personal.AttemptHold{}, nil
|
||||
}
|
||||
|
||||
func (s *eventRuntimeTokenReleaseErrorStore) Release(*personal.AttemptClaim) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenAttemptReleaseGuardEdges(t *testing.T) {
|
||||
var nilReservation *personalSubscriptionAttemptReservation
|
||||
if err := nilReservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
|
||||
t.Fatalf("nil reservation error = %v", err)
|
||||
}
|
||||
|
||||
incomplete := &personalSubscriptionAttemptReservation{}
|
||||
if err := incomplete.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
|
||||
!strings.Contains(err.Error(), "reservation is incomplete") {
|
||||
t.Fatalf("incomplete reservation error = %v", err)
|
||||
}
|
||||
|
||||
wantErr := errors.New("release failed")
|
||||
reservation := &personalSubscriptionAttemptReservation{
|
||||
store: &eventRuntimeTokenReleaseErrorStore{err: wantErr},
|
||||
claim: &personal.AttemptClaim{AttemptID: "attempt"},
|
||||
}
|
||||
if err := reservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
|
||||
!errors.Is(err, wantErr) {
|
||||
t.Fatalf("release failure error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenConsumeRejectionAndOversizeEdges(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldProfile := authpkg.RuntimeProfile()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldValidate := personalValidateConsumeConfig
|
||||
oldConflict := personalValidateNoOutputConflict
|
||||
oldAttemptStore := personalNewSubscriptionAttemptStore
|
||||
oldEnsure := personalEnsureSubscription
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
authpkg.SetRuntimeProfile(oldProfile)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalValidateConsumeConfig = oldValidate
|
||||
personalValidateNoOutputConflict = oldConflict
|
||||
personalNewSubscriptionAttemptStore = oldAttemptStore
|
||||
personalEnsureSubscription = oldEnsure
|
||||
})
|
||||
edition.Override(&edition.Hooks{})
|
||||
authpkg.SetRuntimeProfile("")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
|
||||
oversized := strings.Repeat("x", runtimecred.DefaultMaxTokenBytes+1)
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
ExplicitToken: oversized,
|
||||
ClientIDOverride: "runtime-client",
|
||||
Common: commonConsumeOptions{Foreground: true},
|
||||
})
|
||||
if !errors.Is(err, runtimecred.ErrTokenTooLarge) {
|
||||
t.Fatalf("oversized foreground token error = %v", err)
|
||||
}
|
||||
|
||||
rejection := &personal.APIError{
|
||||
Code: "RUNTIME_TOKEN_REJECTED",
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
if personalRuntimeTokenControlRejection(errors.New("ordinary failure")) {
|
||||
t.Fatal("ordinary error classified as runtime-token rejection")
|
||||
}
|
||||
|
||||
singleStore := &personalRecordingAttemptStore{}
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return singleStore }
|
||||
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
return nil, "", "", rejection
|
||||
}
|
||||
err = runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
ExplicitToken: "runtime-token-single",
|
||||
ClientIDOverride: "runtime-client",
|
||||
ControlBaseURL: "https://control.example.test",
|
||||
})
|
||||
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || singleStore.releaseCalls != 1 || singleStore.failureCalls != 0 {
|
||||
t.Fatalf("single rejection = %v, release=%d failure=%d", err, singleStore.releaseCalls, singleStore.failureCalls)
|
||||
}
|
||||
|
||||
manyStore := &personalRecordingAttemptStore{}
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return manyStore }
|
||||
err = runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
ExplicitToken: "runtime-token-many",
|
||||
ClientIDOverride: "runtime-client",
|
||||
ControlBaseURL: "https://control.example.test",
|
||||
})
|
||||
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || manyStore.releaseCalls != 1 || manyStore.failureCalls != 0 {
|
||||
t.Fatalf("multi rejection = %v, release=%d failure=%d", err, manyStore.releaseCalls, manyStore.failureCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenIdentityFallbackEdges(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
oldEdition := edition.Get()
|
||||
oldProfile := authpkg.RuntimeProfile()
|
||||
oldResolveIdentity := personalResolveEventIdentity
|
||||
oldResolveAuxiliary := personalResolveAuxiliaryAccessToken
|
||||
oldLoadTokenData := personalLoadTokenData
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
oldClientID := personalClientID
|
||||
oldResolveCredentials := personalResolveAppCredentialsStrict
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
authpkg.SetRuntimeProfile(oldProfile)
|
||||
personalResolveEventIdentity = oldResolveIdentity
|
||||
personalResolveAuxiliaryAccessToken = oldResolveAuxiliary
|
||||
personalLoadTokenData = oldLoadTokenData
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
personalClientID = oldClientID
|
||||
personalResolveAppCredentialsStrict = oldResolveCredentials
|
||||
})
|
||||
|
||||
legacy := personal.Identity{ClientID: "legacy-client", SourceID: "legacy-source"}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return legacy, nil }
|
||||
identity, err := resolvePersonalEventIdentityForToken(context.Background(), configDir, "", " ")
|
||||
if err != nil || identity.ClientID != legacy.ClientID {
|
||||
t.Fatalf("wrapper empty-token fallback = %#v, %v", identity, err)
|
||||
}
|
||||
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
|
||||
return "legacy-access", nil
|
||||
}
|
||||
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{
|
||||
CorpID: "legacy-corp", UserID: "legacy-user", ClientID: "direct-client",
|
||||
}, nil
|
||||
}
|
||||
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", " ")
|
||||
if err != nil || identity.ClientID != "direct-client" {
|
||||
t.Fatalf("direct empty-token fallback = %#v, %v", identity, err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{})
|
||||
personalRuntimeEventClientID = func() string { return "" }
|
||||
personalClientID = func() string { return "" }
|
||||
wantMetadataErr := errors.New("profiles unreadable")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, wantMetadataErr }
|
||||
authpkg.SetRuntimeProfile("corp:user")
|
||||
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "token", "runtime-client"); !errors.Is(err, wantMetadataErr) {
|
||||
t.Fatalf("explicit profile metadata error = %v", err)
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile("")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "app-client", "", "", "", nil
|
||||
}
|
||||
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token")
|
||||
if err != nil || identity.ClientID != "app-client" || !strings.HasPrefix(identity.LocalSubject, "access:") {
|
||||
t.Fatalf("app-credential fallback identity = %#v, %v", identity, err)
|
||||
}
|
||||
|
||||
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
return "", "", "", "", errors.New("missing app credentials")
|
||||
}
|
||||
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token"); err == nil || !strings.Contains(err.Error(), "cannot resolve OAuth client_id") {
|
||||
t.Fatalf("missing client ID error = %v", err)
|
||||
}
|
||||
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{
|
||||
CurrentProfile: "stale",
|
||||
Profiles: []authpkg.Profile{{Name: "other", CorpID: "corp", UserID: "user"}},
|
||||
}, nil
|
||||
}
|
||||
profile, err := personalEventProfileMetadata(configDir)
|
||||
if err != nil || profile != nil {
|
||||
t.Fatalf("stale implicit current profile = %#v, %v", profile, err)
|
||||
}
|
||||
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{Name: "other"}}}, nil
|
||||
}
|
||||
profile, err = personalEventProfileMetadata(configDir)
|
||||
if err != nil || profile != nil {
|
||||
t.Fatalf("empty implicit selector = %#v, %v", profile, err)
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
if _, err := personalEventProfileMetadata(configDir); err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
|
||||
t.Fatalf("multi-profile metadata error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type eventRuntimeTokenReadErrorBody struct{}
|
||||
|
||||
func (eventRuntimeTokenReadErrorBody) Read([]byte) (int, error) {
|
||||
return 0, errors.New("body read failed")
|
||||
}
|
||||
|
||||
func (eventRuntimeTokenReadErrorBody) Close() error { return nil }
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenControlTransportErrorEdges(t *testing.T) {
|
||||
const token = "runtime-control-edge-canary"
|
||||
|
||||
unsupported, err := http.NewRequest(http.MethodGet, "unsupported://control.example.test/path", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (runtimeTokenControlTransport{}).RoundTrip(unsupported); err == nil {
|
||||
t.Fatal("nil base unexpectedly accepted an unsupported protocol")
|
||||
}
|
||||
|
||||
wantTransportErr := errors.New("ordinary transport failure")
|
||||
ordinary := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, wantTransportErr
|
||||
})}
|
||||
if _, err := ordinary.RoundTrip(unsupported); !errors.Is(err, wantTransportErr) {
|
||||
t.Fatalf("ordinary transport error = %v", err)
|
||||
}
|
||||
|
||||
leaking := runtimeTokenControlTransport{
|
||||
token: token,
|
||||
base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("reflected " + token)
|
||||
}),
|
||||
}
|
||||
if _, err := leaking.RoundTrip(unsupported); err == nil || strings.Contains(err.Error(), token) ||
|
||||
err.Error() != "personal event: runtime-token control request failed" {
|
||||
t.Fatalf("redacted transport error = %v", err)
|
||||
}
|
||||
|
||||
nilResponse := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, nil
|
||||
})}
|
||||
if resp, err := nilResponse.RoundTrip(unsupported); resp != nil || err != nil {
|
||||
t.Fatalf("nil response = %#v, %v", resp, err)
|
||||
}
|
||||
|
||||
readFailure := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Header: make(http.Header),
|
||||
Body: eventRuntimeTokenReadErrorBody{},
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
if _, err := readFailure.RoundTrip(unsupported); err == nil || !strings.Contains(err.Error(), "read runtime-token control response") {
|
||||
t.Fatalf("body read error = %v", err)
|
||||
}
|
||||
|
||||
nilHeader := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Request: req}, nil
|
||||
})}
|
||||
resp, err := nilHeader.RoundTrip(unsupported)
|
||||
if err != nil || resp == nil || resp.Header == nil || resp.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("nil-header response = %#v, %v", resp, err)
|
||||
}
|
||||
|
||||
if got := redactRuntimeTokenResponseBody(nil, token); len(got) != 0 {
|
||||
t.Fatalf("empty response redaction = %q", got)
|
||||
}
|
||||
value, changed := redactRuntimeTokenJSONValue([]any{"plain", "prefix-" + token}, token)
|
||||
items, ok := value.([]any)
|
||||
if !ok || !changed || len(items) != 2 || strings.Contains(items[1].(string), token) {
|
||||
t.Fatalf("array redaction = %#v changed=%t", value, changed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
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/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandsWireTrimmedRootRuntimeToken(t *testing.T) {
|
||||
oldConsume := eventRunPersonalConsume
|
||||
oldStatus := eventRunPersonalStatus
|
||||
oldStop := eventRunPersonalStop
|
||||
t.Cleanup(func() {
|
||||
eventRunPersonalConsume = oldConsume
|
||||
eventRunPersonalStatus = oldStatus
|
||||
eventRunPersonalStop = oldStop
|
||||
})
|
||||
|
||||
flags := &GlobalFlags{Token: " runtime-canary ", ClientID: " root-client "}
|
||||
assertIdentity := func(token, clientID string) {
|
||||
t.Helper()
|
||||
if token != "runtime-canary" || clientID != "root-client" {
|
||||
t.Fatalf("runtime identity = token %q client %q", token, clientID)
|
||||
}
|
||||
}
|
||||
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
|
||||
return nil
|
||||
}
|
||||
consumeCmd := newEventConsumeCommand(flags)
|
||||
if err := consumeCmd.RunE(consumeCmd, []string{personal.EventMention}); err != nil {
|
||||
t.Fatalf("consume RunE() error = %v", err)
|
||||
}
|
||||
|
||||
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
|
||||
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
|
||||
return nil
|
||||
}
|
||||
statusCmd := newEventStatusCommandWithFlags(flags)
|
||||
if err := statusCmd.RunE(statusCmd, nil); err != nil {
|
||||
t.Fatalf("status RunE() error = %v", err)
|
||||
}
|
||||
|
||||
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
|
||||
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
|
||||
return nil
|
||||
}
|
||||
stopCmd := newEventStopCommandWithFlags(flags)
|
||||
stopRoot := &cobra.Command{Use: "dws"}
|
||||
stopRoot.PersistentFlags().Bool("yes", true, "")
|
||||
stopRoot.AddCommand(stopCmd)
|
||||
if err := stopCmd.RunE(stopCmd, []string{"sub-runtime"}); err != nil {
|
||||
t.Fatalf("stop RunE() error = %v", err)
|
||||
}
|
||||
|
||||
listenCmd := newEventListenIMCommand(flags)
|
||||
if err := listenCmd.RunE(listenCmd, nil); err != nil {
|
||||
t.Fatalf("listen-im RunE() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventConsumeParsesRootRuntimeTokenBeforeAndAfterSubcommand(t *testing.T) {
|
||||
oldConsume := eventRunPersonalConsume
|
||||
t.Cleanup(func() { eventRunPersonalConsume = oldConsume })
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "before", args: []string{"--token", "runtime-before", "event", "consume", personal.EventMention}},
|
||||
{name: "after", args: []string{"event", "consume", personal.EventMention, "--token", "runtime-after"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
flags := &GlobalFlags{}
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
bindPersistentFlags(root, flags)
|
||||
root.AddCommand(newEventCommand(flags))
|
||||
var got string
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
got = opts.ExplicitToken
|
||||
return nil
|
||||
}
|
||||
root.SetArgs(tc.args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
want := "runtime-" + tc.name
|
||||
if got != want {
|
||||
t.Fatalf("ExplicitToken = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenUsesMetadataOnly(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldLoadToken := personalLoadTokenData
|
||||
oldAux := personalResolveAuxiliaryAccessToken
|
||||
oldClientID := personalClientID
|
||||
oldCredentials := personalResolveAppCredentialsStrict
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalLoadTokenData = oldLoadToken
|
||||
personalResolveAuxiliaryAccessToken = oldAux
|
||||
personalClientID = oldClientID
|
||||
personalResolveAppCredentialsStrict = oldCredentials
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
})
|
||||
|
||||
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||
t.Fatal("explicit token identity read sensitive TokenData")
|
||||
return nil, nil
|
||||
}
|
||||
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
|
||||
t.Fatal("explicit token identity resolved local OAuth")
|
||||
return "", nil
|
||||
}
|
||||
personalClientID = func() string {
|
||||
t.Fatal("explicit root client ID was not preferred")
|
||||
return ""
|
||||
}
|
||||
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||
t.Fatal("explicit root client ID unexpectedly fell back to app credentials")
|
||||
return "", "", "", "", nil
|
||||
}
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{{
|
||||
Name: "Runtime profile",
|
||||
CorpID: "profile-corp",
|
||||
CorpName: "Runtime Org",
|
||||
UserID: "profile-user",
|
||||
UserName: "Runtime User",
|
||||
ClientID: "profile-client",
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
authpkg.SetRuntimeProfile("Runtime Org:Runtime User")
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "", false },
|
||||
}
|
||||
}})
|
||||
|
||||
identity, err := resolvePersonalEventIdentityWithToken(
|
||||
context.Background(), "unused", "runtime-source", " runtime-canary ", "root-client",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.CorpID != "runtime-corp" || identity.UserID != "profile-user" || identity.ClientID != "root-client" {
|
||||
t.Fatalf("identity metadata = %#v", identity)
|
||||
}
|
||||
if identity.AccessToken != "" {
|
||||
t.Fatalf("identity retained raw runtime token: %q", identity.AccessToken)
|
||||
}
|
||||
if identity.LocalSubject != "" {
|
||||
t.Fatalf("complete identity LocalSubject = %q, want empty", identity.LocalSubject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithCompleteRuntimeMetadataSkipsProfiles(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
})
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
t.Fatal("complete host metadata unexpectedly read profiles.json")
|
||||
return nil, nil
|
||||
}
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
personalRuntimeEventClientID = func() string { return "edition-client" }
|
||||
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "root-client" {
|
||||
t.Fatalf("identity = %#v", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeEventClientIDPrefersEditionBeforeEnvironment(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
t.Setenv("DWS_CLIENT_ID", "environment-client")
|
||||
|
||||
edition.Override(&edition.Hooks{AuthClientID: "edition-client"})
|
||||
if got := runtimePersonalEventClientID(); got != "edition-client" {
|
||||
t.Fatalf("runtime client ID = %q, want edition hook", got)
|
||||
}
|
||||
edition.Override(&edition.Hooks{})
|
||||
if got := runtimePersonalEventClientID(); got != "environment-client" {
|
||||
t.Fatalf("runtime client ID = %q, want environment fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompleteRuntimeIdentityUsesEditionClientBeforeProfiles(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
})
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
t.Fatal("complete host metadata unexpectedly read profiles.json")
|
||||
return nil, errors.New("unreachable")
|
||||
}
|
||||
personalRuntimeEventClientID = func() string { return "edition-client" }
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "edition-client" {
|
||||
t.Fatalf("identity = %#v", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSelectedProfileClientPrecedesPersistedGlobalClient(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
oldClientID := personalClientID
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
personalClientID = oldClientID
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
})
|
||||
edition.Override(&edition.Hooks{})
|
||||
personalRuntimeEventClientID = func() string { return "" }
|
||||
personalClientID = func() string { return "stale-global-client" }
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{
|
||||
Version: 3,
|
||||
CurrentProfile: "corp:user",
|
||||
Profiles: []authpkg.Profile{{
|
||||
Name: "Selected", CorpID: "corp", UserID: "user", ClientID: "profile-client",
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
authpkg.SetRuntimeProfile("corp:user")
|
||||
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.ClientID != "profile-client" {
|
||||
t.Fatalf("ClientID = %q, want selected profile client", identity.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMalformedPersistedProfilesDoNotBlockRuntimeDefaultsAndGlobalClient(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
oldClientID := personalClientID
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
personalClientID = oldClientID
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
})
|
||||
authpkg.SetRuntimeProfile("")
|
||||
personalRuntimeEventClientID = func() string { return "" }
|
||||
personalClientID = func() string { return "global-client" }
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return nil, errors.New("malformed persisted profiles")
|
||||
}
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
|
||||
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "global-client" {
|
||||
t.Fatalf("identity = %#v", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenRejectsMultipleProfilesBeforeMetadata(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
authpkg.SetRuntimeProfile(previousProfile)
|
||||
})
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
t.Fatal("multiple runtime profiles unexpectedly reached metadata loading")
|
||||
return nil, nil
|
||||
}
|
||||
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
_, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
|
||||
if err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
|
||||
t.Fatalf("multiple-profile error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitProfileRequiresMetadataRegistry(t *testing.T) {
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
defer func() { personalLoadProfiles = oldLoadProfiles }()
|
||||
oldProfile := authpkg.RuntimeProfile()
|
||||
authpkg.SetRuntimeProfile("missing-profile")
|
||||
defer authpkg.SetRuntimeProfile(oldProfile)
|
||||
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{}, nil
|
||||
}
|
||||
_, err := personalEventProfileMetadata(t.TempDir())
|
||||
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
|
||||
t.Fatalf("personalEventProfileMetadata() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompleteRuntimeIdentityStillValidatesExplicitProfile(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
oldLoadProfiles := personalLoadProfiles
|
||||
oldRuntimeClientID := personalRuntimeEventClientID
|
||||
oldProfile := authpkg.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalLoadProfiles = oldLoadProfiles
|
||||
personalRuntimeEventClientID = oldRuntimeClientID
|
||||
authpkg.SetRuntimeProfile(oldProfile)
|
||||
})
|
||||
|
||||
authpkg.SetRuntimeProfile("missing-profile")
|
||||
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{}, nil
|
||||
}
|
||||
personalRuntimeEventClientID = func() string { return "runtime-client" }
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
|
||||
_, err := resolvePersonalEventIdentityWithToken(context.Background(), t.TempDir(), "source", "canary")
|
||||
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
|
||||
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalProfileMetadataOrganizationCurrentBeatsUnresolved(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 3,
|
||||
Profiles: []authpkg.Profile{
|
||||
{Name: "Historical", CorpID: "corp-1"},
|
||||
{Name: "Exact", CorpID: "corp-1", UserID: "user-1"},
|
||||
},
|
||||
OrgCurrentProfiles: map[string]string{"corp-1": "corp-1:user-1"},
|
||||
}
|
||||
profile, err := selectPersonalEventProfileMetadata(cfg, "corp-1", make(map[string]struct{}))
|
||||
if err != nil {
|
||||
t.Fatalf("selectPersonalEventProfileMetadata() error = %v", err)
|
||||
}
|
||||
if profile == nil || profile.UserID != "user-1" {
|
||||
t.Fatalf("selected profile = %#v, want organization current account", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsReflected401(t *testing.T) {
|
||||
const token = "runtime-control-canary"
|
||||
oldLogger := slog.Default()
|
||||
var logs bytes.Buffer
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(oldLogger) })
|
||||
|
||||
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, token)
|
||||
wrapped, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport)
|
||||
if !ok {
|
||||
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
|
||||
}
|
||||
var authorization string
|
||||
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
authorization = req.Header.Get("Authorization")
|
||||
body := `{"code":"UNAUTHORIZED","message":"rejected ` + token + `"}`
|
||||
header := make(http.Header)
|
||||
header.Set("X-Request-Id", "request-"+token)
|
||||
header.Set("X-Trace-Id", "trace-"+token)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: header,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client.HTTPClient.Transport = wrapped
|
||||
|
||||
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("ListSubscriptions() unexpectedly succeeded")
|
||||
}
|
||||
if authorization != "Bearer "+token {
|
||||
t.Fatalf("Authorization = %q", authorization)
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
|
||||
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "RUNTIME_TOKEN_REJECTED") {
|
||||
t.Fatalf("error = %q, want fixed runtime token rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenRedirectGuardDoesNotForwardCustomHeader(t *testing.T) {
|
||||
const token = "runtime-redirect-canary"
|
||||
var controlTargetHits, ticketTargetHits atomic.Int32
|
||||
controlTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
controlTargetHits.Add(1)
|
||||
if r.Header.Get("x-user-access-token") == token {
|
||||
t.Error("control redirect forwarded runtime token")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer controlTarget.Close()
|
||||
controlOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != token {
|
||||
t.Error("control origin did not receive runtime token")
|
||||
}
|
||||
http.Redirect(w, r, controlTarget.URL, http.StatusFound)
|
||||
}))
|
||||
defer controlOrigin.Close()
|
||||
|
||||
client := newPersonalEventControlClient("unused", controlOrigin.URL, personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, token)
|
||||
_, controlErr := client.ListSubscriptions(context.Background(), personal.ListOptions{})
|
||||
if controlErr == nil {
|
||||
t.Fatal("cross-host control redirect unexpectedly succeeded")
|
||||
}
|
||||
if controlTargetHits.Load() != 0 || strings.Contains(controlErr.Error(), token) {
|
||||
t.Fatalf("control redirect hits=%d error=%q", controlTargetHits.Load(), controlErr)
|
||||
}
|
||||
|
||||
ticketTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ticketTargetHits.Add(1)
|
||||
if r.Header.Get("x-user-access-token") == token {
|
||||
t.Error("ticket redirect forwarded runtime token")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer ticketTarget.Close()
|
||||
ticketOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != token {
|
||||
t.Error("ticket origin did not receive runtime token")
|
||||
}
|
||||
http.Redirect(w, r, ticketTarget.URL, http.StatusFound)
|
||||
}))
|
||||
defer ticketOrigin.Close()
|
||||
|
||||
broker := runtimecred.New(runtimecred.Config{RequireSeed: true})
|
||||
if _, err := broker.Update(0, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{
|
||||
ConfigDir: "unused",
|
||||
Identity: personal.Identity{ClientID: "client", SourceID: "source"},
|
||||
TicketURL: ticketOrigin.URL,
|
||||
CredentialBroker: broker,
|
||||
RuntimeTokenMode: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = src.Start(context.Background(), func(*dwsevent.RawEvent) {})
|
||||
if err == nil {
|
||||
t.Fatal("cross-host ticket redirect unexpectedly succeeded")
|
||||
}
|
||||
if ticketTargetHits.Load() != 0 || strings.Contains(err.Error(), token) {
|
||||
t.Fatalf("ticket redirect hits=%d error=%q", ticketTargetHits.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenRedirectPolicyBranches(t *testing.T) {
|
||||
origin, _ := http.NewRequest(http.MethodGet, "https://control.example/start", nil)
|
||||
sameHost, _ := http.NewRequest(http.MethodGet, "https://control.example/next", nil)
|
||||
if err := runtimeTokenRedirectPolicy(sameHost, []*http.Request{origin}); err != nil {
|
||||
t.Fatalf("same-host HTTPS redirect rejected: %v", err)
|
||||
}
|
||||
for name, request := range map[string]*http.Request{
|
||||
"cross-host": func() *http.Request {
|
||||
r, _ := http.NewRequest(http.MethodGet, "https://other.example/next", nil)
|
||||
return r
|
||||
}(),
|
||||
"downgrade": func() *http.Request {
|
||||
r, _ := http.NewRequest(http.MethodGet, "http://control.example/next", nil)
|
||||
return r
|
||||
}(),
|
||||
} {
|
||||
if err := runtimeTokenRedirectPolicy(request, []*http.Request{origin}); !errors.Is(err, http.ErrUseLastResponse) {
|
||||
t.Fatalf("%s redirect policy error = %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := runtimeTokenRedirectPolicy(nil, nil); !errors.Is(err, http.ErrUseLastResponse) {
|
||||
t.Fatalf("empty redirect chain error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsEveryErrorEnvelope(t *testing.T) {
|
||||
const token = "runtime-control-all-status-canary"
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{name: "bad-request", status: http.StatusBadRequest, body: `{"code":"BAD_REQUEST","message":"` + token + `"}`},
|
||||
{name: "server-error", status: http.StatusInternalServerError, body: `{"code":"INTERNAL","message":"` + token + `"}`},
|
||||
{name: "success-false", status: http.StatusOK, body: `{"success":false,"errorCode":"DENIED","errorMsg":"` + token + `"}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
oldLogger := slog.Default()
|
||||
var logs bytes.Buffer
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(oldLogger) })
|
||||
|
||||
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, token)
|
||||
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
|
||||
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
header := make(http.Header)
|
||||
header.Set("X-Request-Id", "request-"+token)
|
||||
header.Set("X-Trace-Id", "trace-"+token)
|
||||
return &http.Response{
|
||||
StatusCode: tc.status,
|
||||
Header: header,
|
||||
Body: io.NopCloser(strings.NewReader(tc.body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client.HTTPClient.Transport = wrapped
|
||||
|
||||
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("ListSubscriptions() unexpectedly succeeded")
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
|
||||
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitTokenControlTransportPreservesSuccessfulResponse(t *testing.T) {
|
||||
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, "runtime-success-canary")
|
||||
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
|
||||
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[],"total":0}}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client.HTTPClient.Transport = wrapped
|
||||
if _, err := client.ListSubscriptions(context.Background(), personal.ListOptions{}); err != nil {
|
||||
t.Fatalf("ListSubscriptions() successful response error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsJSONEscapedToken(t *testing.T) {
|
||||
const token = "runtime<escaped>&canary"
|
||||
body, err := json.Marshal(map[string]any{"code": "BAD_REQUEST", "message": "rejected " + token})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(body, []byte(token)) {
|
||||
t.Fatalf("fixture was not JSON-escaped: %s", body)
|
||||
}
|
||||
oldLogger := slog.Default()
|
||||
var logs bytes.Buffer
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(oldLogger) })
|
||||
|
||||
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
|
||||
ClientID: "client", SourceID: "source",
|
||||
}, token)
|
||||
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
|
||||
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client.HTTPClient.Transport = wrapped
|
||||
_, err = client.ListSubscriptions(context.Background(), personal.ListOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("ListSubscriptions() unexpectedly succeeded")
|
||||
}
|
||||
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
|
||||
t.Fatalf("escaped runtime token leaked: error=%q logs=%q", err, logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenBusModeSkipsLocalOAuthIdentity(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldResolve := eventResolvePersonal
|
||||
oldSource := eventNewPersonalSource
|
||||
oldRun := eventBusRun
|
||||
t.Cleanup(func() {
|
||||
eventResolvePersonal = oldResolve
|
||||
eventNewPersonalSource = oldSource
|
||||
eventBusRun = oldRun
|
||||
})
|
||||
|
||||
resolvedLocal := false
|
||||
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) {
|
||||
resolvedLocal = true
|
||||
return personal.Identity{}, nil
|
||||
}
|
||||
var sourceOpts personalStreamSourceOptions
|
||||
eventNewPersonalSource = func(_ context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
sourceOpts = opts
|
||||
return nil, nil
|
||||
}
|
||||
var busCfg bus.Config
|
||||
eventBusRun = func(_ context.Context, cfg bus.Config) error {
|
||||
busCfg = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventBusCommand()
|
||||
cmd.SetArgs([]string{
|
||||
"--source-kind", "personal_stream",
|
||||
"--runtime-token-mode",
|
||||
"--identity-hash", "0123456789abcdef",
|
||||
"--client-id", "runtime-client",
|
||||
"--stream-source-id", "runtime-source",
|
||||
"--idle-timeout", "0",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("event _bus runtime mode error = %v", err)
|
||||
}
|
||||
if resolvedLocal {
|
||||
t.Fatal("runtime token bus resolved local OAuth identity")
|
||||
}
|
||||
if sourceOpts.CredentialBroker == nil || busCfg.CredentialBroker != sourceOpts.CredentialBroker {
|
||||
t.Fatal("personal source and bus did not share one credential broker")
|
||||
}
|
||||
if busCfg.IdentityHash != "0123456789abcdef" || busCfg.ClientID != "runtime-client" || busCfg.SourceID != "runtime-source" {
|
||||
t.Fatalf("bus identity = %#v", busCfg)
|
||||
}
|
||||
generation, err := sourceOpts.CredentialBroker.Update(0, "detached-activation-canary")
|
||||
if err != nil {
|
||||
t.Fatalf("seed detached broker: %v", err)
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := sourceOpts.CredentialBroker.Resolve(waitCtx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("detached broker resolved before consumer activation: %v", err)
|
||||
}
|
||||
if _, err := sourceOpts.CredentialBroker.Activate(generation); err != nil {
|
||||
t.Fatalf("activate detached broker: %v", err)
|
||||
}
|
||||
if resolved, err := sourceOpts.CredentialBroker.Resolve(context.Background()); err != nil || resolved == "" {
|
||||
t.Fatalf("detached broker did not resolve after activation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForegroundRuntimeBrokerDoesNotRequireActivation(t *testing.T) {
|
||||
broker := newPersonalCredentialBroker(t.TempDir(), true, false)
|
||||
if _, err := broker.Update(0, "foreground-activation-canary"); err != nil {
|
||||
t.Fatalf("seed foreground broker: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if resolved, err := broker.Resolve(ctx); err != nil || resolved == "" {
|
||||
t.Fatalf("foreground broker unexpectedly waited for activation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalRuntimeBusSpawnArgsContainNoSecretOrProfile(t *testing.T) {
|
||||
const token = "runtime-spawn-canary"
|
||||
args := personalBusSpawnArgsForToken(personal.Identity{
|
||||
ClientID: "client", SourceID: "source", CorpID: "corp", UserID: "user",
|
||||
}, "identity-hash", "normal", "https://ticket.invalid", "corp:user", token)
|
||||
joined := strings.Join(args, " ")
|
||||
for _, forbidden := range []string{token, "--profile", "corp:user"} {
|
||||
if strings.Contains(joined, forbidden) {
|
||||
t.Fatalf("spawn args leaked %q: %q", forbidden, joined)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{"--runtime-token-mode", "--identity-hash", "identity-hash", "--stream-source-id", "source"} {
|
||||
if !strings.Contains(joined, required) {
|
||||
t.Fatalf("spawn args %q missing %q", joined, required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnsupportedOldBusDoesNotDeleteReusedSubscription(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
oldConsume := personalConsumeRun
|
||||
oldValidate := personalValidateConsumeConfig
|
||||
oldConflict := personalValidateNoOutputConflict
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
personalConsumeRun = oldConsume
|
||||
personalValidateConsumeConfig = oldValidate
|
||||
personalValidateNoOutputConflict = oldConflict
|
||||
})
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
deleteCalls := 0
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
|
||||
deleteCalls++
|
||||
return nil
|
||||
}
|
||||
var removed []string
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
|
||||
if strings.TrimSpace(cfg.RuntimeToken) == "" {
|
||||
t.Fatal("runtime token was not wired to consume")
|
||||
}
|
||||
return &consume.RuntimeTokenUnsupportedError{BusPID: 72}
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
SubscribeID: "sub-existing",
|
||||
ExplicitToken: "old-bus-cleanup-canary",
|
||||
ClientIDOverride: "runtime-client",
|
||||
})
|
||||
if !errors.Is(err, consume.ErrRuntimeTokenUnsupported) {
|
||||
t.Fatalf("consume error = %v", err)
|
||||
}
|
||||
if deleteCalls != 0 {
|
||||
t.Fatalf("reused remote subscription was deleted %d time(s)", deleteCalls)
|
||||
}
|
||||
if len(removed) != 0 {
|
||||
t.Fatalf("reused local run-state was removed: %#v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenReusedDryRunUsesExplicitControlCredential(t *testing.T) {
|
||||
const token = "runtime-dry-run-control-canary"
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldConsume := personalConsumeRun
|
||||
oldBusRun := personalBusRun
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalConsumeRun = oldConsume
|
||||
personalBusRun = oldBusRun
|
||||
})
|
||||
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||
return map[string]edition.RuntimeDefaultFn{
|
||||
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
|
||||
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
|
||||
}
|
||||
}})
|
||||
|
||||
personalEnsureSubscription = func(ctx context.Context, client *personal.Client, _ personal.Identity, _ personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
if _, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport); !ok {
|
||||
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
|
||||
}
|
||||
got, err := client.AccessTokenProvider(ctx)
|
||||
if err != nil || got != token {
|
||||
t.Fatalf("control token = %q, %v", got, err)
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error {
|
||||
t.Fatal("dry-run unexpectedly persisted run state")
|
||||
return nil
|
||||
}
|
||||
consumeCalls := 0
|
||||
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
|
||||
consumeCalls++
|
||||
if !cfg.DryRun {
|
||||
t.Fatal("consume config is not dry-run")
|
||||
}
|
||||
if strings.Contains(strings.Join(cfg.SpawnExtraArgs, " "), token) {
|
||||
t.Fatal("dry-run spawn args leaked runtime token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
personalBusRun = func(context.Context, bus.Config) error {
|
||||
t.Fatal("dry-run unexpectedly started a bus")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
SubscribeID: "sub-existing",
|
||||
ExplicitToken: token,
|
||||
ClientIDOverride: "runtime-client",
|
||||
Common: commonConsumeOptions{DryRun: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run consume error = %v", err)
|
||||
}
|
||||
if consumeCalls != 1 {
|
||||
t.Fatalf("dry-run consume calls = %d, want 1", consumeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeTokenControlRejectionReleasesSubscriptionClaim(t *testing.T) {
|
||||
store := &personalRecordingAttemptStore{}
|
||||
reservation := &personalSubscriptionAttemptReservation{
|
||||
store: store,
|
||||
claim: &personal.AttemptClaim{AttemptID: "runtime-token-attempt"},
|
||||
items: []personalSubscriptionAttemptItem{{eventKey: personal.EventMention, fingerprint: strings.Repeat("a", 64)}},
|
||||
}
|
||||
cause := &personal.APIError{
|
||||
Code: "RUNTIME_TOKEN_REJECTED",
|
||||
Message: "event runtime token was rejected; retry with a fresh host credential",
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
if !personalRuntimeTokenControlRejection(cause) {
|
||||
t.Fatal("runtime token control rejection was not classified")
|
||||
}
|
||||
err := reservation.releaseRuntimeTokenFailure()
|
||||
if err == nil || !strings.Contains(err.Error(), "runtime token was rejected") {
|
||||
t.Fatalf("releaseRuntimeTokenFailure() error = %v", err)
|
||||
}
|
||||
if store.releaseCalls != 1 || store.failureCalls != 0 {
|
||||
t.Fatalf("attempt store release=%d failure=%d, want release only", store.releaseCalls, store.failureCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type eventRuntimeRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f eventRuntimeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (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/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageEventAgentSelectionBoundaries(t *testing.T) {
|
||||
_ = NewRootCommand()
|
||||
|
||||
eventProduct, ok := contract.LookupProductDecl("event")
|
||||
if !ok {
|
||||
t.Fatal("event ProductDecl is not registered")
|
||||
}
|
||||
assertSelectionContains(t, "event product", eventProduct.Selection.AgentSummary,
|
||||
[]string{"IM", "OA"})
|
||||
assertSelectionContains(t, "event product use_when", strings.Join(eventProduct.Selection.UseWhen, "\n"),
|
||||
[]string{"消息", "群生命周期", "OA"})
|
||||
assertSelectionContains(t, "event product avoid_when", strings.Join(eventProduct.Selection.AvoidWhen, "\n"),
|
||||
[]string{"chat", "oa", "dev app event"})
|
||||
|
||||
listenMeta, ok := cli.ResolveMeta("event +listen-im")
|
||||
if !ok {
|
||||
t.Fatal("event +listen-im metadata is not registered")
|
||||
}
|
||||
assertSelectionContains(t, "event.listen_im use_when", strings.Join(listenMeta.Selection.UseWhen, "\n"),
|
||||
[]string{"@我", "message/reaction/read/recall"})
|
||||
assertSelectionContains(t, "event.listen_im avoid_when", strings.Join(listenMeta.Selection.AvoidWhen, "\n"),
|
||||
[]string{"OA 审批事件", "群标题", "Filter DSL", "event consume", "历史消息"})
|
||||
|
||||
consumeMeta, ok := cli.ResolveMeta("event consume")
|
||||
if !ok {
|
||||
t.Fatal("event consume metadata is not registered")
|
||||
}
|
||||
consumeUse := strings.Join(consumeMeta.Selection.UseWhen, "\n")
|
||||
assertSelectionContains(t, "event.consume use_when", consumeUse,
|
||||
[]string{"OA", "群", "EventKey", "Filter DSL", "subscribe_id", "transport envelope", "高级多事件"})
|
||||
consumeAvoid := strings.Join(consumeMeta.Selection.AvoidWhen, "\n")
|
||||
assertSelectionContains(t, "event.consume avoid_when", consumeAvoid,
|
||||
[]string{"event +listen-im", "历史聊天", "oa", "dev app event"})
|
||||
|
||||
schemaMeta, ok := cli.ResolveMeta("event schema")
|
||||
if !ok {
|
||||
t.Fatal("event schema metadata is not registered")
|
||||
}
|
||||
assertSelectionContains(t, "event.schema use_when", strings.Join(schemaMeta.Selection.UseWhen, "\n"),
|
||||
[]string{"IM", "OA", "--flatten"})
|
||||
|
||||
for productID, want := range map[string]string{
|
||||
"chat": "event +listen-im",
|
||||
"oa": "event consume",
|
||||
} {
|
||||
decl, found := contract.LookupProductDecl(productID)
|
||||
if !found {
|
||||
t.Fatalf("%s ProductDecl is not registered", productID)
|
||||
}
|
||||
assertSelectionContains(t, productID+" avoid_when", strings.Join(decl.Selection.AvoidWhen, "\n"), []string{want})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSelectionContains(t *testing.T, label, text string, fragments []string) {
|
||||
t.Helper()
|
||||
for _, fragment := range fragments {
|
||||
if !strings.Contains(text, fragment) {
|
||||
t.Errorf("%s = %q, want fragment %q", label, text, fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFrameworkErrorProjectionPreservesRecoveryMetadata(t *testing.T) {
|
||||
next := time.Date(2026, 8, 10, 1, 2, 3, 0, time.FixedZone("test", 8*60*60))
|
||||
retry := int64(4)
|
||||
started := true
|
||||
leaf := &helpers.CLIError{Code: "UPSTREAM_CODE", Suggestion: "retry with id", Operation: "create"}
|
||||
call := &transport.CallError{Stage: transport.CallStage("decode"), HTTPStatus: 503, RPCCode: 91, TraceID: "call-trace", Cause: leaf}
|
||||
typed := &apperrors.Error{
|
||||
Category: apperrors.CategoryAPI, Message: "failed", Reason: "upstream_failed", Hint: "use status",
|
||||
Actions: []string{"dws status"}, Retryable: true, RetryableSet: true, RetryAfterSeconds: &retry,
|
||||
RPCCode: 92, RPCData: json.RawMessage(`{"task":"x"}`), Operation: "publish", ServerKey: "server",
|
||||
Origin: "gateway", FailureStage: "response", ExecutionStarted: &started, NextRetryAt: &next,
|
||||
AvailableFlags: []string{"--id"}, Snapshot: "/tmp/snapshot", Details: map[string]any{"id": "x"},
|
||||
ServerDiag: apperrors.ServerDiagnostics{TraceID: "typed-trace", ServerErrorCode: "SERVER_CODE", TechnicalDetail: "detail", FriendlyHint: "friendly", ActionURL: "https://example.test"},
|
||||
Cause: call,
|
||||
}
|
||||
info := errorInfoFromExecutionError(typed)
|
||||
if info.Type != "api" || info.Subtype != "upstream_failed" || info.HTTPStatus != 503 || info.RPCCode != 92 || info.RequestID != "call-trace" || info.TraceID != "typed-trace" {
|
||||
t.Fatalf("projection=%+v", info)
|
||||
}
|
||||
if info.UpstreamCode != "SERVER_CODE" || info.Operation != "publish" || info.NextRetryAt == "" || info.Cause == "" || info.RPCData == nil || info.ExecutionStarted == nil || !*info.ExecutionStarted {
|
||||
t.Fatalf("recovery metadata=%+v", info)
|
||||
}
|
||||
|
||||
innerOperation := &helpers.CLIError{Operation: "create"}
|
||||
outerWithoutOperation := &apperrors.Error{
|
||||
Category: apperrors.CategoryAPI,
|
||||
Message: "failed",
|
||||
Cause: innerOperation,
|
||||
}
|
||||
preserved := errorInfoFromExecutionError(outerWithoutOperation)
|
||||
if preserved.Operation != "create" {
|
||||
t.Fatalf("operation=%q, want inner operation preserved", preserved.Operation)
|
||||
}
|
||||
|
||||
requestCall := &transport.CallError{Stage: transport.CallStage("request"), HTTPStatus: 429, RequestID: "request-id"}
|
||||
requestInfo := errorInfoFromExecutionError(requestCall)
|
||||
if requestInfo.RequestID != "request-id" || requestInfo.HTTPStatus != 429 {
|
||||
t.Fatalf("request projection=%+v", requestInfo)
|
||||
}
|
||||
partial := errorInfoFromExecutionError(&apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"})
|
||||
if partial.Type != "internal" {
|
||||
t.Fatalf("partial error type=%s", partial.Type)
|
||||
}
|
||||
for code, want := range map[int]string{1: "api", 2: "auth", 3: "validation", 4: "permission", 6: "discovery", 99: "internal"} {
|
||||
if got := errorTypeForExitCode(code); got != want {
|
||||
t.Fatalf("errorTypeForExitCode(%d)=%q", code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkExecutePreparseUnifiedErrorAndEmissionFallback(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "unified", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "leaf"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("bad preparse") })
|
||||
var stdout bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.SetContext(ctx)
|
||||
leaf := &cobra.Command{Use: "leaf"}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
if failWriter {
|
||||
leaf.SetOut(frameworkFailWriter{})
|
||||
} else {
|
||||
leaf.SetOut(&stdout)
|
||||
}
|
||||
leaf.SetErr(&bytes.Buffer{})
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkPublicRootRequiresResultFromActiveCommand(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{Use: "active-no-result", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"active-no-result"})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
|
||||
t.Fatalf("ExecuteC error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkAbortOutputSinkRemoveFailure(t *testing.T) {
|
||||
originalRemove := rootRemoveFile
|
||||
t.Cleanup(func() { rootRemoveFile = originalRemove })
|
||||
file, err := os.CreateTemp(t.TempDir(), "abort-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootRemoveFile = func(string) error { return errors.New("remove failed") }
|
||||
cmd := &cobra.Command{Use: "abort"}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file, tempPath: file.Name()}))
|
||||
if err := abortOutputSink(cmd); err == nil || !strings.Contains(err.Error(), "remove temporary") {
|
||||
t.Fatalf("abort error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkOutputSinkHookWrappingAndCleanupEdges(t *testing.T) {
|
||||
installOutputSinkRunBoundary(nil)
|
||||
plain := &cobra.Command{Use: "plain"}
|
||||
plain.SetContext(context.Background())
|
||||
installOutputSinkRunBoundary(plain)
|
||||
|
||||
// newBoundaryChild builds a leaf whose --output lives on the root's
|
||||
// persistent flag set, matching production wiring (a local --output flag
|
||||
// belongs to the leaf's own business contract and skips the sink).
|
||||
newBoundaryChild := func(outputPath string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
root.PersistentFlags().String("output", outputPath, "")
|
||||
cmd := &cobra.Command{Use: "leaf"}
|
||||
root.AddCommand(cmd)
|
||||
cmd.SetContext(context.Background())
|
||||
return cmd
|
||||
}
|
||||
|
||||
var calls int
|
||||
cmd := newBoundaryChild("")
|
||||
cmd.RunE = func(*cobra.Command, []string) error { calls++; return nil }
|
||||
cmd.PostRunE = func(*cobra.Command, []string) error { calls++; return nil }
|
||||
installOutputSinkRunBoundary(cmd)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.PostRunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runOnly := newBoundaryChild("")
|
||||
runOnly.Run = func(*cobra.Command, []string) { calls++ }
|
||||
runOnly.PostRun = func(*cobra.Command, []string) { calls++ }
|
||||
installOutputSinkRunBoundary(runOnly)
|
||||
if runOnly.Run != nil || runOnly.RunE == nil {
|
||||
t.Fatal("Run-only leaf must be converted to RunE so sink setup errors surface")
|
||||
}
|
||||
if err := runOnly.RunE(runOnly, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runOnly.PostRun(runOnly, nil)
|
||||
if calls != 4 {
|
||||
t.Fatalf("hook calls=%d", calls)
|
||||
}
|
||||
|
||||
// A sink setup failure at Run entry returns before the business hook runs.
|
||||
testseam.Swap(t, &rootCreateTemp, func(string, string) (*os.File, error) { return nil, errors.New("create failed") })
|
||||
failCmd := newBoundaryChild(filepath.Join(t.TempDir(), "out.txt"))
|
||||
businessRan := false
|
||||
failCmd.RunE = func(*cobra.Command, []string) error { businessRan = true; return nil }
|
||||
installOutputSinkRunBoundary(failCmd)
|
||||
if err := failCmd.RunE(failCmd, nil); err == nil || !strings.Contains(err.Error(), "create failed") {
|
||||
t.Fatalf("Run entry sink setup error=%v", err)
|
||||
}
|
||||
if businessRan {
|
||||
t.Fatal("business hook ran after sink setup failure")
|
||||
}
|
||||
testseam.Swap(t, &rootCreateTemp, os.CreateTemp)
|
||||
|
||||
// A Run entry business error aborts the open sink: the temporary file is
|
||||
// removed and the final target is never created.
|
||||
abortTarget := filepath.Join(t.TempDir(), "result.txt")
|
||||
abortCmd := newBoundaryChild(abortTarget)
|
||||
abortCmd.RunE = func(*cobra.Command, []string) error { return errors.New("boom") }
|
||||
installOutputSinkRunBoundary(abortCmd)
|
||||
if err := abortCmd.RunE(abortCmd, nil); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("Run entry business error=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(abortTarget); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target exists after aborted run: %v", err)
|
||||
}
|
||||
assertNoOutputTemps(t, abortTarget)
|
||||
|
||||
// A second configureOutputSink call on an already-open sink (a reused
|
||||
// command tree stacks one Run wrapper per ExecuteC) must not replace the
|
||||
// live sink with a second temporary file.
|
||||
repeatTarget := filepath.Join(t.TempDir(), "result.txt")
|
||||
repeatCmd := newBoundaryChild(repeatTarget)
|
||||
if err := configureOutputSink(repeatCmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := outputSinkForCommand(repeatCmd)
|
||||
if first == nil {
|
||||
t.Fatal("first configureOutputSink did not open a sink")
|
||||
}
|
||||
if err := configureOutputSink(repeatCmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second := outputSinkForCommand(repeatCmd); second != first {
|
||||
t.Fatal("configureOutputSink replaced an open sink")
|
||||
}
|
||||
if err := abortOutputSink(repeatCmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoOutputTemps(t, repeatTarget)
|
||||
|
||||
file2, err := os.CreateTemp(t.TempDir(), "sink-error-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorCmd := &cobra.Command{Use: "error"}
|
||||
errorCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file2, tempPath: file2.Name(), target: "unused"}))
|
||||
if err := runWithOutputSinkErrorCleanup(errorCmd, func() error { return errors.New("boom") }); err == nil {
|
||||
t.Fatal("run error swallowed")
|
||||
}
|
||||
|
||||
file3, err := os.CreateTemp(t.TempDir(), "sink-panic-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panicCmd := &cobra.Command{Use: "panic"}
|
||||
panicCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file3, tempPath: file3.Name(), target: "unused"}))
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("panic swallowed")
|
||||
}
|
||||
}()
|
||||
_ = runWithOutputSinkErrorCleanup(panicCmd, func() error { panic("boom") })
|
||||
}()
|
||||
|
||||
if closeOutputSink(nil) != nil || abortOutputSink(nil) != nil || outputSinkForCommand(nil) != nil {
|
||||
t.Fatal("nil sink guards failed")
|
||||
}
|
||||
finished := &outputSinkState{finished: true, file: file3}
|
||||
finishedCmd := &cobra.Command{Use: "finished"}
|
||||
finishedCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, finished))
|
||||
if closeOutputSink(finishedCmd) != nil || abortOutputSink(finishedCmd) != nil {
|
||||
t.Fatal("finished sink was processed twice")
|
||||
}
|
||||
}
|
||||
|
||||
type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.SetContext(ctx)
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
if failWriter {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
} else {
|
||||
cmd.SetOut(&stdout)
|
||||
}
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
return cmd
|
||||
})
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) { panic("before emission") })
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T) {
|
||||
t.Run("preparse interrupted", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(cmd *cobra.Command, _ *pipeline.Engine) error {
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return errors.New("preparse failed")
|
||||
})
|
||||
if code := Execute(); code != 130 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil executed after emission attempt", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _ = output.EmitStoredResult(cmd)
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return nil, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure after emission", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure envelope writer also fails", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
state := &outputSinkState{file: file, original: frameworkFailWriter{}, finished: true}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
|
||||
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failure envelope cannot be written", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
cmd.SetOut(frameworkFailWriter{})
|
||||
return cmd, errors.New("business failed")
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("late output publication warning", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "late-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := &outputSinkState{file: file, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json")}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
|
||||
return cmd, nil
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
unified bool
|
||||
original io.Writer
|
||||
wantOutput bool
|
||||
}{
|
||||
{name: "unified late publication failure", unified: true, original: &bytes.Buffer{}, wantOutput: true},
|
||||
{name: "legacy late publication failure", original: io.Discard},
|
||||
{name: "late publication failure writer fails", unified: true, original: frameworkFailWriter{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, tc.unified, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
var original io.Writer = tc.original
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "panic-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetOut(file)
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, original: original, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
|
||||
}))
|
||||
panic("after sink open")
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
if tc.wantOutput && !strings.Contains(tc.original.(*bytes.Buffer).String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%q", tc.original.(*bytes.Buffer).String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("abort failure is diagnostic", func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "abort-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, original: io.Discard, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
|
||||
}))
|
||||
return cmd, errors.New("business failed")
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication helper requires observable finished transaction", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "unified"}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
file, err := os.CreateTemp(t.TempDir(), "unfinished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, finished: true,
|
||||
}))
|
||||
if _, handled, emitErr := emitOutputPublicationFailure(cmd, newOutputPublicationError("publish", errors.New("rename failed"))); handled || emitErr != nil {
|
||||
t.Fatalf("handled=%v err=%v", handled, emitErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type frameworkPanicWriter struct{}
|
||||
|
||||
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
|
||||
|
||||
func TestCrossPlatformCoverageFrameworkRootHookErrors(t *testing.T) {
|
||||
t.Run("flag group validation", func(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{Use: "exclusive", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
leaf.Flags().Bool("left", false, "")
|
||||
leaf.Flags().Bool("right", false, "")
|
||||
leaf.MarkFlagsMutuallyExclusive("left", "right")
|
||||
root.AddCommand(leaf)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"exclusive", "--left", "--right"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("expected mutually-exclusive flag error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edition pre-run error", func(t *testing.T) {
|
||||
old := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(old) })
|
||||
edition.Override(&edition.Hooks{AfterPersistentPreRun: func(*cobra.Command, []string) error {
|
||||
return errors.New("edition hook failed")
|
||||
}})
|
||||
root := NewRootCommand(context.Background())
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "edition hook failed") {
|
||||
t.Fatalf("Execute error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post-run emission panic", func(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
cmd := &cobra.Command{Use: "panic-output"}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(frameworkPanicWriter{})
|
||||
if err := output.StoreResult(ctx, output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected post-run panic")
|
||||
}
|
||||
}()
|
||||
_ = root.PersistentPostRunE(cmd, nil)
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// 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
|
||||
// dws-shared/SKILL.md, so removing it from the embedded skill source must
|
||||
// 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)
|
||||
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
|
||||
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
|
||||
}
|
||||
const rule = "禁止选择第一项、最近登录或最近使用账号"
|
||||
if !strings.Contains(string(data), rule) {
|
||||
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", 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 {
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
copyArgs[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
|
||||
text := paramAliasResponseForTool(tool)
|
||||
text := c.paramAliasResponseForTool(tool)
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
// 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 paramAliasResponseForTool(tool string) string {
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
@@ -58,6 +58,27 @@ func paramAliasResponseForTool(tool string) string {
|
||||
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 `{}`
|
||||
}
|
||||
@@ -165,6 +186,24 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
|
||||
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
|
||||
@@ -191,7 +230,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
|
||||
return ctx, root.Execute()
|
||||
}
|
||||
|
||||
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
confirmation []string
|
||||
@@ -244,7 +283,7 @@ func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
start := "2026-03-10T14:00:00+08:00"
|
||||
end := "2026-03-10T18:00:00+08:00"
|
||||
@@ -279,7 +318,7 @@ func TestParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
@@ -311,7 +350,7 @@ func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command []string
|
||||
@@ -390,7 +429,7 @@ func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
engine := newPipelineEngine()
|
||||
entries, err := cli.ReduceParamAliases(root)
|
||||
@@ -478,7 +517,7 @@ func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
|
||||
func TestIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
want string
|
||||
@@ -531,7 +570,7 @@ func TestIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
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)
|
||||
@@ -556,7 +595,7 @@ func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
@@ -653,7 +692,7 @@ func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
for _, args := range [][]string{
|
||||
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
|
||||
@@ -678,7 +717,7 @@ func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -770,7 +809,7 @@ func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
emitted string
|
||||
@@ -817,7 +856,7 @@ func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
|
||||
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 {
|
||||
|
||||
@@ -44,7 +44,7 @@ import (
|
||||
// - 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 TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -113,13 +113,19 @@ func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
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) < 2 || flagArgs[1] != fixtureValue {
|
||||
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
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 := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
|
||||
gotBare, inlineValue, hasInlineValue := strings.Cut(strings.TrimPrefix(got, "--"), "=")
|
||||
switch {
|
||||
case got == "--"+c.Expect:
|
||||
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)
|
||||
|
||||
@@ -30,13 +30,34 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"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"},
|
||||
@@ -81,9 +102,36 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"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"},
|
||||
@@ -101,6 +149,13 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
// 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"},
|
||||
},
|
||||
@@ -111,6 +166,9 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"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
|
||||
@@ -149,6 +207,38 @@ var paramAliasNewIMCases = []struct {
|
||||
{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
|
||||
@@ -163,14 +253,33 @@ var paramAliasNewIMCases = []struct {
|
||||
// That duplicated command construction was enough to push the pre-existing
|
||||
// macOS app suite beyond its package-level 10-minute timeout.
|
||||
var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("aitable +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("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
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) {
|
||||
@@ -273,8 +382,9 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
|
||||
_, 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)
|
||||
@@ -290,13 +400,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
activeAliases++
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
|
||||
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)
|
||||
@@ -308,6 +421,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -79,13 +79,15 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
|
||||
t.Fatalf("timed out authorization = %v, %v", ok, err)
|
||||
}
|
||||
patAuthorizationTimeout = 5 * time.Millisecond
|
||||
patAuthorizationTimeout = time.Second
|
||||
patAuthorizationPollInterval = time.Millisecond
|
||||
pollCtx, pollCancel := context.WithCancel(context.Background())
|
||||
patResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||
pollCancel()
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
out.Reset()
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
|
||||
if ok, err := WaitForPatAuthorization(pollCtx, "", &out); ok || !errors.Is(err, context.Canceled) || !strings.Contains(out.String(), "等待授权中") {
|
||||
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -55,7 +55,7 @@ func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
args := []string{
|
||||
"chat", "message", "send",
|
||||
@@ -98,3 +98,65 @@ func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
t.Fatalf("--debug details missing from early error:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTree(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantReason string
|
||||
wantCommand string
|
||||
}{
|
||||
{
|
||||
name: "unknown shortcut",
|
||||
args: []string{"chat", "+chat-mesages", "--keyword", "x", "--format", "json"},
|
||||
wantReason: "unknown_shortcut",
|
||||
wantCommand: "dws chat",
|
||||
},
|
||||
{
|
||||
name: "unknown dev app subcommand",
|
||||
args: []string{"dev", "app", "search", "--keyword", "x", "--format", "json"},
|
||||
wantReason: "unknown_subcommand",
|
||||
wantCommand: "dws dev app",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if ctx == nil || ctx.Command != test.wantCommand {
|
||||
t.Fatalf("Context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
err = newPreParseValidationError(err)
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
|
||||
t.Fatalf("structured error = %#v", structured)
|
||||
}
|
||||
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
|
||||
t.Fatalf("command error leaked flag classification: %#v", structured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvedShortcutStillUsesExactLeafFlagError(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+chat-messages", "--keyword", "x", "--format", "json"}
|
||||
if ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args); err != nil {
|
||||
t.Fatalf("valid shortcut path failed PreParse: %#v, %v", ctx, err)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != "unknown_flag" || !strings.Contains(structured.Message, "dws chat +chat-messages --help") {
|
||||
t.Fatalf("resolved shortcut flag error = %#v", structured)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,496 +1,118 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
recoverySavePlan = (*recovery.Store).SavePlan
|
||||
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
|
||||
)
|
||||
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
|
||||
|
||||
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
planUseLast bool
|
||||
planEventID string
|
||||
executeUseLast bool
|
||||
executeEventID string
|
||||
finalEventID string
|
||||
finalOutcome string
|
||||
executionFile string
|
||||
)
|
||||
|
||||
runtime := newRecoveryRuntime(flags)
|
||||
type recoveryCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// newRecoveryCommand keeps a visible Deprecated compatibility surface for
|
||||
// historical argv and Interface Integrity. Behavior is unchanged: every leaf
|
||||
// returns an explicit unsupported notice. Skills must not teach this path.
|
||||
func newRecoveryCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "recovery",
|
||||
Short: "错误恢复辅助命令",
|
||||
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
|
||||
Short: "不再支持:错误恢复辅助命令(兼容入口)",
|
||||
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
return printRecoveryUnsupported(cmd, "dws recovery")
|
||||
},
|
||||
}
|
||||
|
||||
planCmd := &cobra.Command{
|
||||
Use: "plan",
|
||||
Short: "基于失败快照生成恢复计划",
|
||||
Short: "不再支持:基于失败快照生成恢复计划",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planner := recovery.NewPlanner(runtime)
|
||||
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
|
||||
EventID: last.EventID,
|
||||
EnableDocSearch: true,
|
||||
})
|
||||
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
|
||||
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
|
||||
return fmt.Errorf("保存恢复计划失败: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event_id": last.EventID,
|
||||
"context": last.Context,
|
||||
"plan": plan,
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery plan")
|
||||
},
|
||||
}
|
||||
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
|
||||
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
|
||||
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
executeCmd := &cobra.Command{
|
||||
Use: "execute",
|
||||
Short: "生成面向 Agent 的恢复分析包",
|
||||
Short: "不再支持:生成面向 Agent 的恢复分析包",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planner := recovery.NewPlanner(runtime)
|
||||
executor := recovery.NewExecutor(planner, runtime)
|
||||
bundle := executor.Execute(cmd.Context(), *last)
|
||||
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
|
||||
return fmt.Errorf("保存恢复分析失败: %w", err)
|
||||
}
|
||||
|
||||
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery execute")
|
||||
},
|
||||
}
|
||||
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
|
||||
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
|
||||
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
finalizeCmd := &cobra.Command{
|
||||
Use: "finalize",
|
||||
Short: "回写恢复闭环结果",
|
||||
Short: "不再支持:回写恢复闭环结果",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(finalEventID) == "" {
|
||||
return fmt.Errorf("必须提供 --event-id")
|
||||
}
|
||||
if strings.TrimSpace(finalOutcome) == "" {
|
||||
return fmt.Errorf("必须提供 --outcome")
|
||||
}
|
||||
switch finalOutcome {
|
||||
case "recovered", "failed", "handoff":
|
||||
default:
|
||||
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
|
||||
}
|
||||
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
var execution *recovery.RecoveryExecution
|
||||
if strings.TrimSpace(executionFile) != "" {
|
||||
loaded, err := loadRecoveryExecution(executionFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
execution = &loaded
|
||||
}
|
||||
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
|
||||
return fmt.Errorf("回写恢复结果失败: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event_id": finalEventID,
|
||||
"outcome": finalOutcome,
|
||||
"success": true,
|
||||
}
|
||||
if execution != nil {
|
||||
payload["execution_recorded"] = true
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery finalize")
|
||||
},
|
||||
}
|
||||
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
|
||||
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
|
||||
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
|
||||
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
|
||||
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
|
||||
if useLast && strings.TrimSpace(eventID) != "" {
|
||||
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
|
||||
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
|
||||
notice := recoveryCompatNotice{
|
||||
Status: "unsupported",
|
||||
Command: command,
|
||||
Message: recoveryUnsupportedMessage,
|
||||
}
|
||||
switch {
|
||||
case useLast:
|
||||
last, err := store.LoadLastError()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "", "json":
|
||||
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
|
||||
return err
|
||||
}
|
||||
return last, nil
|
||||
case strings.TrimSpace(eventID) != "":
|
||||
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
case "pretty":
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
return last, nil
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
default:
|
||||
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
|
||||
}
|
||||
}
|
||||
|
||||
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
|
||||
var execution recovery.RecoveryExecution
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
|
||||
}
|
||||
var payload recoveryExecutionPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
|
||||
}
|
||||
execution.Actions = append([]string(nil), payload.Actions...)
|
||||
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
|
||||
execution.Actions = []string{strings.TrimSpace(payload.Action)}
|
||||
}
|
||||
execution.Result = strings.TrimSpace(payload.Result)
|
||||
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
|
||||
if execution.ErrorSummary == "" {
|
||||
execution.ErrorSummary = strings.TrimSpace(payload.Error)
|
||||
}
|
||||
|
||||
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
|
||||
if err != nil {
|
||||
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
|
||||
}
|
||||
if len(attempts) == 0 && payload.Attempt > 0 {
|
||||
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
|
||||
}
|
||||
execution.Attempts = attempts
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
type recoveryExecutionPayload struct {
|
||||
Action string `json:"action,omitempty"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
Attempts json.RawMessage `json:"attempts,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorSummary string `json:"error_summary,omitempty"`
|
||||
}
|
||||
|
||||
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var attempts []recovery.RecoveryAttempt
|
||||
if err := json.Unmarshal(raw, &attempts); err != nil {
|
||||
return nil, err
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
return attempts, nil
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := json.Unmarshal(raw, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
|
||||
}
|
||||
|
||||
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
summary := strings.TrimSpace(strings.Join(actions, ", "))
|
||||
if summary == "" {
|
||||
summary = "legacy execution attempt"
|
||||
}
|
||||
attempts := make([]recovery.RecoveryAttempt, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
attempts = append(attempts, recovery.RecoveryAttempt{
|
||||
CommandSummary: summary,
|
||||
Result: result,
|
||||
ErrorSummary: errorSummary,
|
||||
Source: "legacy_execution_file",
|
||||
})
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
type recoveryRuntime struct {
|
||||
transport *transport.Client
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
|
||||
var httpClient *http.Client
|
||||
if flags != nil && flags.Timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
}
|
||||
client := transport.NewClient(httpClient)
|
||||
client.ExtraHeaders = resolveIdentityHeaders()
|
||||
return &recoveryRuntime{
|
||||
transport: client,
|
||||
flags: flags,
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
|
||||
const (
|
||||
searchPage = 1
|
||||
searchSize = 5
|
||||
)
|
||||
requestArgs := map[string]any{
|
||||
"keyword": query,
|
||||
"page": searchPage,
|
||||
"size": searchSize,
|
||||
}
|
||||
|
||||
retrieval := recovery.KnowledgeRetrieval{
|
||||
DocSearch: recovery.DocSearch{
|
||||
Provider: "open_platform_docs",
|
||||
Query: query,
|
||||
Page: searchPage,
|
||||
Size: searchSize,
|
||||
Status: "empty",
|
||||
Request: &recovery.ToolCallRecord{
|
||||
ServerID: "devdoc",
|
||||
ToolName: "search_open_platform_docs_rag",
|
||||
Arguments: cloneRecoveryArgs(requestArgs),
|
||||
},
|
||||
},
|
||||
}
|
||||
if r == nil || strings.TrimSpace(query) == "" {
|
||||
retrieval.DocSearch.Status = "skipped"
|
||||
return retrieval, nil
|
||||
}
|
||||
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
|
||||
if result != nil {
|
||||
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
|
||||
}
|
||||
if err != nil {
|
||||
retrieval.DocSearch.Status = "error"
|
||||
retrieval.DocSearch.Error = err.Error()
|
||||
return retrieval, err
|
||||
}
|
||||
|
||||
retrieval.DocSearch.Items = parseDocSearchItems(result)
|
||||
if len(retrieval.DocSearch.Items) > 0 {
|
||||
retrieval.DocSearch.Status = "success"
|
||||
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
|
||||
}
|
||||
return retrieval, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
|
||||
if r == nil || r.transport == nil {
|
||||
return nil, fmt.Errorf("recovery runtime not initialized")
|
||||
}
|
||||
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
|
||||
if err != nil {
|
||||
return nil, tokenResolutionError(err)
|
||||
}
|
||||
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
result, err := tc.CallTool(ctx, endpoint, toolName, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.IsError {
|
||||
return &result, apperrors.NewAPI(
|
||||
extractMCPErrorMessage(result),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(serverID),
|
||||
)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
|
||||
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
|
||||
}
|
||||
|
||||
func recoveryRuntimeToken(flags *GlobalFlags) string {
|
||||
if flags == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(flags.Token)
|
||||
}
|
||||
|
||||
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
response := &recovery.ToolResponse{IsError: result.IsError}
|
||||
if len(result.Blocks) > 0 {
|
||||
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
|
||||
for _, block := range result.Blocks {
|
||||
response.Content = append(response.Content, recovery.ToolResponseBlock{
|
||||
Type: block.Type,
|
||||
Text: block.Text,
|
||||
})
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
for _, block := range result.Blocks {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
|
||||
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
if data, ok := payload["data"].(map[string]any); ok {
|
||||
if items := toDocSearchItems(data["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
if result, ok := payload["result"].(map[string]any); ok {
|
||||
if items := toDocSearchItems(result["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toDocSearchItems(raw any) []recovery.DocSearchItem {
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items := make([]recovery.DocSearchItem, 0, len(list))
|
||||
for _, entry := range list {
|
||||
object, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := recovery.DocSearchItem{}
|
||||
if title, ok := object["title"].(string); ok {
|
||||
item.Title = title
|
||||
}
|
||||
if url, ok := object["url"].(string); ok {
|
||||
item.URL = url
|
||||
}
|
||||
if desc, ok := object["desc"].(string); ok {
|
||||
item.Desc = desc
|
||||
}
|
||||
if item.Title != "" || item.URL != "" || item.Desc != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
|
||||
type scoredHit struct {
|
||||
hit recovery.KBHit
|
||||
score float64
|
||||
}
|
||||
scored := make([]scoredHit, 0, len(items))
|
||||
for _, item := range items {
|
||||
text := strings.ToLower(strings.Join(append([]string{
|
||||
item.Title,
|
||||
item.URL,
|
||||
item.Desc,
|
||||
rc.ToolName,
|
||||
}, rc.CommandPath...), " "))
|
||||
score := 0.0
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
score += 1
|
||||
}
|
||||
}
|
||||
scored = append(scored, scoredHit{
|
||||
hit: recovery.KBHit{
|
||||
Source: "open_platform_docs",
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: item.Desc,
|
||||
Score: score,
|
||||
},
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
return scored[i].score > scored[j].score
|
||||
})
|
||||
limit := len(scored)
|
||||
if limit > 3 {
|
||||
limit = 3
|
||||
}
|
||||
hits := make([]recovery.KBHit, 0, limit)
|
||||
for _, item := range scored[:limit] {
|
||||
hits = append(hits, item.hit)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryDeprecatedUnsupportedShim(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
group := mustFindCommand(t, root, "recovery")
|
||||
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
|
||||
t.Fatalf("recovery group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
|
||||
}
|
||||
|
||||
for _, leaf := range []string{"plan", "execute", "finalize"} {
|
||||
cmd := mustFindCommand(t, root, "recovery", leaf)
|
||||
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
|
||||
t.Fatalf("recovery %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
|
||||
}
|
||||
wantFlags := []string{"event-id"}
|
||||
switch leaf {
|
||||
case "plan", "execute":
|
||||
wantFlags = append(wantFlags, "last")
|
||||
case "finalize":
|
||||
wantFlags = append(wantFlags, "outcome", "execution-file")
|
||||
}
|
||||
for _, flag := range wantFlags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("recovery %s missing --%s", leaf, flag)
|
||||
}
|
||||
}
|
||||
for _, child := range newRecoveryCommand().Commands() {
|
||||
if child.Name() != leaf {
|
||||
continue
|
||||
}
|
||||
if err := child.RunE(child, nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
|
||||
t.Fatalf("recovery %s RunE = %v, want 不再支持", leaf, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"", "json", "pretty", "table"} {
|
||||
var out bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.PersistentFlags().String("format", format, "")
|
||||
cmd.SetOut(&out)
|
||||
sub := &cobra.Command{Use: "recovery"}
|
||||
cmd.AddCommand(sub)
|
||||
err := printRecoveryUnsupported(sub, "dws recovery plan")
|
||||
if err == nil {
|
||||
t.Fatalf("format=%q returned nil error", format)
|
||||
}
|
||||
typed, ok := err.(*apperrors.Error)
|
||||
if !ok || typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("format=%q error = %T/%v, want validation Error", format, err, err)
|
||||
}
|
||||
got := out.String() + err.Error()
|
||||
if !strings.Contains(got, "不再支持") {
|
||||
t.Fatalf("format=%q missing 不再支持:\n%s", format, got)
|
||||
}
|
||||
if format == "" || format == "json" || format == "pretty" {
|
||||
if !strings.Contains(got, `"status":"unsupported"`) && !strings.Contains(got, `"status": "unsupported"`) {
|
||||
t.Fatalf("format=%q missing unsupported JSON status:\n%s", format, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.PersistentFlags().String("format", format, "")
|
||||
cmd.SetOut(failWriter{})
|
||||
sub := &cobra.Command{Use: "recovery"}
|
||||
cmd.AddCommand(sub)
|
||||
if err := printRecoveryUnsupported(sub, "dws recovery plan"); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := newRecoveryCommand().RunE(newRecoveryCommand(), nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
|
||||
t.Fatalf("recovery parent RunE = %v, want 不再支持", err)
|
||||
}
|
||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||
}
|
||||
|
||||
type failWriter struct{}
|
||||
|
||||
func (failWriter) Write([]byte) (int, error) {
|
||||
return 0, errWriteFailed
|
||||
}
|
||||
|
||||
var errWriteFailed = errors.New("write failed")
|
||||
@@ -1,151 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
|
||||
cmd := newRecoveryCommand(&GlobalFlags{})
|
||||
out := &strings.Builder{}
|
||||
cmd.SetOut(out)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(cmdArgs)
|
||||
err := cmd.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
|
||||
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
|
||||
t.Cleanup(func() {
|
||||
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
|
||||
})
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
store := recovery.NewStore(configDir)
|
||||
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("injected plan save failure succeeded")
|
||||
}
|
||||
recoverySavePlan = oldSavePlan
|
||||
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
|
||||
return errors.New("save analysis")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("injected analysis save failure succeeded")
|
||||
}
|
||||
recoverySaveAnalysis = oldSaveAnalysis
|
||||
|
||||
parent := newRecoveryCommand(nil)
|
||||
parent.SetOut(io.Discard)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
|
||||
t.Fatalf("recovery plan = %q, %v", out, err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
|
||||
t.Fatalf("recovery execute = %q, %v", out, err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"finalize"},
|
||||
{"finalize", "--event-id", last.EventID},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
|
||||
} {
|
||||
if _, err := recoveryCoverageRun(args...); err == nil {
|
||||
t.Fatalf("recovery finalize %#v should fail", args)
|
||||
}
|
||||
}
|
||||
executionPath := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
|
||||
t.Fatalf("recovery finalize = %q, %v", out, err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
|
||||
t.Fatal("conflicting snapshot selectors should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
|
||||
t.Fatal("missing event snapshot should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
|
||||
t.Fatal("empty snapshot selector should fail")
|
||||
}
|
||||
missingStore := recovery.NewStore(t.TempDir())
|
||||
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
|
||||
t.Fatal("missing latest snapshot should fail")
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
|
||||
if err := os.Remove(eventsPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(eventsPath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("recovery plan save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("recovery analysis save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
|
||||
t.Fatal("recovery finalization save should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
|
||||
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("invalid attempts should fail")
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
|
||||
t.Fatal("invalid attempt array should fail")
|
||||
}
|
||||
|
||||
SetDynamicServers(nil)
|
||||
runtime := &recoveryRuntime{
|
||||
transport: transport.NewClient(nil),
|
||||
flags: &GlobalFlags{Token: "token"},
|
||||
}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("direct resolution error = %v", err)
|
||||
}
|
||||
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
|
||||
t.Fatalf("search error = %#v, %v", got, err)
|
||||
}
|
||||
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
|
||||
t.Fatalf("empty doc search items = %#v", got)
|
||||
}
|
||||
for _, payload := range []map[string]any{
|
||||
{"data": map[string]any{}},
|
||||
{"result": map[string]any{}},
|
||||
} {
|
||||
if got := parseDocSearchItemsFromMap(payload); got != nil {
|
||||
t.Fatalf("empty nested doc search items = %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
)
|
||||
|
||||
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
|
||||
if rawErr == nil && wrappedErr == nil {
|
||||
return
|
||||
}
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
if store == nil || !store.Enabled() {
|
||||
return
|
||||
}
|
||||
input := recovery.CaptureInput{
|
||||
CommandPath: runtimeCommandPath(invocation),
|
||||
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
|
||||
ToolName: strings.TrimSpace(invocation.Tool),
|
||||
Args: cloneRecoveryArgs(invocation.Params),
|
||||
Argv: append([]string(nil), os.Args[1:]...),
|
||||
RawErr: rawErr,
|
||||
WrappedErr: wrappedErr,
|
||||
}
|
||||
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
|
||||
}
|
||||
|
||||
func runtimeCommandPath(invocation executor.Invocation) []string {
|
||||
if path := currentCommandPath(); len(path) > 0 {
|
||||
return path
|
||||
}
|
||||
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
|
||||
return legacy
|
||||
}
|
||||
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
|
||||
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
|
||||
return []string{product, tool}
|
||||
}
|
||||
return []string{product}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentCommandPath() []string {
|
||||
boolFlags := map[string]struct{}{
|
||||
"--verbose": {},
|
||||
"-v": {},
|
||||
"--debug": {},
|
||||
"--mock": {},
|
||||
"--dry-run": {},
|
||||
"--yes": {},
|
||||
"-y": {},
|
||||
"--help": {},
|
||||
"-h": {},
|
||||
"--json": {},
|
||||
}
|
||||
path := make([]string, 0, len(os.Args))
|
||||
skipNext := false
|
||||
for _, arg := range os.Args[1:] {
|
||||
if skipNext {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
if strings.Contains(arg, "=") {
|
||||
continue
|
||||
}
|
||||
if _, ok := boolFlags[arg]; ok {
|
||||
continue
|
||||
}
|
||||
skipNext = true
|
||||
continue
|
||||
}
|
||||
path = append(path, arg)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func cloneRecoveryArgs(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
// captureRuntimeFailure previously persisted a recovery snapshot for
|
||||
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
|
||||
// failure paths stay stable while the visible Deprecated shim remains.
|
||||
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
|
||||
|
||||
+521
-38
@@ -15,32 +15,32 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"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"
|
||||
"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/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"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/shortcut/usage"
|
||||
"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/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -51,20 +51,22 @@ import (
|
||||
|
||||
type outputFileContextKey struct{}
|
||||
|
||||
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||||
|
||||
var (
|
||||
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
|
||||
rootExecuteCommand = (*cobra.Command).ExecuteC
|
||||
rootNewRootCommandWithEngine = NewRootCommandWithEngine
|
||||
rootRunPreParse = pipeline.RunPreParse
|
||||
rootLatestRecoveryCapture = recovery.LatestCapture
|
||||
rootResetRecoveryState = recovery.ResetRuntimeState
|
||||
rootStopAllStdioClients = StopAllStdioClients
|
||||
rootLoadPlugins = loadPlugins
|
||||
rootMkdirAll = os.MkdirAll
|
||||
rootCreateFile = os.Create
|
||||
rootCreateTemp = os.CreateTemp
|
||||
rootSyncFile = (*os.File).Sync
|
||||
rootCloseFile = (*os.File).Close
|
||||
// os.Rename replaces an existing non-directory target on every supported
|
||||
// Go host; the Windows implementation uses MOVEFILE_REPLACE_EXISTING. Keep
|
||||
// the temporary file beside the target so publication stays on one volume.
|
||||
rootRenameFile = os.Rename
|
||||
rootRemoveFile = os.Remove
|
||||
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
|
||||
rootPluginLoadUser = (*plugin.Loader).LoadUser
|
||||
rootPluginLoadDev = (*plugin.Loader).LoadDev
|
||||
@@ -81,10 +83,53 @@ var (
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() (exitCode int) {
|
||||
var (
|
||||
root *cobra.Command
|
||||
executed *cobra.Command
|
||||
resultStore *output.ResultStore
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
target := executed
|
||||
if target == nil && root != nil {
|
||||
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||
target = found
|
||||
}
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
exitCode = code
|
||||
if target != nil {
|
||||
fmt.Fprintf(target.ErrOrStderr(), "Warning: command panicked after result emission attempt: %v\n", r)
|
||||
}
|
||||
} else if target != nil && output.UsesUnifiedResult(target) {
|
||||
info := &output.ErrorInfo{Type: "internal", ExitCode: 5, Message: fmt.Sprintf("internal panic: %v", r)}
|
||||
if code, err := output.EmitResult(target, output.Failure(info)); err == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
}
|
||||
if executed == nil {
|
||||
executed = target
|
||||
}
|
||||
}
|
||||
CloseFileLogger()
|
||||
if executed != nil {
|
||||
if err := closeOutputSink(executed); err != nil {
|
||||
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
fmt.Fprintf(os.Stderr, "Warning: close output sink: %v\n", err)
|
||||
if emitErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -100,16 +145,17 @@ func Execute() (exitCode int) {
|
||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||
}()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// Attach timing collector to context for use by child components
|
||||
ctx = WithTimingCollector(ctx, timing)
|
||||
ctx := WithTimingCollector(context.Background(), timing)
|
||||
ctx, resultStore = output.WithResultStore(ctx)
|
||||
var signalState *processSignalState
|
||||
var stopSignals func()
|
||||
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||
defer stopSignals()
|
||||
|
||||
initStart := time.Now()
|
||||
rootResetRecoveryState()
|
||||
engine := newPipelineEngine()
|
||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -117,33 +163,218 @@ func Execute() (exitCode int) {
|
||||
// and --limit100 → --limit 100.
|
||||
if err := rootRunPreParse(root, engine); err != nil {
|
||||
err = newPreParseValidationError(err)
|
||||
if interrupted, _ := signalState.outcome(); interrupted != nil {
|
||||
err = interrupted
|
||||
}
|
||||
if target, _, findErr := root.Find(os.Args[1:]); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(target, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
}
|
||||
}
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
|
||||
executed, err := rootExecuteCommand(root)
|
||||
var err error
|
||||
executed, err = rootExecuteCommand(root)
|
||||
// PersistentPostRunE normally commits or aborts the transactional output
|
||||
// sink. Finalize once more at the process boundary so custom execution
|
||||
// seams, embedding callers, or future hook changes cannot leave publication
|
||||
// errors to a defer that runs after the process exit code is fixed.
|
||||
if executed != nil {
|
||||
if err == nil {
|
||||
if closeErr := closeOutputSink(executed); closeErr != nil {
|
||||
err = closeErr
|
||||
}
|
||||
} else if abortErr := abortOutputSink(executed); abortErr != nil {
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: abort output sink after command failure: %v\n", abortErr)
|
||||
}
|
||||
}
|
||||
interrupted, primaryCompletedBeforeSignal := signalState.outcome()
|
||||
if interrupted != nil && !primaryCompletedBeforeSignal {
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if err != nil && stderrors.As(err, &publicationErr) {
|
||||
// The successful result was written only to a transaction that did
|
||||
// not publish. Let the error path replace it with one observable
|
||||
// failure envelope on the restored original stream.
|
||||
} else {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: process interrupted after result emission attempt: %v\n", interrupted)
|
||||
// Once publication starts, its stored exit code is authoritative. A
|
||||
// signal recorded just before or during publication must not turn a
|
||||
// successfully emitted result into a contradictory 130/143 process
|
||||
// status; likewise, a failed publication must retain its internal
|
||||
// error code instead of being relabelled as cancellation.
|
||||
return code
|
||||
}
|
||||
}
|
||||
var publicationErr *outputPublicationError
|
||||
if err == nil || !stderrors.As(err, &publicationErr) {
|
||||
err = interrupted
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if stderrors.As(err, &publicationErr) {
|
||||
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
|
||||
if emitErr == nil {
|
||||
return failureCode
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
return apperrors.ExitCode(publicationErr)
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||
return code
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
var raw apperrors.RawStderrError
|
||||
if output.UsesUnifiedResult(executed) && !stderrors.As(err, &raw) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(executed, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
}
|
||||
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
if isUnknownCommandError(err) {
|
||||
executed.SetOut(os.Stderr)
|
||||
_ = executed.Help()
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
|
||||
}
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||
return code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// errorInfoFromExecutionError projects the repository error model into the unified
|
||||
// failure body. Exit code and category are derived from the same error value,
|
||||
// preventing the wire and process status from drifting apart.
|
||||
func errorInfoFromExecutionError(err error) *output.ErrorInfo {
|
||||
exitCode := apperrors.ExitCode(err)
|
||||
info := &output.ErrorInfo{
|
||||
Type: errorTypeForExitCode(exitCode),
|
||||
ExitCode: exitCode,
|
||||
Message: err.Error(),
|
||||
}
|
||||
var interrupted *processInterruption
|
||||
if stderrors.As(err, &interrupted) && interrupted != nil {
|
||||
info.Type = "internal"
|
||||
info.Subtype = interrupted.Subtype()
|
||||
return info
|
||||
}
|
||||
if stderrors.Is(err, context.DeadlineExceeded) {
|
||||
info.Subtype = "deadline_exceeded"
|
||||
}
|
||||
var cliErr *helpers.CLIError
|
||||
if stderrors.As(err, &cliErr) && cliErr != nil {
|
||||
info.UpstreamCode = cliErr.Code
|
||||
info.Hint = cliErr.Suggestion
|
||||
info.Operation = cliErr.Operation
|
||||
}
|
||||
var callErr *transport.CallError
|
||||
if stderrors.As(err, &callErr) && callErr != nil {
|
||||
info.HTTPStatus = callErr.HTTPStatus
|
||||
info.RPCCode = callErr.RPCCode
|
||||
info.Stage = string(callErr.Stage)
|
||||
if callErr.RequestID != "" {
|
||||
info.RequestID = callErr.RequestID
|
||||
} else if callErr.TraceID != "" {
|
||||
info.RequestID = callErr.TraceID
|
||||
}
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed == nil {
|
||||
return info
|
||||
}
|
||||
if typed.Category == apperrors.CategoryPartial {
|
||||
// An error lacks the item-level data required by partial_failure.
|
||||
// Callers must use output.Partial; fail closed consistently otherwise.
|
||||
info.Type = string(apperrors.CategoryInternal)
|
||||
} else {
|
||||
info.Type = string(typed.Category)
|
||||
}
|
||||
info.Subtype = typed.Reason
|
||||
if typed.Hint != "" {
|
||||
info.Hint = typed.Hint
|
||||
}
|
||||
info.Actions = append([]string(nil), typed.Actions...)
|
||||
info.Retryable = typed.RetryableSet && typed.Retryable
|
||||
info.RetryAfterSeconds = typed.RetryAfterSeconds
|
||||
if typed.RPCCode != 0 {
|
||||
info.RPCCode = typed.RPCCode
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "" {
|
||||
info.TraceID = typed.ServerDiag.TraceID
|
||||
}
|
||||
if typed.Operation != "" {
|
||||
info.Operation = typed.Operation
|
||||
}
|
||||
info.ServerKey = typed.ServerKey
|
||||
info.Origin = typed.Origin
|
||||
if typed.FailureStage != "" {
|
||||
info.Stage = typed.FailureStage
|
||||
}
|
||||
info.ExecutionStarted = typed.ExecutionStarted
|
||||
if typed.NextRetryAt != nil {
|
||||
info.NextRetryAt = typed.NextRetryAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
info.AvailableFlags = append([]string(nil), typed.AvailableFlags...)
|
||||
info.SnapshotPath = typed.Snapshot
|
||||
info.Details = typed.Details
|
||||
if len(typed.RPCData) > 0 {
|
||||
var rpcData any
|
||||
if json.Unmarshal(typed.RPCData, &rpcData) == nil {
|
||||
info.RPCData = rpcData
|
||||
}
|
||||
}
|
||||
info.TechnicalDetail = typed.ServerDiag.TechnicalDetail
|
||||
info.FriendlyHint, info.ActionURL = apperrors.ServerGuidance(typed.ServerDiag)
|
||||
if typed.Cause != nil {
|
||||
info.Cause = typed.Cause.Error()
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "" {
|
||||
info.UpstreamCode = typed.ServerDiag.ServerErrorCode
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func errorTypeForExitCode(code int) string {
|
||||
switch code {
|
||||
case 1:
|
||||
return "api"
|
||||
case 2:
|
||||
return "auth"
|
||||
case 3:
|
||||
return "validation"
|
||||
case 4:
|
||||
return "permission"
|
||||
case 6:
|
||||
return "discovery"
|
||||
default:
|
||||
return "internal"
|
||||
}
|
||||
}
|
||||
|
||||
// newPreParseValidationError keeps pipeline handler identity in internal logs
|
||||
// while exposing only the underlying parameter-domain error to CLI users.
|
||||
func newPreParseValidationError(err error) error {
|
||||
if structured, ok := err.(*apperrors.Error); ok {
|
||||
return structured
|
||||
}
|
||||
userErr := err
|
||||
var handlerErr *pipeline.HandlerError
|
||||
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
|
||||
@@ -374,6 +605,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
}
|
||||
rootCtx, _ = output.WithResultStore(rootCtx)
|
||||
return newRootCommandWithEngine(rootCtx, nil, true, false)
|
||||
}
|
||||
|
||||
@@ -396,6 +628,7 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
// no pipeline processing is applied.
|
||||
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
registerSchemaRuntimeDelivery()
|
||||
rootCtx, _ = output.WithResultStore(rootCtx)
|
||||
return newRootCommandWithEngine(rootCtx, engine, true, false)
|
||||
}
|
||||
|
||||
@@ -420,6 +653,25 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// A public root may be reused by embedding callers through multiple
|
||||
// ExecuteC invocations. Begin each invocation with an empty result
|
||||
// lifecycle while retaining the store pointer observed by Execute's
|
||||
// signal and exit-code handling. Declaration-only command trees do not
|
||||
// install a store at construction time, so add one lazily when those
|
||||
// trees are executed for compatibility and policy tests.
|
||||
executionCtx, _ := output.WithResultStore(cmd.Context())
|
||||
cmd.SetContext(executionCtx)
|
||||
// WithResultStore above guarantees the reset precondition.
|
||||
_ = output.ResetResultStore(executionCtx)
|
||||
// Do not run Cobra's ValidateRequiredFlags/ValidateFlagGroups here:
|
||||
// Cobra executes them between the leaf's PreRunE and RunE, and leaves
|
||||
// rely on that order to normalize alias flags into required canonical
|
||||
// flags (for example chat message download-media copies --msg-id into
|
||||
// the required --message-id in PreRunE). Running them early fails the
|
||||
// alias path before the leaf can normalize it. The transactional
|
||||
// --output sink instead opens at Run entry (after Cobra's own
|
||||
// validation), so validation failures still cannot strand a
|
||||
// temporary file.
|
||||
// Validate caller-provided identity labels before any edition hook
|
||||
// or command network activity can run. Header-only library callers
|
||||
// use the best-effort path in resolveIdentityHeaders instead.
|
||||
@@ -429,6 +681,11 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
|
||||
return err
|
||||
}
|
||||
if shouldDetectNestedSkillLayout(cmd) {
|
||||
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
|
||||
}
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(flags.Profile)
|
||||
// Apply OAuth credential overrides from CLI flags (highest priority).
|
||||
@@ -442,19 +699,37 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
// Configure global slog level based on --debug / --verbose flags.
|
||||
configureLogLevel(flags)
|
||||
|
||||
if err := configureOutputSink(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
installOutputSinkRunBoundary(cmd)
|
||||
if fn := edition.Get().AfterPersistentPreRun; fn != nil {
|
||||
return fn(cmd, args)
|
||||
if err := fn(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
panic(r)
|
||||
}
|
||||
if err != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
}
|
||||
}()
|
||||
_, emitted, emitErr := output.EmitStoredResult(cmd)
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
CloseFileLogger()
|
||||
return closeOutputSink(cmd)
|
||||
if emitErr != nil {
|
||||
return apperrors.NewInternal("emit command result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
if output.UsesUnifiedResult(cmd) && !emitted {
|
||||
return apperrors.NewInternal("framework 2.0 command returned without a CommandResult")
|
||||
}
|
||||
if closeErr := closeOutputSink(cmd); closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -477,10 +752,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
newCatalogCommand(),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newEventCommand(),
|
||||
newRecoveryCommand(),
|
||||
newEventCommand(flags),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
@@ -724,8 +999,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
var builtinCommandNames = map[string]bool{
|
||||
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
|
||||
"doctor": true, "event": true, "completion": true, "skill": true,
|
||||
"plugin": true, "profile": true, "version": true, "help": true,
|
||||
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
|
||||
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
|
||||
"schema": true, "mcp": true, "upgrade": true,
|
||||
}
|
||||
|
||||
// commandNameSet returns a new set containing every name in base plus extras.
|
||||
@@ -854,6 +1129,54 @@ func deduplicateCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
type outputSinkState struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
original io.Writer
|
||||
tempPath string
|
||||
target string
|
||||
finished bool
|
||||
}
|
||||
|
||||
type outputPublicationError struct {
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *outputPublicationError) Error() string { return e.cause.Error() }
|
||||
func (e *outputPublicationError) Unwrap() error { return e.cause }
|
||||
func (e *outputPublicationError) ExitCode() int { return 5 }
|
||||
|
||||
func newOutputPublicationError(message string, cause error) error {
|
||||
return &outputPublicationError{cause: fmt.Errorf("%s: %w", message, cause)}
|
||||
}
|
||||
|
||||
// emitOutputPublicationFailure replaces a result that was rendered only into a
|
||||
// rolled-back transactional file with one observable failure envelope on the
|
||||
// original output stream. This is not a second public result: closeOutputSink
|
||||
// has removed the temporary file and restored cmd.OutOrStdout before returning
|
||||
// the publication error.
|
||||
func emitOutputPublicationFailure(cmd *cobra.Command, err error) (code int, handled bool, emitErr error) {
|
||||
var publicationErr *outputPublicationError
|
||||
if cmd == nil || !stderrors.As(err, &publicationErr) || !output.UsesUnifiedResult(cmd) {
|
||||
return 0, false, nil
|
||||
}
|
||||
state := outputSinkForCommand(cmd)
|
||||
if state == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
state.mu.Lock()
|
||||
original := state.original
|
||||
finished := state.finished
|
||||
state.mu.Unlock()
|
||||
if original == nil || !finished {
|
||||
return 0, false, nil
|
||||
}
|
||||
cmd.SetOut(original)
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(publicationErr), apperrors.ExitCode(publicationErr))
|
||||
code, emitErr = output.EmitResult(cmd, result)
|
||||
return code, true, emitErr
|
||||
}
|
||||
|
||||
func configureOutputSink(cmd *cobra.Command) error {
|
||||
if local := cmd.LocalFlags().Lookup("output"); local != nil {
|
||||
return nil
|
||||
@@ -866,32 +1189,180 @@ func configureOutputSink(cmd *cobra.Command) error {
|
||||
if outputPath == "" {
|
||||
return nil
|
||||
}
|
||||
// A public root may be reused across ExecuteC calls, accumulating one Run
|
||||
// wrapper per execution. When the sink for this invocation is already open,
|
||||
// an inner wrapper must not replace it with a second temporary file.
|
||||
if state := outputSinkForCommand(cmd); state != nil {
|
||||
state.mu.Lock()
|
||||
finished := state.finished
|
||||
state.mu.Unlock()
|
||||
if !finished {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := validateOptionalPath("--output", outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
|
||||
}
|
||||
file, err := rootCreateFile(outputPath)
|
||||
tempPattern := "." + filepath.Base(outputPath) + ".tmp-*"
|
||||
file, err := rootCreateTemp(filepath.Dir(outputPath), tempPattern)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create temporary output file: %v", err))
|
||||
}
|
||||
originalOut := cmd.OutOrStdout()
|
||||
cmd.SetOut(file)
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file))
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file,
|
||||
original: originalOut,
|
||||
tempPath: file.Name(),
|
||||
target: outputPath,
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
// installOutputSinkRunBoundary defers opening the transactional --output sink
|
||||
// to the executed command's Run entry. Cobra runs ValidateRequiredFlags and
|
||||
// ValidateFlagGroups after the leaf's PreRunE and immediately before RunE, so
|
||||
// opening the sink there keeps two invariants at once: leaf PreRunE hooks can
|
||||
// still normalize alias flags into required canonical flags, and a validation
|
||||
// failure can never strand a temporary output file. Run-only leaves are
|
||||
// converted to RunE so a sink setup failure remains a returned error. Post-run
|
||||
// hooks keep the error cleanup wrapping so a post-run failure still aborts the
|
||||
// transaction; pre-run hooks need no wrapping because the sink cannot exist
|
||||
// before Run entry.
|
||||
func installOutputSinkRunBoundary(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
openSinkAndRun := func(run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if err := configureOutputSink(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return runWithOutputSinkErrorCleanup(cmd, func() error { return run(cmd, args) })
|
||||
}
|
||||
}
|
||||
if cmd.RunE != nil {
|
||||
cmd.RunE = openSinkAndRun(cmd.RunE)
|
||||
} else if cmd.Run != nil {
|
||||
original := cmd.Run
|
||||
cmd.Run = nil
|
||||
cmd.RunE = openSinkAndRun(func(cmd *cobra.Command, args []string) error {
|
||||
original(cmd, args)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if cmd.PostRunE != nil {
|
||||
original := cmd.PostRunE
|
||||
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
|
||||
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
|
||||
}
|
||||
}
|
||||
if cmd.PostRun != nil {
|
||||
original := cmd.PostRun
|
||||
cmd.PostRun = func(cmd *cobra.Command, args []string) {
|
||||
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
|
||||
original(cmd, args)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runWithOutputSinkErrorCleanup(cmd *cobra.Command, run func() error) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
panic(r)
|
||||
}
|
||||
if err != nil {
|
||||
warnAbortOutputSink(cmd)
|
||||
}
|
||||
}()
|
||||
return run()
|
||||
}
|
||||
|
||||
func warnAbortOutputSink(cmd *cobra.Command) {
|
||||
if closeErr := abortOutputSink(cmd); closeErr != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: close output sink: %v\n", closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func closeOutputSink(cmd *cobra.Command) error {
|
||||
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File)
|
||||
if !ok || file == nil {
|
||||
state := outputSinkForCommand(cmd)
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
if err := rootCloseFile(file); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
// A reusable Cobra tree must never retain the transactional file as its
|
||||
// stdout after this execution. Restore the caller's writer on every terminal
|
||||
// path, including sync/close/rename failures and repeated cleanup calls.
|
||||
if state.original != nil {
|
||||
cmd.SetOut(state.original)
|
||||
}
|
||||
if state.finished {
|
||||
return nil
|
||||
}
|
||||
state.finished = true
|
||||
if err := rootSyncFile(state.file); err != nil {
|
||||
_ = rootCloseFile(state.file)
|
||||
_ = rootRemoveFile(state.tempPath)
|
||||
return newOutputPublicationError("failed to sync output file", err)
|
||||
}
|
||||
if err := rootCloseFile(state.file); err != nil {
|
||||
_ = rootRemoveFile(state.tempPath)
|
||||
return newOutputPublicationError("failed to close output file", err)
|
||||
}
|
||||
if err := rootRenameFile(state.tempPath, state.target); err != nil {
|
||||
_ = rootRemoveFile(state.tempPath)
|
||||
return newOutputPublicationError("failed to publish output file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func abortOutputSink(cmd *cobra.Command) error {
|
||||
state := outputSinkForCommand(cmd)
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.finished {
|
||||
return nil
|
||||
}
|
||||
state.finished = true
|
||||
// A business error still needs the root execution boundary to publish one
|
||||
// typed failure envelope. Restore the pre-transaction writer before closing
|
||||
// and unlinking the temporary file so that failure emission cannot target a
|
||||
// closed descriptor. The final --output target remains untouched.
|
||||
if state.original != nil {
|
||||
cmd.SetOut(state.original)
|
||||
}
|
||||
closeErr := rootCloseFile(state.file)
|
||||
removeErr := rootRemoveFile(state.tempPath)
|
||||
if closeErr != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", closeErr))
|
||||
}
|
||||
if removeErr != nil && !stderrors.Is(removeErr, os.ErrNotExist) {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to remove temporary output file: %v", removeErr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputSinkForCommand(cmd *cobra.Command) *outputSinkState {
|
||||
if cmd == nil || cmd.Context() == nil {
|
||||
return nil
|
||||
}
|
||||
state, _ := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState)
|
||||
if state == nil || state.file == nil {
|
||||
return nil
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func validateOptionalPath(flagName, path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
@@ -1333,6 +1804,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
// - PostResponse: after transport returns, before stdout (canonical RunE)
|
||||
func newPipelineEngine() *pipeline.Engine {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.SetCommandPathFallbackLookup(func(path string) (pipeline.CommandPathFallback, bool) {
|
||||
entry, ok := cli.LookupCommandPathFallback(path)
|
||||
if !ok {
|
||||
return pipeline.CommandPathFallback{}, false
|
||||
}
|
||||
return pipeline.CommandPathFallback{
|
||||
From: entry.From,
|
||||
Mode: string(entry.Mode),
|
||||
To: entry.To,
|
||||
Candidates: append([]string(nil), entry.Candidates...),
|
||||
}, true
|
||||
})
|
||||
engine.RegisterAll(
|
||||
// Register handler runs during command tree building.
|
||||
handlers.RegisterHandler{},
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -24,8 +23,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldLatest := rootLatestRecoveryCapture
|
||||
oldReset := rootResetRecoveryState
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
@@ -33,20 +30,16 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootLatestRecoveryCapture = oldLatest
|
||||
rootResetRecoveryState = oldReset
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootResetRecoveryState = func() {}
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
@@ -59,7 +52,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
@@ -165,11 +157,11 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
||||
})
|
||||
|
||||
oldMkdir := rootMkdirAll
|
||||
oldCreate := rootCreateFile
|
||||
oldCreate := rootCreateTemp
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() {
|
||||
rootMkdirAll = oldMkdir
|
||||
rootCreateFile = oldCreate
|
||||
rootCreateTemp = oldCreate
|
||||
rootCloseFile = oldClose
|
||||
})
|
||||
wantErr := errors.New("filesystem")
|
||||
@@ -201,17 +193,19 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
||||
t.Fatal("mkdir failure succeeded")
|
||||
}
|
||||
rootMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
|
||||
rootCreateTemp = func(string, string) (*os.File, error) { return nil, wantErr }
|
||||
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
|
||||
t.Fatal("create failure succeeded")
|
||||
}
|
||||
rootCreateFile = oldCreate
|
||||
rootCreateTemp = oldCreate
|
||||
file, err := os.CreateTemp(t.TempDir(), "close")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := &cobra.Command{Use: "close"}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-target"),
|
||||
}))
|
||||
rootCloseFile = func(*os.File) error { return wantErr }
|
||||
if err := closeOutputSink(cmd); err == nil {
|
||||
t.Fatal("close failure succeeded")
|
||||
@@ -224,7 +218,9 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
|
||||
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-success-target"),
|
||||
}))
|
||||
if err := closeOutputSink(cmd); err != nil {
|
||||
t.Fatalf("close success = %v", err)
|
||||
}
|
||||
|
||||
@@ -419,20 +419,14 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRefreshCompatibilityStub(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
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())
|
||||
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
cmd, _, err := root.Find([]string{"cache", "refresh"})
|
||||
if err != nil || cmd == nil || cmd == root {
|
||||
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if cmd.Hidden || cmd.Deprecated == "" {
|
||||
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPublicRootDirectExecuteResetsUnifiedResultLifecycle(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
run := 0
|
||||
leaf := &cobra.Command{
|
||||
Use: "lifecycle-repeat",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
run++
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
|
||||
for want := 1; want <= 2; want++ {
|
||||
stdout.Reset()
|
||||
root.SetArgs([]string{"lifecycle-repeat", "--format", "json"})
|
||||
executed, err := root.ExecuteC()
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteC run %d: %v", want, err)
|
||||
}
|
||||
if executed != leaf {
|
||||
t.Fatalf("ExecuteC run %d executed %v, want lifecycle leaf", want, executed)
|
||||
}
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Run int `json:"run"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("ExecuteC run %d output %q: %v", want, stdout.String(), err)
|
||||
}
|
||||
if !envelope.OK || envelope.Data.Run != want {
|
||||
t.Fatalf("ExecuteC run %d envelope=%+v", want, envelope)
|
||||
}
|
||||
}
|
||||
|
||||
missing := &cobra.Command{Use: "lifecycle-missing", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
output.SetCommandRollout(missing, output.RolloutUnifiedActive)
|
||||
root.AddCommand(missing)
|
||||
stdout.Reset()
|
||||
root.SetArgs([]string{"lifecycle-missing", "--format", "json"})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
|
||||
t.Fatalf("missing-result ExecuteC error=%v, want fresh lifecycle failure", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("missing-result ExecuteC replayed stale output %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootRestoresStdoutAfterSuccessfulOutputPublication(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
run := 0
|
||||
leaf := &cobra.Command{
|
||||
Use: "lifecycle-output-repeat",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
run++
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
|
||||
target := filepath.Join(t.TempDir(), "result.json")
|
||||
root.SetArgs([]string{"lifecycle-output-repeat", "--output", target, "--format", "json"})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("first ExecuteC: %v", err)
|
||||
}
|
||||
first, err := os.ReadFile(target)
|
||||
if err != nil || !bytes.Contains(first, []byte(`"run": 1`)) {
|
||||
t.Fatalf("published output=%q err=%v", first, err)
|
||||
}
|
||||
|
||||
if err := root.PersistentFlags().Set("output", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
root.SetArgs([]string{"lifecycle-output-repeat", "--format", "json"})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("second ExecuteC: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"run": 2`) {
|
||||
t.Fatalf("second stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootDirectExecuteFailsWhenUnifiedSinkCannotPublish(t *testing.T) {
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||
closeCalls := 0
|
||||
rootCloseFile = func(file *os.File) error {
|
||||
closeCalls++
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("late close diagnostic")
|
||||
}
|
||||
|
||||
root := NewRootCommand(context.Background())
|
||||
leaf := &cobra.Command{
|
||||
Use: "lifecycle-unified",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"lifecycle-unified", "--output", filepath.Join(t.TempDir(), "result.json")})
|
||||
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil || apperrors.ExitCode(err) != 5 {
|
||||
t.Fatalf("direct ExecuteC error=%v, want publication failure with exit 5", err)
|
||||
}
|
||||
if executed != leaf {
|
||||
t.Fatalf("executed=%v, want lifecycle leaf", executed)
|
||||
}
|
||||
if closeCalls != 1 {
|
||||
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootDirectExecutePreservesLegacyCloseError(t *testing.T) {
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||
rootCloseFile = func(file *os.File) error {
|
||||
_ = file.Close()
|
||||
return errors.New("legacy close failed")
|
||||
}
|
||||
|
||||
root := NewRootCommandWithEngine(context.Background(), nil)
|
||||
root.AddCommand(&cobra.Command{Use: "lifecycle-legacy", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.SetArgs([]string{"lifecycle-legacy", "--output", filepath.Join(t.TempDir(), "result.txt")})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "legacy close failed") {
|
||||
t.Fatalf("legacy direct ExecuteC error=%v, want close failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||
oldClose := rootCloseFile
|
||||
t.Cleanup(func() { rootCloseFile = oldClose })
|
||||
closeCalls := 0
|
||||
rootCloseFile = func(file *os.File) error {
|
||||
closeCalls++
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
root := NewRootCommand(context.Background())
|
||||
root.AddCommand(&cobra.Command{Use: "lifecycle-error", RunE: func(*cobra.Command, []string) error {
|
||||
return errors.New("handler failed")
|
||||
}})
|
||||
root.SetArgs([]string{"lifecycle-error", "--output", filepath.Join(t.TempDir(), "result.txt")})
|
||||
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "handler failed") {
|
||||
t.Fatalf("direct ExecuteC error=%v, want handler failure", err)
|
||||
}
|
||||
if closeCalls != 1 {
|
||||
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
var stdout, stderr bytes.Buffer
|
||||
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetContext(ctx)
|
||||
return cmd
|
||||
}
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
result := output.Failure(&output.ErrorInfo{Type: "validation", Message: "bad input"})
|
||||
if err := output.StoreResult(cmd.Context(), result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panic("after emission")
|
||||
}
|
||||
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute code=%d, want emitted validation code 3", code)
|
||||
}
|
||||
if got := strings.Count(stdout.String(), `"outcome": "failure"`); got != 1 {
|
||||
t.Fatalf("stdout contains %d envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "panicked after result emission attempt") {
|
||||
t.Fatalf("panic diagnostic missing: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorInfoProjectionKeepsTraceIDDistinctFromRequestID(t *testing.T) {
|
||||
err := apperrors.NewAPI("failed", apperrors.WithTraceID("trace-1"))
|
||||
info := errorInfoFromExecutionError(err)
|
||||
if info.TraceID != "trace-1" || info.RequestID != "" {
|
||||
t.Fatalf("projection trace_id=%q request_id=%q", info.TraceID, info.RequestID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestOutputSinkAtomicallyReplacesExistingTargetWithMode0600(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var tempMode os.FileMode
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
info, err := cmd.OutOrStdout().(*os.File).Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempMode = info.Mode().Perm()
|
||||
_, err = fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||
return err
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("ExecuteC: %v", err)
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "replacement", 0o600)
|
||||
if tempMode != 0o600 {
|
||||
t.Fatalf("temporary output mode=%#o, want 0600", tempMode)
|
||||
}
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkHandlerFailurePreservesTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
||||
return errors.New("handler failed")
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedRunEFailureWithOutputRestoresStdoutAndPreservesTarget(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "atomic-output-unified-failure", "--output", filepath.Join(t.TempDir(), "result.json"), "--format", "json"}
|
||||
target := os.Args[3]
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout, stderr bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
root := NewRootCommandWithEngine(ctx, engine)
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-output-unified-failure",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
return apperrors.NewValidation("business validation failed")
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("Execute exit code=%d, want validation code 3; stderr=%q", code, stderr.String())
|
||||
}
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Outcome string `json:"outcome"`
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
|
||||
}
|
||||
if envelope.OK || envelope.Outcome != "failure" || envelope.Error.Type != "validation" || envelope.Error.Message != "business validation failed" {
|
||||
t.Fatalf("failure envelope=%+v", envelope)
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkPanicCleansTempAndPreservesTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
||||
panic("boom")
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if recovered := executeAndRecover(root); recovered == nil {
|
||||
t.Fatal("ExecuteC did not panic")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkRenameFailurePreservesTarget(t *testing.T) {
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
||||
return errors.New("rename failed")
|
||||
})
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||
return err
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil || err.Error() == "" {
|
||||
t.Fatalf("ExecuteC error=%v, want publication failure", err)
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkSyncAndCloseFailuresPreserveTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seam func(*testing.T)
|
||||
}{
|
||||
{
|
||||
name: "sync",
|
||||
seam: func(t *testing.T) {
|
||||
testseam.Swap(t, &rootSyncFile, func(*os.File) error { return errors.New("sync failed") })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "close",
|
||||
seam: func(t *testing.T) {
|
||||
testseam.Swap(t, &rootCloseFile, func(file *os.File) error {
|
||||
_ = file.Close()
|
||||
return errors.New("close failed")
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.seam(t)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
||||
return err
|
||||
})
|
||||
root.SetArgs([]string{"atomic-output", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing.T) {
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
||||
return errors.New("rename failed")
|
||||
})
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-output-unified",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"atomic-output-unified", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("unified ExecuteC succeeded without publishing its output")
|
||||
} else if code := apperrors.ExitCode(err); code != 5 {
|
||||
t.Fatalf("publication exit code=%d, want 5: %v", code, err)
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("final output exists after publication failure: %v", err)
|
||||
}
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
var stdout, stderr bytes.Buffer
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
root := NewRootCommandWithEngine(ctx, engine)
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-output-unified-publication",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
return root
|
||||
})
|
||||
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute exit code=%d, want publication failure code 5; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
var envelope output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("publication failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
|
||||
}
|
||||
if envelope.OK || envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || envelope.Error.Type != "internal" || envelope.Error.ExitCode != 5 {
|
||||
t.Fatalf("publication failure envelope=%+v", envelope)
|
||||
}
|
||||
if !strings.Contains(envelope.Error.Message, "failed to publish output file") {
|
||||
t.Fatalf("publication failure message=%q", envelope.Error.Message)
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)); got != 1 {
|
||||
t.Fatalf("stdout contains %d failure envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
||||
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkEmissionFailurePreservesTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := &cobra.Command{
|
||||
Use: "atomic-emission-failure",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"})); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.OutOrStdout().(*os.File).Close()
|
||||
},
|
||||
}
|
||||
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"atomic-emission-failure", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded after emission failure")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestOutputSinkValidationFailureDoesNotCreateTemp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := &cobra.Command{Use: "atomic-validation", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
leaf.Flags().String("required", "", "")
|
||||
_ = leaf.MarkFlagRequired("required")
|
||||
root.AddCommand(leaf)
|
||||
root.SetArgs([]string{"atomic-validation", "--output", target})
|
||||
if _, err := root.ExecuteC(); err == nil {
|
||||
t.Fatal("ExecuteC succeeded without required flag")
|
||||
}
|
||||
|
||||
assertOutputFile(t, target, "original", 0o600)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func newAtomicOutputTestRoot(run func(*cobra.Command) error) *cobra.Command {
|
||||
root := NewRootCommand()
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "atomic-output",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return run(cmd)
|
||||
},
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
func executeAndRecover(cmd *cobra.Command) (recovered any) {
|
||||
defer func() { recovered = recover() }()
|
||||
_, _ = cmd.ExecuteC()
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertOutputFile(t *testing.T, path, want string, wantMode os.FileMode) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read output: %v", err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("output=%q, want %q", data, want)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat output: %v", err)
|
||||
}
|
||||
if mode := info.Mode().Perm(); mode != wantMode {
|
||||
t.Fatalf("output mode=%#o, want %#o", mode, wantMode)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoOutputTemps(t *testing.T, target string) {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary output files remain: %v", matches)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag is the root-level
|
||||
// regression for the alias normalization order: root's persistent pre-run must
|
||||
// not run Cobra's required-flag validation ahead of the leaf PreRunE.
|
||||
// chat message download-media copies --msg-id / --open-message-id into the
|
||||
// required --message-id flag in its PreRunE; validating early failed that
|
||||
// documented alias path with "missing required flag(s): --message-id".
|
||||
func TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag(t *testing.T) {
|
||||
for _, alias := range []string{"msg-id", "open-message-id"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
root := NewRootCommand(context.Background())
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
target := filepath.Join(t.TempDir(), "download.bin")
|
||||
root.SetArgs([]string{
|
||||
"chat", "message", "download-media",
|
||||
"--type", "mediaId",
|
||||
"--resource-id", "media-1",
|
||||
"--" + alias, "msg-1",
|
||||
"--open-conversation-id", "cid-1",
|
||||
"--output", target,
|
||||
"--dry-run", "--format", "json",
|
||||
})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("ExecuteC with alias --%s: %v\nstdout: %s\nstderr: %s", alias, err, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
cmd.SetContext(ctx)
|
||||
return cmd
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
executed := &cobra.Command{Use: "leaf"}
|
||||
output.SetCommandRollout(executed, output.RolloutUnifiedActive)
|
||||
executed.SetOut(&stdout)
|
||||
executed.SetErr(&stderr)
|
||||
rootExecuteCommand = func(root *cobra.Command) (*cobra.Command, error) {
|
||||
executed.SetContext(root.Context())
|
||||
if err := output.StoreResult(executed.Context(), output.Success(map[string]any{"id": "a"})); err != nil {
|
||||
return executed, err
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(executed); err != nil {
|
||||
return executed, err
|
||||
}
|
||||
return executed, nil
|
||||
}
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("Execute code=%d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr=%q, want diagnostics only/empty", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
|
||||
t.Fatalf("stdout does not match the unified envelope: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootExecutionErrorToStderrOnly 是 B184 的回归断言:失败信封(JSON 错误
|
||||
// 输出)恒走 stderr,stdout 严格为空(契约 §5.1:失败时 stdout 必须为空)。
|
||||
// printExecutionError 把 PrintJSON/PrintHuman 都写 stderr writer,stdout
|
||||
// writer 不得收到任何字节。
|
||||
func TestRootExecutionErrorToStderrOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
_ = root.PersistentFlags().Set("format", "json")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewAuth("token expired")); err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
|
||||
}
|
||||
want := "{\n \"error\": {\n \"category\": \"auth\",\n \"code\": 2,\n \"message\": \"token expired\"\n }\n}\n"
|
||||
if got := stderr.String(); got != want {
|
||||
t.Fatalf("legacy root error wire changed\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootHumanErrorToStderrOnly 是 B184 的人类可读分支断言:非 JSON 模式下,
|
||||
// 失败走 stderr(PrintHuman),stdout 为空。
|
||||
func TestRootHumanErrorToStderrOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
_ = root.PersistentFlags().Set("format", "table")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewInternal("boom")); err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Error:") {
|
||||
t.Fatalf("expected human error on stderr, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootExecuteOutcomeToExitCode 是 B185 的 Execute 出口断言:Execute 把
|
||||
// 命令返回的 error 类别映射为进程退出码(apperrors.ExitCode)。ok→0、
|
||||
// confirmation/validation→3、panic 与 unrepresentable partial error→5。
|
||||
func TestRootExecuteOutcomeToExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
|
||||
// ok / pending(信封 success/pending 语义)→ 0
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("success Execute code = %d, want 0", code)
|
||||
}
|
||||
|
||||
// An error cannot carry partial succeeded/failed data and fails closed.
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, &apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"}
|
||||
}
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("partial error Execute code = %d, want 5", code)
|
||||
}
|
||||
|
||||
// confirmation_required(validation 子类)→ 3
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, apperrors.NewValidation("blocked", apperrors.WithReason("confirmation_required"))
|
||||
}
|
||||
if code := Execute(); code != 3 {
|
||||
t.Fatalf("confirmation Execute code = %d, want 3", code)
|
||||
}
|
||||
|
||||
// plain internal → 5
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, errors.New("plain")
|
||||
}
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("plain Execute code = %d, want 5", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootSilenceErrorsAndDeferTeardown 是 B186 的断言:根命令 SilenceErrors/
|
||||
// SilenceUsage 打开(Cobra 不自行打印),且 Execute 出口 defer 收尾路径
|
||||
// (StopAllStdioClients)在错误路径也被调用。
|
||||
func TestRootSilenceErrorsAndDeferTeardown(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
rootNormalizeProcessProfileArgs = oldNormalize
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
stopped := false
|
||||
rootStopAllStdioClients = func() { stopped = true }
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
|
||||
// 错误路径:Execute 返回非零,且 defer 收尾(StopAllStdioClients)被调用。
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
|
||||
return nil, apperrors.NewInternal("fail")
|
||||
}
|
||||
_ = Execute()
|
||||
if !stopped {
|
||||
t.Fatal("defer teardown (StopAllStdioClients) not called on error path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootSilenceErrorsFlag 断言根命令的 SilenceErrors/SilenceUsage 为真,
|
||||
// 保证 Cobra 不自行在错误时打印 usage/错误(错误渲染统一走 printExecutionError)。
|
||||
func TestRootSilenceErrorsFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := NewRootCommand()
|
||||
if !root.SilenceErrors || !root.SilenceUsage {
|
||||
t.Fatalf("root must set SilenceErrors=%v SilenceUsage=%v", root.SilenceErrors, root.SilenceUsage)
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
|
||||
t.Fatalf("write dry-run fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
return agentExampleFiles{
|
||||
root: root,
|
||||
markdown: "./" + filepath.Base(markdown),
|
||||
json: "./" + filepath.Base(jsonFile),
|
||||
batch: "./" + filepath.Base(batch),
|
||||
binary: "./" + filepath.Base(binary),
|
||||
image: "./" + filepath.Base(image),
|
||||
}
|
||||
}
|
||||
|
||||
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
|
||||
|
||||
@@ -25,11 +25,32 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
|
||||
if !containsSchemaPath(report.Covered, "chat category create-smart") {
|
||||
t.Fatal("chat category create-smart is not covered by runtime Schema")
|
||||
}
|
||||
for _, path := range missingChatCatalogCoveragePaths() {
|
||||
if !containsSchemaPath(report.Covered, path) {
|
||||
t.Fatalf("%s is not covered by runtime Schema", path)
|
||||
}
|
||||
}
|
||||
if !containsSchemaPath(report.Excluded, "agoal strategy list") {
|
||||
t.Fatal("agoal strategy list is not recorded as a reviewed exclusion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchemaCompletenessDoesNotExcludeMissingChatCatalogPaths(t *testing.T) {
|
||||
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
excluded := map[string]bool{}
|
||||
for _, exclusion := range exclusions {
|
||||
excluded[exclusion.CLIPath] = true
|
||||
}
|
||||
for _, path := range missingChatCatalogCoveragePaths() {
|
||||
if excluded[path] {
|
||||
t.Fatalf("%s must not remain in runtime Schema exclusions", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsSchemaPath(paths []string, want string) bool {
|
||||
for _, path := range paths {
|
||||
if path == want {
|
||||
@@ -38,3 +59,38 @@ func containsSchemaPath(paths []string, want string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func missingChatCatalogCoveragePaths() []string {
|
||||
return []string{
|
||||
"chat category add-conv",
|
||||
"chat category create",
|
||||
"chat category delete",
|
||||
"chat category remove-conv",
|
||||
"chat category rename",
|
||||
"chat chmod",
|
||||
"chat clear-all-red-point",
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
"chat group members list-by-ids",
|
||||
"chat group notice create",
|
||||
"chat group notice edit",
|
||||
"chat group notice get",
|
||||
"chat group notice list",
|
||||
"chat group share-invite",
|
||||
"chat group update-alias",
|
||||
"chat hide",
|
||||
"chat list-all-conversations",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat message list-emotion-replies",
|
||||
"chat message set-top-msg",
|
||||
"chat message unset-top-msg",
|
||||
"chat mute-at-all",
|
||||
"chat mute-red-envelope",
|
||||
"chat text translate",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,12 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
{
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.create_with_data",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_read",
|
||||
|
||||
@@ -26,6 +26,14 @@ func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
|
||||
wants := []finalSchemaSafetyWant{
|
||||
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared},
|
||||
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
// Card update intentionally layers confirmation: the atomic typed command
|
||||
// preserves its original contract, while the Agent-facing shortcut owns
|
||||
// the outer confirmation boundary.
|
||||
{canonical: "chat.update_streaming_card", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_send", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_send_by_webhook", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_update_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 294
|
||||
publicShortcutCount = 399
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 295
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 401
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 294
|
||||
publiclyDeliveredShortcutCount = 399
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,12 +114,14 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 180; got != want {
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
shortcutCount := 0
|
||||
summaryByCLIPath := make(map[string]map[string]any, len(summaries))
|
||||
for _, summary := range summaries {
|
||||
summaryByCLIPath[schemaContractString(summary["cli_path"])] = summary
|
||||
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
|
||||
shortcutCount++
|
||||
}
|
||||
@@ -127,6 +129,204 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
if shortcutCount != 98 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
|
||||
}
|
||||
for _, cliPath := range missingChatCatalogCoveragePaths() {
|
||||
if summaryByCLIPath[cliPath] == nil {
|
||||
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
|
||||
}
|
||||
}
|
||||
assertSchemaSummarySafety(t, summaryByCLIPath, "chat clear-messages", "destructive", "high", "user_required")
|
||||
assertSchemaSummarySafety(t, summaryByCLIPath, "chat data-auth cross-org", "write", "high", "user_required")
|
||||
assertSchemaSummarySafety(t, summaryByCLIPath, "chat group share-invite", "write", "medium", "user_required")
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
cliPath string,
|
||||
effect string,
|
||||
risk string,
|
||||
confirmation string,
|
||||
) {
|
||||
t.Helper()
|
||||
summary := summaries[cliPath]
|
||||
if summary == nil {
|
||||
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
|
||||
}
|
||||
if got := schemaContractString(summary["effect"]); got != effect {
|
||||
t.Fatalf("%s effect = %q, want %q", cliPath, got, effect)
|
||||
}
|
||||
if got := schemaContractString(summary["risk"]); got != risk {
|
||||
t.Fatalf("%s risk = %q, want %q", cliPath, got, risk)
|
||||
}
|
||||
if got := schemaContractString(summary["confirmation"]); got != confirmation {
|
||||
t.Fatalf("%s confirmation = %q, want %q", cliPath, got, confirmation)
|
||||
}
|
||||
}
|
||||
|
||||
func assertChatCatalogCompleteLeafContracts(t testing.TB) {
|
||||
t.Helper()
|
||||
for _, cliPath := range []string{
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat hide",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat mute-at-all",
|
||||
"chat mute-red-envelope",
|
||||
} {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", cliPath)
|
||||
assertSchemaLeafParameterRequired(t, leaf, cliPath, "conversation-id", false)
|
||||
assertSchemaLeafConstraints(t, leaf, cliPath, map[string]any{
|
||||
"require_one_of": [][]string{{"conversation-id", "id", "chat"}},
|
||||
"mutually_exclusive": [][]string{{"conversation-id", "id", "chat"}},
|
||||
})
|
||||
}
|
||||
|
||||
markRead := executeShortcutSchemaQuery(t, "--cli-path", "chat mark-read")
|
||||
assertSchemaLeafParameterRequired(t, markRead, "chat mark-read", "message-id", true)
|
||||
|
||||
chmod := executeShortcutSchemaQuery(t, "--cli-path", "chat chmod")
|
||||
assertSchemaLeafConstraints(t, chmod, "chat chmod", map[string]any{
|
||||
"require_one_of": [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
"mutually_exclusive": [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
|
||||
})
|
||||
assertChatGrantParameterFacts(t, chmod, "chat chmod")
|
||||
|
||||
crossOrg := executeShortcutSchemaQuery(t, "--cli-path", "chat data-auth cross-org")
|
||||
assertSchemaLeafConstraints(t, crossOrg, "chat data-auth cross-org", map[string]any{
|
||||
"require_one_of": [][]string{{"target-org-id", "all"}},
|
||||
"mutually_exclusive": [][]string{{"target-org-id", "all"}},
|
||||
})
|
||||
assertChatGrantParameterFacts(t, crossOrg, "chat data-auth cross-org")
|
||||
|
||||
shareInvite := executeShortcutSchemaQuery(t, "--cli-path", "chat group share-invite")
|
||||
assertSchemaLeafConstraints(t, shareInvite, "chat group share-invite", map[string]any{
|
||||
"require_one_of": [][]string{{"target", "receiver"}},
|
||||
"mutually_exclusive": [][]string{{"target", "receiver"}},
|
||||
})
|
||||
|
||||
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
|
||||
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
|
||||
}
|
||||
if got, _ := parameter["required"].(bool); got != want {
|
||||
t.Fatalf("%s --%s required = %#v, want %v", cliPath, name, parameter["required"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterEnum(t testing.TB, leaf map[string]any, cliPath, name string, want []string) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractStringSlice(parameter["enum"]); !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("%s --%s enum = %#v, want %#v", cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafConstraints(t testing.TB, leaf map[string]any, cliPath string, want map[string]any) {
|
||||
t.Helper()
|
||||
if got := leaf["constraints"]; !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("%s constraints = %#v, want %#v", cliPath, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertChatGrantParameterFacts(t testing.TB, leaf map[string]any, cliPath string) {
|
||||
t.Helper()
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
grantType := parameters["grant-type"]
|
||||
if grantType == nil {
|
||||
t.Fatalf("%s missing --grant-type parameter: %#v", cliPath, parameters)
|
||||
}
|
||||
wantEnum := []string{"once", "session", "timed", "permanent"}
|
||||
if got := schemaContractStringSlice(grantType["enum"]); !schemaContractJSONEqual(got, wantEnum) {
|
||||
t.Fatalf("%s --grant-type enum = %#v, want %#v", cliPath, got, wantEnum)
|
||||
}
|
||||
if got := schemaContractString(parameters["session-id"]["required_when"]); got != "grant-type is session" {
|
||||
t.Fatalf("%s --session-id required_when = %q, want grant-type is session", cliPath, got)
|
||||
}
|
||||
if got := schemaContractString(parameters["ttl"]["required_when"]); got != "grant-type is timed" {
|
||||
t.Fatalf("%s --ttl required_when = %q, want grant-type is timed", cliPath, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
|
||||
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
|
||||
t.Fatalf("confirmation = %q, want %q", got, want)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
if got, want := len(parameters), 11; got != want {
|
||||
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
|
||||
}
|
||||
if required, _ := parameters["node"]["required"].(bool); !required {
|
||||
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
|
||||
}
|
||||
if required, _ := parameters["command"]["required"].(bool); required {
|
||||
t.Errorf("--command required = true, want runtime custom validation")
|
||||
}
|
||||
wantProperties := map[string]string{
|
||||
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
|
||||
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
|
||||
"expected-revision": "expectedRevision",
|
||||
}
|
||||
for name, want := range wantProperties {
|
||||
if got := schemaContractString(parameters[name]["property"]); got != want {
|
||||
t.Errorf("--%s property = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
|
||||
parameter := parameters[name]
|
||||
if required, _ := parameter["required"].(bool); required {
|
||||
t.Errorf("--%s required = true, want runtime custom validation", name)
|
||||
}
|
||||
if got := schemaContractString(parameter["required_when"]); got != "" {
|
||||
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
|
||||
}
|
||||
}
|
||||
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
|
||||
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
|
||||
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
|
||||
commentParameters := schemaContractMap(comment["parameters"])
|
||||
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
|
||||
if _, ok := commentParameters[name]; !ok {
|
||||
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
|
||||
}
|
||||
}
|
||||
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
|
||||
if got := schemaContractString(commentParameters[name]["property"]); got != want {
|
||||
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
|
||||
exportFormat := schemaContractMap(export["parameters"])["export-format"]
|
||||
if required, _ := exportFormat["required"].(bool); required {
|
||||
t.Fatalf("export --export-format required = true, want compatibility default")
|
||||
}
|
||||
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
|
||||
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
|
||||
}
|
||||
|
||||
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
|
||||
constraints, _ := importLeaf["constraints"].(map[string]any)
|
||||
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
|
||||
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
|
||||
|
||||
@@ -81,6 +81,7 @@ func newServerFailureAPIError(
|
||||
apperrors.WithReason(fallbackReason),
|
||||
apperrors.WithServerKey(serverKey),
|
||||
apperrors.WithHint(fallbackHint),
|
||||
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
|
||||
@@ -95,6 +95,9 @@ func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing
|
||||
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("unexpected fallback classification: %#v", typed)
|
||||
}
|
||||
if len(typed.Actions) == 0 || !strings.Contains(typed.Actions[0], "dws doctor") {
|
||||
t.Fatalf("fallback error has no stable troubleshooting entry: %#v", typed.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
var rootEscalateSignal = func(sig os.Signal) {
|
||||
signal.Reset(sig)
|
||||
redeliverProcessSignal(sig)
|
||||
}
|
||||
|
||||
var (
|
||||
rootFindProcess = os.FindProcess
|
||||
rootExitProcess = os.Exit
|
||||
)
|
||||
|
||||
// redeliverProcessSignal asks the current process to handle the second signal
|
||||
// with the platform's default semantics. Platforms that cannot deliver the
|
||||
// requested signal through os.Process.Signal fall back to the conventional
|
||||
// CLI exit status instead of leaving the process running after escalation.
|
||||
func redeliverProcessSignal(sig os.Signal) {
|
||||
process, err := rootFindProcess(os.Getpid())
|
||||
if err == nil {
|
||||
err = process.Signal(sig)
|
||||
}
|
||||
if err != nil {
|
||||
rootExitProcess(interruptionExitCode(sig))
|
||||
}
|
||||
}
|
||||
|
||||
func interruptionExitCode(sig os.Signal) int {
|
||||
if sig == syscall.SIGTERM {
|
||||
return 143
|
||||
}
|
||||
return 130
|
||||
}
|
||||
|
||||
type processInterruption struct {
|
||||
signal os.Signal
|
||||
}
|
||||
|
||||
func (e *processInterruption) Error() string {
|
||||
return fmt.Sprintf("process interrupted by %s", e.signal)
|
||||
}
|
||||
|
||||
func (e *processInterruption) Unwrap() error { return context.Canceled }
|
||||
|
||||
func (e *processInterruption) ExitCode() int {
|
||||
return interruptionExitCode(e.signal)
|
||||
}
|
||||
|
||||
func (e *processInterruption) Subtype() string {
|
||||
if e.signal == syscall.SIGTERM {
|
||||
return "terminated"
|
||||
}
|
||||
return "cancelled_by_user"
|
||||
}
|
||||
|
||||
type processSignalState struct {
|
||||
mu sync.Mutex
|
||||
interruption *processInterruption
|
||||
primaryCompletedAtSignal bool
|
||||
}
|
||||
|
||||
func (s *processSignalState) record(sig os.Signal, store *output.ResultStore) (first bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.interruption != nil {
|
||||
return false
|
||||
}
|
||||
_, _, s.primaryCompletedAtSignal, _ = output.StoredEmissionState(store)
|
||||
s.interruption = &processInterruption{signal: sig}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *processSignalState) outcome() (*processInterruption, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.interruption, s.primaryCompletedAtSignal
|
||||
}
|
||||
|
||||
func installProcessSignalContext(parent context.Context, store *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
|
||||
return manageProcessSignals(parent, store, signals, func() { signal.Stop(signals) }, rootEscalateSignal)
|
||||
}
|
||||
|
||||
func manageProcessSignals(
|
||||
parent context.Context,
|
||||
store *output.ResultStore,
|
||||
signals <-chan os.Signal,
|
||||
stopNotify func(),
|
||||
escalate func(os.Signal),
|
||||
) (context.Context, *processSignalState, func()) {
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
state := &processSignalState{}
|
||||
done := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
for {
|
||||
select {
|
||||
case sig := <-signals:
|
||||
if sig == nil {
|
||||
continue
|
||||
}
|
||||
if state.record(sig, store) {
|
||||
cancel(state.interruption)
|
||||
continue
|
||||
}
|
||||
escalate(sig)
|
||||
return
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
stop := func() {
|
||||
stopOnce.Do(func() {
|
||||
stopNotify()
|
||||
close(done)
|
||||
<-stopped
|
||||
cancel(context.Canceled)
|
||||
})
|
||||
}
|
||||
return ctx, state, stop
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func signalSelf(t *testing.T, sig syscall.Signal) {
|
||||
t.Helper()
|
||||
process, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("find current process: %v", err)
|
||||
}
|
||||
if err := process.Signal(sig); err != nil {
|
||||
t.Skipf("current platform does not support process signal delivery: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
|
||||
originalFind, originalExit := rootFindProcess, rootExitProcess
|
||||
t.Cleanup(func() { rootFindProcess, rootExitProcess = originalFind, originalExit })
|
||||
rootFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find failed") }
|
||||
exitCode := 0
|
||||
rootExitProcess = func(code int) { exitCode = code }
|
||||
rootEscalateSignal(syscall.SIGTERM)
|
||||
if exitCode != 143 {
|
||||
t.Fatalf("escalation exit=%d", exitCode)
|
||||
}
|
||||
exitCode = 0
|
||||
redeliverProcessSignal(syscall.SIGTERM)
|
||||
if exitCode != 143 {
|
||||
t.Fatalf("fallback exit=%d", exitCode)
|
||||
}
|
||||
rootFindProcess = func(int) (*os.Process, error) { return os.FindProcess(99999999) }
|
||||
exitCode = 0
|
||||
redeliverProcessSignal(syscall.SIGINT)
|
||||
if exitCode != 130 {
|
||||
t.Fatalf("signal fallback exit=%d", exitCode)
|
||||
}
|
||||
interrupted := &processInterruption{signal: syscall.SIGINT}
|
||||
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
|
||||
t.Fatalf("interruption=%v", interrupted)
|
||||
}
|
||||
terminated := &processInterruption{signal: syscall.SIGTERM}
|
||||
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
|
||||
t.Fatalf("termination=%v", terminated)
|
||||
}
|
||||
state := &processSignalState{}
|
||||
if !state.record(syscall.SIGINT, nil) || state.record(syscall.SIGTERM, nil) {
|
||||
t.Fatal("signal state did not reject a second interruption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
|
||||
signals := make(chan os.Signal, 3)
|
||||
stopped, escalated := false, make(chan os.Signal, 1)
|
||||
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() { stopped = true }, func(sig os.Signal) { escalated <- sig })
|
||||
signals <- nil
|
||||
signals <- syscall.SIGINT
|
||||
<-ctx.Done()
|
||||
signals <- syscall.SIGTERM
|
||||
if got := <-escalated; got != syscall.SIGTERM {
|
||||
t.Fatalf("escalated=%v", got)
|
||||
}
|
||||
stop()
|
||||
stop()
|
||||
if !stopped {
|
||||
t.Fatal("signal notification was not stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
if unified {
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
}
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
return cmd
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
code int
|
||||
subtype string
|
||||
}{
|
||||
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
||||
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, &stderr)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
signalSelf(t, tc.signal)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
|
||||
if code := Execute(); code != tc.code {
|
||||
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
|
||||
}
|
||||
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
||||
t.Fatalf("error=%+v, want internal/%s exit %d", env.Error, tc.subtype, tc.code)
|
||||
}
|
||||
if bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)) != 1 {
|
||||
t.Fatalf("stdout must contain one failure envelope: %s", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
signal syscall.Signal
|
||||
code int
|
||||
}{{syscall.SIGINT, 130}, {syscall.SIGTERM, 143}} {
|
||||
t.Run(tc.signal.String(), func(t *testing.T) {
|
||||
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
signalSelf(t, tc.signal)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != tc.code {
|
||||
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeadlineIsNotSignalCancellation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
return cmd, context.DeadlineExceeded
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d, want internal deadline code 5", code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Error == nil || env.Error.Subtype != "deadline_exceeded" || env.Error.ExitCode == 130 || env.Error.ExitCode == 143 {
|
||||
t.Fatalf("deadline error=%+v", env.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
cmd.SetOut(failingWriter{})
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _ = output.EmitStoredResult(cmd)
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("Execute code=%d, want publication failure code 5", code)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("second envelope emitted: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
// Record cancellation before publication begins, then simulate a command
|
||||
// hook that has already committed its result and completes publication.
|
||||
// The wire result must remain authoritative over the earlier signal.
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("Execute code=%d, want published success code 0", code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
|
||||
}
|
||||
if !env.OK || env.Outcome != output.OutcomeSuccess {
|
||||
t.Fatalf("published envelope=%+v, want successful outcome", env)
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
|
||||
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signalSelf(t, syscall.SIGINT)
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("Execute code=%d, want established success code 0", code)
|
||||
}
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
|
||||
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSignalSubprocessExitStatus(t *testing.T) {
|
||||
if os.Getenv("DWS_SIGNAL_HELPER") == "1" {
|
||||
installSignalExecuteSeams(t, true, os.Stdout, os.Stderr)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "READY")
|
||||
<-cmd.Context().Done()
|
||||
return cmd, cmd.Context().Err()
|
||||
})
|
||||
os.Exit(Execute())
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
code int
|
||||
subtype string
|
||||
}{
|
||||
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
||||
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestExecuteSignalSubprocessExitStatus$")
|
||||
cmd.Env = append(os.Environ(), "DWS_SIGNAL_HELPER=1")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scanner := bufio.NewScanner(stderr); !scanner.Scan() || scanner.Text() != "READY" {
|
||||
t.Fatalf("helper readiness failed: %q, err=%v", scanner.Text(), scanner.Err())
|
||||
}
|
||||
if err := cmd.Process.Signal(tc.signal); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
t.Skipf("current platform does not support subprocess signal delivery: %v", err)
|
||||
}
|
||||
payload, readErr := io.ReadAll(stdout)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
waitErr := cmd.Wait()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != tc.code {
|
||||
t.Fatalf("wait error=%v, want exit %d", waitErr, tc.code)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(payload, &env); err != nil {
|
||||
t.Fatalf("decode helper output: %v; output=%q", err, payload)
|
||||
}
|
||||
if env.Error == nil || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
||||
t.Fatalf("helper error=%+v", env.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondSignalUsesEscalationSeam(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
escalated := make(chan os.Signal, 1)
|
||||
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() {}, func(sig os.Signal) {
|
||||
escalated <- sig
|
||||
})
|
||||
signals <- syscall.SIGINT
|
||||
<-ctx.Done()
|
||||
if !errors.Is(context.Cause(ctx), context.Canceled) {
|
||||
t.Fatalf("cause=%v, want cancellation", context.Cause(ctx))
|
||||
}
|
||||
signals <- syscall.SIGTERM
|
||||
if got := <-escalated; got != syscall.SIGTERM {
|
||||
t.Fatalf("escalated %v, want SIGTERM", got)
|
||||
}
|
||||
stop()
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
@@ -126,6 +126,7 @@ var agentSkillPaths = map[string]string{
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"zcode": ".zcode/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
// IDE / agent registries also probed by `dws skill setup --target all`.
|
||||
"gemini": ".gemini/skills",
|
||||
|
||||
@@ -452,7 +452,7 @@ func TestSupportedTargets(t *testing.T) {
|
||||
// Should contain all predefined targets — including the agents/* sentinel
|
||||
// and the IDE/agent registries we share with skillSetupAgentHomes.
|
||||
expectedTargets := []string{
|
||||
"agents", "claude", "cursor", "codex", "opencode", "qoder",
|
||||
"agents", "claude", "cursor", "codex", "zcode", "opencode", "qoder",
|
||||
"gemini", "github", "windsurf", "augment", "cline",
|
||||
"amp", "kiro", "trae", "openclaw", "hermes",
|
||||
".",
|
||||
|
||||
+1376
-159
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func useManagedSkillNames(t *testing.T, names ...string) {
|
||||
t.Helper()
|
||||
records := make([]skillprovenance.Record, 0, len(names))
|
||||
for _, name := range names {
|
||||
records = append(records, skillprovenance.Record{Name: name})
|
||||
}
|
||||
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
|
||||
return &skillstate.State{ManagedSkills: records}, true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills verifies the
|
||||
// confirmation prompt lists stale dingtalk-* / dws-shared directories that a
|
||||
// full (unfiltered) multi install will back up and remove, and that a
|
||||
// filtered install previews nothing extra.
|
||||
func TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupInteractive, func() bool { return false })
|
||||
|
||||
dest := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
stale := filepath.Join(dest, "dingtalk-old")
|
||||
if err := os.MkdirAll(stale, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-old")
|
||||
|
||||
var out bytes.Buffer
|
||||
ok, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, false)
|
||||
if err == nil || ok || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "将备份并移除过期 skill") {
|
||||
t.Fatalf("full install preview must list stale skills, got %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), filepath.Join(dest, "dingtalk-old")) {
|
||||
t.Fatalf("preview must name the stale directory, got %q", out.String())
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
ok, err = confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, true)
|
||||
if err == nil || ok {
|
||||
t.Fatalf("filtered confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
|
||||
}
|
||||
if strings.Contains(out.String(), "将备份并移除过期 skill") {
|
||||
t.Fatalf("filtered install must stay additive in the preview, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupUnifiedOwnership(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "dingtalk-custom")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isManagedDWSMultiSkillDir(dir) {
|
||||
t.Fatal("an unregistered dingtalk-* directory must not be treated as DWS-owned")
|
||||
}
|
||||
managed := map[string]bool{"dingtalk-custom": true}
|
||||
if !isManagedDWSMultiSkillDir(dir, managed) {
|
||||
t.Fatal("unified metadata must prove ownership")
|
||||
}
|
||||
legacy := filepath.Join(t.TempDir(), legacySharedSkill)
|
||||
if !isManagedDWSMultiSkillDir(legacy) {
|
||||
t.Fatal("the exact legacy dws-shared name must remain managed")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePublishManagedSkillFailurePaths(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
skillSrc := filepath.Join(src, "dingtalk-a")
|
||||
failure := errors.New("publish denied")
|
||||
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupPublishTemp, func(string, string) (string, error) { return "", failure })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "staging") {
|
||||
t.Fatalf("mkdir error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("copy", func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("copy error = %v", err)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("copy failure retained staging: %v, err=%v", entries, readErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "发布 Skill") {
|
||||
t.Fatalf("rename error = %v", err)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("rename failure retained staging: %v, err=%v", entries, readErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleanup", func(t *testing.T) {
|
||||
renameErr := errors.New("rename denied")
|
||||
cleanupErr := errors.New("cleanup denied")
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return renameErr })
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
|
||||
if !errors.Is(err, renameErr) || !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupCleanupHomeFailure verifies that
|
||||
// cleanupMutualExclusion keeps every victim in place with a warning when
|
||||
// $HOME cannot be resolved, instead of destroying anything.
|
||||
func TestCrossPlatformCoverageSkillSetupCleanupHomeFailure(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), ".agents", "skills")
|
||||
victim := filepath.Join(dest, "dws")
|
||||
if err := os.MkdirAll(victim, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
homeErr := errors.New("home boom")
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", homeErr })
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
|
||||
t.Fatalf("expected HOME warning on errOut, got %q", errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(victim, "SKILL.md")); err != nil {
|
||||
t.Fatalf("victim must survive the HOME failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupBackupFailureSkipsWholeTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
copyCalls++
|
||||
return nil
|
||||
})
|
||||
failure := errors.New("backup boom")
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if filepath.Base(dir) == "dws" {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
|
||||
if err != nil || installed != 0 || skipped != 2 {
|
||||
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
|
||||
}
|
||||
if copyCalls != 2 {
|
||||
t.Fatalf("backup failure staged %d new Skills, want 2", copyCalls)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "跳过整个 Agent 目标") {
|
||||
t.Fatalf("missing whole-target warning: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCleanupMutualExclusionBackupFailure(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
victim := filepath.Join(dest, "dws")
|
||||
if err := os.MkdirAll(victim, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failure := errors.New("backup boom")
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if dir != victim {
|
||||
t.Fatalf("backup victim = %q, want %q", dir, victim)
|
||||
}
|
||||
return "", failure
|
||||
})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("cleanup error = %v, want %v", err, failure)
|
||||
}
|
||||
if out.Len() != 0 || !strings.Contains(errOut.String(), "互斥清理失败") {
|
||||
t.Fatalf("cleanup output = %q / %q", out.String(), errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMonoCleanupFailureSkipsWholeTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
copyCalls++
|
||||
return nil
|
||||
})
|
||||
failure := errors.New("multi backup boom")
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if filepath.Base(dir) == "dingtalk-a" {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
base := filepath.Join(home, ".agents", "skills")
|
||||
multi := filepath.Join(base, "dingtalk-a")
|
||||
if err := os.MkdirAll(multi, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useManagedSkillNames(t, filepath.Base(multi))
|
||||
monoSrc := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
|
||||
}
|
||||
if copyCalls != 1 {
|
||||
t.Fatalf("multi cleanup failure staged mono %d times, want 1", copyCalls)
|
||||
}
|
||||
if _, err := os.Stat(multi); err != nil {
|
||||
t.Fatalf("multi leftover must survive backup failure: %v", err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
|
||||
t.Fatalf("missing mono whole-target warning: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupStaleBackupFailureSkipsWholeTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
copyCalls++
|
||||
return nil
|
||||
})
|
||||
failure := errors.New("stale backup boom")
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if filepath.Base(dir) == "dingtalk-stale" {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
stale := filepath.Join(dest, "dingtalk-stale")
|
||||
if err := os.MkdirAll(stale, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useManagedSkillNames(t, filepath.Base(stale))
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
|
||||
if err != nil || installed != 0 || skipped != 2 {
|
||||
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
|
||||
}
|
||||
if copyCalls != 2 {
|
||||
t.Fatalf("stale backup failure staged %d new Skills, want 2", copyCalls)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
|
||||
t.Fatalf("missing stale whole-target warning: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *testing.T) {
|
||||
for _, failureKind := range []string{"later_backup", "later_publish"} {
|
||||
failureKind := failureKind
|
||||
t.Run(failureKind, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
first := filepath.Join(dest, "dingtalk-first")
|
||||
second := filepath.Join(dest, "dingtalk-second")
|
||||
for path, body := range map[string]string{
|
||||
filepath.Join(first, "SKILL.md"): "old first\n",
|
||||
filepath.Join(second, "SKILL.md"): "old second\n",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-first", "dingtalk-second"})
|
||||
if err := os.WriteFile(filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
failure := errors.New("injected " + failureKind + " failure")
|
||||
if failureKind == "later_backup" {
|
||||
originalBackup := skillSetupBackupAndRemove
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(homeDir, dir string) (string, error) {
|
||||
if dir == second {
|
||||
return "", failure
|
||||
}
|
||||
return originalBackup(homeDir, dir)
|
||||
})
|
||||
} else {
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
})
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
src,
|
||||
[]string{"dingtalk-first", "dingtalk-second"},
|
||||
[]string{dest},
|
||||
&out,
|
||||
&errOut,
|
||||
true,
|
||||
)
|
||||
if err != nil || installed != 0 || skipped != 2 {
|
||||
t.Fatalf("transaction failure = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
filepath.Join(first, "SKILL.md"): "old first\n",
|
||||
filepath.Join(second, "SKILL.md"): "old second\n",
|
||||
} {
|
||||
got, readErr := os.ReadFile(path)
|
||||
if readErr != nil || string(got) != want {
|
||||
t.Fatalf("restored %s = %q, err=%v, want %q", path, got, readErr, want)
|
||||
}
|
||||
}
|
||||
entries, readErr := os.ReadDir(dest)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-setup-set-") {
|
||||
t.Fatalf("transaction left staging directory %s", entry.Name())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "已执行回滚") || !strings.Contains(errOut.String(), failure.Error()) {
|
||||
t.Fatalf("transaction failure output = %q", errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
failure := errors.New("injected transaction failure")
|
||||
|
||||
t.Run("managed publish success", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
dest := filepath.Join(t.TempDir(), "dingtalk-a")
|
||||
if err := publishDWSManagedSkillDir(filepath.Join(src, "dingtalk-a"), dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
|
||||
t.Fatalf("published Skill missing: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staging cleanup failure", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
dest := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
|
||||
cleanupErr := errors.New("staging cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
|
||||
_, _, err := stageSkillSetupTarget(
|
||||
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
|
||||
skillSetupTargetPlan{Destination: dest},
|
||||
)
|
||||
if !errors.Is(err, failure) || !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("staging cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staging directory failure", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
dest := t.TempDir()
|
||||
originalMkdirAll := skillSetupMkdirAll
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(path string, mode os.FileMode) error {
|
||||
if filepath.Base(path) == "dingtalk-a" && strings.HasPrefix(filepath.Base(filepath.Dir(path)), ".dws-setup-set-") {
|
||||
return failure
|
||||
}
|
||||
return originalMkdirAll(path, mode)
|
||||
})
|
||||
_, _, err := stageSkillSetupTarget(
|
||||
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
|
||||
skillSetupTargetPlan{Destination: dest},
|
||||
)
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill staging 目录失败") {
|
||||
t.Fatalf("staging directory error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restore failure aggregation", func(t *testing.T) {
|
||||
t.Run("remove published", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
|
||||
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
|
||||
t.Fatalf("remove published error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("original still exists", func(t *testing.T) {
|
||||
original := t.TempDir()
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: original, backup: "backup"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "恢复目标仍存在") {
|
||||
t.Fatalf("existing restore target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("stat", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
|
||||
t.Fatalf("restore stat error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
|
||||
t.Fatalf("restore mkdir error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
|
||||
t.Fatalf("restore rename error = %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("backup rollback failure", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return "backup", nil
|
||||
}
|
||||
return "", failure
|
||||
})
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
restoreErr := errors.New("restore failure")
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
|
||||
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
|
||||
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
|
||||
t.Fatalf("backup rollback error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publish rollback failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
err := publishSkillSetupTarget(
|
||||
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
|
||||
[]skillSetupBackedUpDir{{original: "dest", backup: "backup"}},
|
||||
)
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "回滚不完整") {
|
||||
t.Fatalf("publish rollback error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("execute cleanup errors", func(t *testing.T) {
|
||||
newPlan := func(t *testing.T) *skillSetupPlan {
|
||||
t.Helper()
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
return &skillSetupPlan{
|
||||
Mode: skillSetupModeMulti,
|
||||
Source: src,
|
||||
MultiSkillNames: []string{"dingtalk-a"},
|
||||
Targets: []skillSetupTargetPlan{{Destination: t.TempDir()}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("after backup failure", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
plan.Targets[0].Backups = []skillSetupBackup{{Path: filepath.Join(plan.Targets[0].Destination, "old")}}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
|
||||
cleanupErr := errors.New("cleanup after backup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
|
||||
t.Fatalf("backup cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("after publish failure", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
})
|
||||
originalRemoveAll := skillSetupRemoveAll
|
||||
cleanupErr := errors.New("cleanup after publish failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
|
||||
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
|
||||
return cleanupErr
|
||||
}
|
||||
return originalRemoveAll(path)
|
||||
})
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
|
||||
t.Fatalf("publish cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("after success", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
originalRemoveAll := skillSetupRemoveAll
|
||||
cleanupErr := errors.New("cleanup after success")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
|
||||
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
|
||||
return cleanupErr
|
||||
}
|
||||
return originalRemoveAll(path)
|
||||
})
|
||||
var stderr bytes.Buffer
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
|
||||
if err != nil || installed != 1 || skipped != 0 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
|
||||
t.Fatalf("success cleanup = installed %d, skipped %d, err %v, stderr %q", installed, skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips verifies both
|
||||
// install paths skip (never destroy) every target when $HOME cannot be
|
||||
// resolved for the pre-refresh backup.
|
||||
func TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
|
||||
|
||||
monoSrc := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if err := os.MkdirAll(monoDest, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(monoDest, "SKILL.md"), []byte("# old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installSkillToHomes(monoSrc, []string{monoDest}, &out, &errOut)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("mono install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过刷新") {
|
||||
t.Fatalf("expected HOME skip warning, got %q", errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
|
||||
t.Fatalf("existing mono dir must be preserved: %v", err)
|
||||
}
|
||||
|
||||
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
multiDest := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(multiDest, "dingtalk-a"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
installed, skipped, err = installMultiSkillToHomes(multiSrc, []string{"dingtalk-a"}, []string{multiDest}, &out, &errOut, true)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("multi install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过整个 Agent 目标") {
|
||||
t.Fatalf("expected multi HOME skip warning, got %q", errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(multiDest, "dingtalk-a")); err != nil {
|
||||
t.Fatalf("existing sub skill must be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges covers
|
||||
// removeStaleMultiSkills and its preview companion staleMultiSkillVictims:
|
||||
// scan failures, the HOME failure, backup failures, and the success path.
|
||||
func TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), ".cursor", "skills")
|
||||
keep := []string{"dingtalk-chat"}
|
||||
entries := map[string]bool{ // dir entries; README below is a plain file
|
||||
"dingtalk-chat": true, // kept (in bundle)
|
||||
"dingtalk-stale": true, // stale product skill
|
||||
"dws-shared": true, // legacy shared name is stale too
|
||||
"other-skill": true, // non-DWS, must survive
|
||||
}
|
||||
for name := range entries {
|
||||
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-stale")
|
||||
if err := os.WriteFile(filepath.Join(dest, "README"), []byte("file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
|
||||
// Non-ENOENT scan failure warns; ENOENT is silent.
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("scan boom") })
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "过期 skill 扫描失败") {
|
||||
t.Fatalf("expected scan warning, got %q", errOut.String())
|
||||
}
|
||||
errOut.Reset()
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist })
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if errOut.Len() != 0 {
|
||||
t.Fatalf("ENOENT scan must be silent, got %q", errOut.String())
|
||||
}
|
||||
testseam.Swap(t, &skillSetupReadDir, os.ReadDir)
|
||||
|
||||
// The preview companion sees the same victims and skips files/kept/non-DWS.
|
||||
victims := staleMultiSkillVictims(dest, keep)
|
||||
wantVictims := []string{filepath.Join(dest, "dingtalk-stale"), filepath.Join(dest, "dws-shared")}
|
||||
if len(victims) != len(wantVictims) {
|
||||
t.Fatalf("staleMultiSkillVictims = %v, want %v", victims, wantVictims)
|
||||
}
|
||||
|
||||
// HOME failure keeps every stale directory with a warning.
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
|
||||
errOut.Reset()
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
|
||||
t.Fatalf("expected HOME warning, got %q", errOut.String())
|
||||
}
|
||||
for name := range entries {
|
||||
if _, err := os.Stat(filepath.Join(dest, name)); err != nil {
|
||||
t.Fatalf("entry %s must survive the HOME failure: %v", name, err)
|
||||
}
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
|
||||
// Backup failure keeps the stale directory with a warning.
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup boom") })
|
||||
errOut.Reset()
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "过期 skill 清理失败(保留原目录") {
|
||||
t.Fatalf("expected backup failure warning, got %q", errOut.String())
|
||||
}
|
||||
for _, stale := range wantVictims {
|
||||
if _, err := os.Stat(stale); err != nil {
|
||||
t.Fatalf("stale dir must survive the backup failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Success: both stale dirs are backed up and reported; the rest survives.
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_, dir string) (string, error) { return filepath.Join(t.TempDir(), "backup"), nil })
|
||||
out.Reset()
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if count := strings.Count(out.String(), "已备份并清理过期 skill"); count != len(wantVictims) {
|
||||
t.Fatalf("expected %d stale cleanup lines, got %d (out=%q)", len(wantVictims), count, out.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "other-skill")); err != nil {
|
||||
t.Fatalf("non-DWS dir must survive: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-chat")); err != nil {
|
||||
t.Fatalf("bundle skill must survive: %v", err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user