Compare commits
508
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c64e5ef4 | ||
|
|
7a140e59c3 | ||
|
|
38832448d2 | ||
|
|
3d4e43f4fc | ||
|
|
155ce984c9 | ||
|
|
4b93a1cb28 | ||
|
|
1d384b9189 | ||
|
|
025287873d | ||
|
|
6ddda6f1bf | ||
|
|
dde5049454 | ||
|
|
a3f5a83527 | ||
|
|
5d7a66d4a3 | ||
|
|
aebb75371b | ||
|
|
0a0634cfc2 | ||
|
|
46aa0fe16d | ||
|
|
78165393e0 | ||
|
|
931af6af59 | ||
|
|
f6a4e0d5ad | ||
|
|
44311d0160 | ||
|
|
fb44601f21 | ||
|
|
83c64d31dd | ||
|
|
f81d09fb95 | ||
|
|
e437cf4bbb | ||
|
|
cd1ba34d96 | ||
|
|
2cc410db6c | ||
|
|
495a3b256f | ||
|
|
4210334f55 | ||
|
|
06ec207d17 | ||
|
|
30caba5dcb | ||
|
|
76316ef5f0 | ||
|
|
f5b1c2659f | ||
|
|
b4f0053bbe | ||
|
|
3593818a46 | ||
|
|
21bbf42ca7 | ||
|
|
edf1e58141 | ||
|
|
bd94c63de8 | ||
|
|
43b1936b65 | ||
|
|
9d8806927f | ||
|
|
dbe47d58fb | ||
|
|
8c2c94e0f1 | ||
|
|
b7b78f0c16 | ||
|
|
ec7593dabb | ||
|
|
1df4cc95a6 | ||
|
|
66468c703f | ||
|
|
74ef426064 | ||
|
|
5ce391b49b | ||
|
|
4a14f4b1e3 | ||
|
|
451a6fffe7 | ||
|
|
d052c104d9 | ||
|
|
e4e653d3b3 | ||
|
|
6b85867309 | ||
|
|
fdf3e8cc3b | ||
|
|
a5902ca233 | ||
|
|
0fb332c3f3 | ||
|
|
28669ffeee | ||
|
|
fa5bc65d66 | ||
|
|
27b16b190f | ||
|
|
de1e1aaf6c | ||
|
|
20d1f7c614 | ||
|
|
233e0359e4 | ||
|
|
ad6837d694 | ||
|
|
49afa82d27 | ||
|
|
3eda3b5ce6 | ||
|
|
49ab7a46f4 | ||
|
|
d500f2fe5f | ||
|
|
7849116a69 | ||
|
|
b082135e6e | ||
|
|
bcc9e27da0 | ||
|
|
cf64f2ad02 | ||
|
|
5ab46921c5 | ||
|
|
f8a031564a | ||
|
|
9ae0191270 | ||
|
|
2989c1db37 | ||
|
|
eaee7f1c6f | ||
|
|
211a5fa393 | ||
|
|
103b188458 | ||
|
|
8619d90119 | ||
|
|
156d95e6d1 | ||
|
|
af199e73e2 | ||
|
|
fd24619437 | ||
|
|
b1f5c67e9c | ||
|
|
037deefe67 | ||
|
|
42e764a7a8 | ||
|
|
6337058d15 | ||
|
|
d38868c8aa | ||
|
|
06ed3aeeb3 | ||
|
|
de8040ecc2 | ||
|
|
96f406be6b | ||
|
|
b244df1634 | ||
|
|
0d99d18acc | ||
|
|
9377abc5f6 | ||
|
|
eb3f7328bb | ||
|
|
3c445ce73a | ||
|
|
e8ca78fe49 | ||
|
|
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 | ||
|
|
d3e444cb56 | ||
|
|
6fdd17d3b6 | ||
|
|
5c2181a31d | ||
|
|
0148ad1800 | ||
|
|
956819663d | ||
|
|
670ab1fd5e | ||
|
|
b299400017 | ||
|
|
3afcabc41d | ||
|
|
4a4a1e0407 | ||
|
|
e38fd9ab93 | ||
|
|
fb33a0b9e0 | ||
|
|
e94c7063ed | ||
|
|
d6b51a04f4 | ||
|
|
cd3a09e153 | ||
|
|
2f925d29fd | ||
|
|
68483f05b2 | ||
|
|
730d3fa27f | ||
|
|
6eb3efa065 | ||
|
|
a43e75e8df | ||
|
|
783e1eeef9 | ||
|
|
9e0a67f728 | ||
|
|
19f9285f8c | ||
|
|
c295027e84 | ||
|
|
3817ac230d | ||
|
|
75b54a9467 | ||
|
|
24437fc1a5 | ||
|
|
2aad96fa7b | ||
|
|
3fe2a7f5c0 | ||
|
|
851d491d2a | ||
|
|
b55f243780 | ||
|
|
e9850a2e49 | ||
|
|
24fd2d2573 | ||
|
|
90d99d9bbe | ||
|
|
257ac94fb1 | ||
|
|
9834a84888 | ||
|
|
8097943e3a | ||
|
|
a68c06540c | ||
|
|
44c5ef13b4 | ||
|
|
d5a9a72fa6 | ||
|
|
6f73e5187a | ||
|
|
b7918be6f3 | ||
|
|
a37f614be4 | ||
|
|
b70e109e89 | ||
|
|
b84b56d9f8 | ||
|
|
e66cd95c51 | ||
|
|
7e8b216e07 | ||
|
|
08cf334cc1 | ||
|
|
12088f2d44 | ||
|
|
d9c74fbe96 | ||
|
|
3fc144a699 | ||
|
|
5501c9f1a5 | ||
|
|
1522653844 | ||
|
|
4d274c9da3 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
f0b0bdbe48 | ||
|
|
1fe019994d | ||
|
|
85cb41423b | ||
|
|
b9e7ff8c55 | ||
|
|
02ccd9d134 | ||
|
|
c280b19568 | ||
|
|
bb5065410e | ||
|
|
0353215b1d | ||
|
|
b94e21331d | ||
|
|
637a6f2f68 | ||
|
|
ae9ba333a0 | ||
|
|
6dbc8399df | ||
|
|
3c7ed03bd6 | ||
|
|
24cdb85d71 | ||
|
|
a1f8ecb7f0 | ||
|
|
a34ca5a137 | ||
|
|
7887b9473f | ||
|
|
fd3c82aa91 | ||
|
|
9266632694 | ||
|
|
b0cbcd7a04 | ||
|
|
6251117bd0 | ||
|
|
566803c431 | ||
|
|
d6848da60b | ||
|
|
5f5d7ee21e | ||
|
|
53169a41af | ||
|
|
e90d5bac68 | ||
|
|
d725bdbaa3 | ||
|
|
d75b744a35 | ||
|
|
9aea8c0b5c | ||
|
|
7134f33e1d | ||
|
|
f54b964d62 | ||
|
|
88f7ea5679 | ||
|
|
11fbeb4851 | ||
|
|
2b1f38edae | ||
|
|
2f3797c1f6 | ||
|
|
c0b562cc07 | ||
|
|
37fbb110e3 | ||
|
|
7564496ea0 | ||
|
|
a0b0d98180 | ||
|
|
990c85d36b | ||
|
|
b742343937 | ||
|
|
657df05d40 | ||
|
|
86f2b14449 | ||
|
|
86014c97cf | ||
|
|
70d58648c8 | ||
|
|
94f3bba504 | ||
|
|
339eaa4b1b | ||
|
|
1f77ba31f3 | ||
|
|
eb0bd69b82 | ||
|
|
665b79d8da | ||
|
|
32e7a80889 | ||
|
|
5e6b588b5e | ||
|
|
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 | ||
|
|
2ea5acc2a3 | ||
|
|
5e1bac51e5 | ||
|
|
f1e95d763d | ||
|
|
9140015c42 | ||
|
|
de418c5696 | ||
|
|
5ec5b9811c | ||
|
|
378eaa377e | ||
|
|
d4368be1c3 | ||
|
|
9733acfb5a | ||
|
|
b1d422dcfd | ||
|
|
1231e500a3 | ||
|
|
c508968814 | ||
|
|
65ce71b8d4 | ||
|
|
6bfcac4d54 | ||
|
|
f819566c63 | ||
|
|
10d9aa3058 | ||
|
|
a0ca1fdb28 | ||
|
|
10943629d6 | ||
|
|
5a93f80daa | ||
|
|
8260cf7f53 | ||
|
|
9fd38d9b9d | ||
|
|
58dfbc5b6e | ||
|
|
9fd9ae7a95 | ||
|
|
5b0198b2ec | ||
|
|
dc854acb9b | ||
|
|
d054e3dc01 | ||
|
|
95536c3e97 | ||
|
|
1bfedbd4d2 | ||
|
|
cfaf161fc7 | ||
|
|
703406df13 | ||
|
|
753d538140 | ||
|
|
f890dda7e7 | ||
|
|
c870d2ebdc |
@@ -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;
|
||||
});
|
||||
+35
-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
|
||||
@@ -1366,29 +1371,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
|
||||
@@ -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() {
|
||||
|
||||
@@ -464,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
|
||||
@@ -479,8 +607,9 @@ path; a generator unit test or JSON count alone is insufficient.
|
||||
a complete compatibility baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A compact leaf defines Agent selection, CLI parameters,
|
||||
constraints, and safety/confirmation semantics. Full leaf fields such as
|
||||
`property`, `interface_ref`, and provenance are audit facts. A conflict is
|
||||
contract drift, not permission to guess.
|
||||
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.
|
||||
|
||||
+196
@@ -6,6 +6,200 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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.4` baseline.
|
||||
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
|
||||
and compatibility improvements, plus the multi-skill framework alignment and
|
||||
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
|
||||
the final prerelease.
|
||||
|
||||
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
|
||||
baseline as stable `v1.0.57` without adding post-beta product changes.
|
||||
|
||||
## [1.0.57-beta.3] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Reviewed document shortcuts** (#880) — adds public document shortcuts for
|
||||
safe local downloads, content and history, review, media and style, and
|
||||
document access/sharing workflows, while retaining reviewed compatibility
|
||||
identities and confirmation safeguards for writes.
|
||||
- **Mentions in chat replies** (#881) — `chat message reply` now supports
|
||||
`--at-open-dingtalk-ids` and `--at-all`, forwarding reply mention fields and
|
||||
adding any required mention placeholders without changing existing send
|
||||
behavior.
|
||||
|
||||
## [1.0.57-beta.2] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
@@ -69,6 +263,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
|
||||
|
||||
@@ -81,6 +276,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.
|
||||
|
||||
+11
-3
@@ -68,9 +68,17 @@ 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
|
||||
|
||||
@@ -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.2"
|
||||
version "1.0.58-beta.3"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "2119754d4c6f6be2b4856ab559ad44ac582a3b3abc76ff907927f62c7a4a3d29"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a453341d6df1a78b7d74bd624842503d857a41f73fa1ac36394e4594e4961e8d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
|
||||
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.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "734df2c7f34ca36aa48151fda2b18e1c2c90fe812fb5ab13e8c00e074cca43af"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "f602a63ab6afd2e24db7b7dabfddb0cdcf3a7bd55b0cc60a99013bac5cacc56f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-skills.zip"
|
||||
sha256 "486f5ef30a88a293c14df1ff0768760284179993c51f898fa2bee2c9391d8607"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
|
||||
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
|
||||
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"
|
||||
@@ -88,8 +88,8 @@ policy: test-auth-legacy-compat
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-commands.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
|
||||
@@ -103,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
|
||||
@@ -121,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
|
||||
@@ -132,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
|
||||
|
||||
@@ -149,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; \
|
||||
@@ -167,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; \
|
||||
|
||||
@@ -71,9 +71,7 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -394,7 +392,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -443,7 +441,6 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
|
||||
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
|
||||
<details>
|
||||
@@ -474,7 +471,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog 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 +481,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 +515,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
|
||||
|
||||
+27
-20
@@ -71,9 +71,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -388,7 +386,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
@@ -437,7 +435,6 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
||||
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
|
||||
<details>
|
||||
@@ -468,7 +465,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 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 +475,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 +509,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+39
-8
@@ -157,15 +157,33 @@ expected to repeat every CI job locally:
|
||||
```sh
|
||||
make build
|
||||
make policy
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
make mock-mcp-smoke
|
||||
go test -v -count=1 ./pkg/editiontest/...
|
||||
```
|
||||
|
||||
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
|
||||
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
|
||||
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
|
||||
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
|
||||
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
|
||||
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
|
||||
candidate SHA。
|
||||
|
||||
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
|
||||
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
|
||||
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
|
||||
flag 迁移。
|
||||
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
|
||||
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
|
||||
For an exact CHANGELOG-only branch:
|
||||
|
||||
```sh
|
||||
@@ -186,11 +204,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。当前清单为空,因此本治理 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`。
|
||||
|
||||
|
||||
@@ -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
@@ -6,7 +6,7 @@
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
|
||||
@@ -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
|
||||
+1401
-115
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
# DWS Skill 内容框架合同
|
||||
|
||||
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
|
||||
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
|
||||
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
|
||||
|
||||
## 1. 两棵内容树
|
||||
|
||||
| 树 | 路径 | 角色 |
|
||||
|---|---|---|
|
||||
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
|
||||
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
|
||||
|
||||
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
|
||||
|
||||
## 2. Multi 目录合同(如何新增一个产品 skill)
|
||||
|
||||
新建 `skills/multi/<name>/` 时必须满足:
|
||||
|
||||
1. **命名**
|
||||
- 产品 skill:`dingtalk-<product>`(小写、连字符)
|
||||
- 共享 skill:仅允许 `dingtalk-shared`
|
||||
2. **根文件**
|
||||
- 必有 `SKILL.md`(YAML frontmatter + 正文)
|
||||
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
|
||||
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
|
||||
3. **Frontmatter 最小集**(产品 / shared)
|
||||
- `name`:与目录名一致
|
||||
- `description`:非空,含触发意图与边界
|
||||
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
|
||||
- `metadata.requires.bins`:含 `dws`
|
||||
4. **契约块**
|
||||
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
|
||||
- `dingtalk-shared` 承载跨产品路由与全局协议落点
|
||||
5. **与 mono 映射**
|
||||
- 每个 mono `references/products/<stem>`(文件或目录)必须在
|
||||
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
|
||||
|
||||
### 2.1 推荐骨架
|
||||
|
||||
```text
|
||||
skills/multi/dingtalk-example/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── example.md # 主产品面
|
||||
│ └── … # 子章节 / 意图表
|
||||
└── scripts/ # 可选;须被 md 引用
|
||||
└── example_helper.py
|
||||
```
|
||||
|
||||
## 3. Mono 目录合同(质检对照基准)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/ # 覆盖质检主源
|
||||
│ ├── error-codes.md # 全局协议示例
|
||||
│ ├── error-codes.md
|
||||
│ └── …
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
|
||||
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
|
||||
|
||||
## 4. 共享内容(`dingtalk-shared`)
|
||||
|
||||
| 职责 | 落点 |
|
||||
|---|---|
|
||||
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
|
||||
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
|
||||
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
|
||||
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
|
||||
|
||||
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
|
||||
|
||||
## 5. Zip 内容布局合同(形状,非安装默认)
|
||||
|
||||
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
|
||||
|
||||
| Zip 路径 | 含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 内容副本(兼容旧面) |
|
||||
| `<root>/mono/` | 与 `skills/mono/` 同构 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言源树形状;**不**断言安装器默认解压哪棵。
|
||||
|
||||
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
|
||||
|
||||
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
|
||||
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
|
||||
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
|
||||
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
|
||||
|
||||
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
|
||||
|
||||
## 7. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
|
||||
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
|
||||
@@ -0,0 +1,76 @@
|
||||
# Mono↔Multi Skill 内容质检规格
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
| ID | 类型 | 输入 | 通过准则 |
|
||||
|---|---|---|---|
|
||||
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
|
||||
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
|
||||
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
|
||||
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
|
||||
|
||||
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
|
||||
|
||||
## 2. 有意省略 / 延期登记格式
|
||||
|
||||
YAML(见 coverage 文件):
|
||||
|
||||
```yaml
|
||||
omit_coverage:
|
||||
- mono: simple
|
||||
disposition: covered_by # covered_by | defer | wontfix
|
||||
via: dingtalk-misc # optional
|
||||
reason: "拆入 oa/devdoc…"
|
||||
|
||||
omit_global:
|
||||
- id: field-rules-global
|
||||
mono_path: references/field-rules.md
|
||||
expected_multi: dingtalk-aitable/references/field-rules.md
|
||||
disposition: covered_by
|
||||
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
|
||||
|
||||
orphan_scripts_allowlist:
|
||||
- path: dingtalk-misc/scripts/report_received_today.py
|
||||
disposition: defer
|
||||
reason: "pending report.md reference"
|
||||
|
||||
paired_files:
|
||||
- mono: references/products/sheet.md
|
||||
multi: dingtalk-misc/references/sheet.md
|
||||
mode: link-normalized
|
||||
link_substitutions:
|
||||
- mono: "../url-patterns.md"
|
||||
multi: "../../dingtalk-shared/references/url-patterns.md"
|
||||
- mono: "../intent-guide.md"
|
||||
multi: "sheet-intent-guide.md"
|
||||
```
|
||||
|
||||
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
|
||||
|
||||
## 3. 缺口基线(相对 mono)
|
||||
|
||||
| ID | 项 | disposition | 说明 |
|
||||
|---|---|---|---|
|
||||
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
|
||||
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
|
||||
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
|
||||
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
|
||||
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
|
||||
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
|
||||
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
|
||||
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
|
||||
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
|
||||
| X4 | event 缺 metadata | **done** | |
|
||||
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
|
||||
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
|
||||
|
||||
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
|
||||
|
||||
## 4. 与悟空
|
||||
|
||||
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
|
||||
@@ -0,0 +1,272 @@
|
||||
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
|
||||
|
||||
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
|
||||
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
|
||||
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
|
||||
>
|
||||
> 对照仓:
|
||||
>
|
||||
> | 仓 | 路径 | 基线 |
|
||||
> |---|---|---|
|
||||
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
|
||||
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
|
||||
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
|
||||
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
|
||||
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
|
||||
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
|
||||
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
|
||||
|
||||
### 0.1 IN SCOPE
|
||||
|
||||
| 类别 | 包含 |
|
||||
|---|---|
|
||||
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
|
||||
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
|
||||
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
|
||||
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
|
||||
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
|
||||
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
|
||||
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
|
||||
|
||||
### 0.2 OUT OF SCOPE
|
||||
|
||||
| 类别 | 去向 |
|
||||
|---|---|
|
||||
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
|
||||
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
|
||||
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
|
||||
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
|
||||
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
|
||||
| 把质检做成「改安装默认值」的后门 | 拒绝 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 内容现状盘点
|
||||
|
||||
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
|
||||
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
|
||||
│ └── best_practices/…
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
### 1.2 DWS `skills/multi`(内容主体)
|
||||
|
||||
```text
|
||||
skills/multi/
|
||||
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
|
||||
└── dingtalk-*/ # 19 产品 + 各 references、scripts
|
||||
```
|
||||
|
||||
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
|
||||
|
||||
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
|
||||
|
||||
### 1.4 Zip 内容布局合同
|
||||
|
||||
| Zip 路径 | 内容含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 副本(兼容) |
|
||||
| `<root>/mono/` | 显式 mono 内容源 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
|
||||
|
||||
### 1.5 现有 DWS skill 内容质检资产(复用清单)
|
||||
|
||||
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|
||||
|---|---|---|
|
||||
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
|
||||
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
|
||||
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
|
||||
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
|
||||
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
|
||||
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
|
||||
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
|
||||
|
||||
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
|
||||
|
||||
### 1.6 悟空侧类比质检
|
||||
|
||||
| 悟空 | 说明 | 本分支 |
|
||||
|---|---|---|
|
||||
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
|
||||
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
|
||||
|
||||
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
|
||||
|
||||
---
|
||||
|
||||
## 2. Diff(内容组织 + 质检视角)
|
||||
|
||||
### 2.1 已同构
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
|
||||
|
||||
### 2.2 分叉与已知内容风险(质检要盯的)
|
||||
|
||||
| 风险 ID | 现象(线索) | 质检类型 |
|
||||
|---|---|---|
|
||||
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
|
||||
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
|
||||
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
|
||||
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
|
||||
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
|
||||
|
||||
### 2.3 Reject
|
||||
|
||||
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
|
||||
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
|
||||
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
|
||||
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
|
||||
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
|
||||
|
||||
### 3.2 Non-goals
|
||||
|
||||
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
|
||||
|
||||
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
|
||||
|
||||
### Phase 0 — 方案冻结(本文)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 本文件;§7(含质检轨)勾选 |
|
||||
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
|
||||
|
||||
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
|
||||
| **触达** | `docs/skill-content-framework.md` |
|
||||
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
|
||||
|
||||
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
|
||||
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
|
||||
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
|
||||
|
||||
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | G1–G4 自动门禁 |
|
||||
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
|
||||
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
|
||||
|
||||
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
|
||||
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
|
||||
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
|
||||
|
||||
### 延期登记(非本分支)
|
||||
|
||||
| 主题 | 载体 |
|
||||
|---|---|
|
||||
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
|
||||
| skillhome / 安装面 bootstrap | 行为分支 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Port / Adapt / Reject
|
||||
|
||||
| 项 | 决策 | 说明 |
|
||||
|---|---|---|
|
||||
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
|
||||
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
|
||||
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
|
||||
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
|
||||
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
|
||||
| 行为 cherry-pick | **Defer** | 另分支 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 `402429ac` / `d5c8982c`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 本分支 cherry-pick? | **否** |
|
||||
| 质检是否替代行为翻转? | **否** |
|
||||
| 行为分支 | 另开;可与内容/质检并行 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 批准清单(请重新勾选)
|
||||
|
||||
**范围**
|
||||
|
||||
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
|
||||
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
|
||||
- [x] 取消产品与悟空客户端链路仍拒绝
|
||||
|
||||
**内容框架 Phase**
|
||||
|
||||
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
|
||||
|
||||
**质检轨 Phase**
|
||||
|
||||
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
|
||||
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
|
||||
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
|
||||
|
||||
**可选**
|
||||
|
||||
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
|
||||
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
|
||||
|
||||
**Follow-up 知悉**
|
||||
|
||||
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
|
||||
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
|
||||
安装默认 multi 等行为仍走 **另一分支**。
|
||||
|
||||
---
|
||||
|
||||
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
|
||||
@@ -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。
|
||||
@@ -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() != "" {
|
||||
@@ -358,11 +352,16 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConfigCommandRemainingCoverage(t *testing.T) {
|
||||
command := newConfigCommand()
|
||||
command.SetOut(io.Discard)
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", "configured")
|
||||
configCmd := &cobra.Command{Use: "config"}
|
||||
@@ -380,4 +379,26 @@ func TestCrossPlatformCoverageConfigCommandRemainingCoverage(t *testing.T) {
|
||||
if err := runConfigList(list, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cacheRoot := &cobra.Command{Use: "root"}
|
||||
cacheRoot.PersistentFlags().String("format", "", "")
|
||||
cacheCmd := &cobra.Command{Use: "cache"}
|
||||
cacheRoot.AddCommand(cacheCmd)
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
_ = cacheRoot.PersistentFlags().Set("format", format)
|
||||
cacheCmd.SetOut(io.Discard)
|
||||
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, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("pretty write error = %v", err)
|
||||
}
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "table")
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("table write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/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"`
|
||||
Message string `json:"message"`
|
||||
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: "不再支持:服务发现缓存兼容入口",
|
||||
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return printCacheCompatNotice(cmd, "dws cache")
|
||||
},
|
||||
}
|
||||
for _, name := range []string{"refresh", "status", "clean"} {
|
||||
subName := name
|
||||
sub := &cobra.Command{
|
||||
Use: subName,
|
||||
Short: "不再支持:静态端点模式无需服务发现缓存",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return printCacheCompatNotice(cmd, "dws cache "+subName)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
notice := cacheCompatNotice{
|
||||
Status: "deprecated",
|
||||
Command: command,
|
||||
Message: cacheUnsupportedMessage,
|
||||
Replacement: cacheReplacementHint,
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "", "json":
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||
case "pretty":
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
_, 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)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -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,7 +32,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
@@ -227,115 +226,6 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
|
||||
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
|
||||
t.Fatalf("array attempts = %#v %v", got, err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
|
||||
t.Fatal("malformed attempts succeeded")
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
|
||||
t.Fatalf("legacy attempts = %#v %v", got, err)
|
||||
}
|
||||
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
|
||||
t.Fatal("legacy attempts edge mismatch")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"dws", "--debug", "doc", "get", "--node", "n"},
|
||||
{"dws", "--format=json", "doc", "--", "ignored"},
|
||||
{"dws", "--unknown", "value", "doc"},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = args
|
||||
_ = currentCommandPath()
|
||||
os.Args = old
|
||||
}
|
||||
for _, inv := range []executor.Invocation{
|
||||
{LegacyPath: "legacy path"},
|
||||
{CanonicalProduct: "doc", Tool: "get"},
|
||||
{CanonicalProduct: "doc"},
|
||||
{},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = []string{"dws"}
|
||||
_ = runtimeCommandPath(inv)
|
||||
os.Args = old
|
||||
}
|
||||
if cloneRecoveryArgs(nil) != nil {
|
||||
t.Fatal("empty recovery args should clone to nil")
|
||||
}
|
||||
original := map[string]any{"x": 1}
|
||||
clone := cloneRecoveryArgs(original)
|
||||
clone["x"] = 2
|
||||
if original["x"] != 1 {
|
||||
t.Fatal("recovery args were not cloned")
|
||||
}
|
||||
|
||||
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("nil recovery search = %#v", got)
|
||||
}
|
||||
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("blank recovery search = %#v", got)
|
||||
}
|
||||
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
|
||||
t.Fatal("nil recovery runtime call succeeded")
|
||||
}
|
||||
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !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 +401,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
_ = defaultPATMCPEndpoint()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
|
||||
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing recovery execution succeeded")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("malformed recovery execution succeeded")
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
|
||||
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
|
||||
t.Fatalf("loaded execution = %#v %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
|
||||
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
|
||||
}))
|
||||
defer server.Close()
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
|
||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
|
||||
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
|
||||
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
|
||||
t.Fatalf("recovery search = %#v %v", got, err)
|
||||
}
|
||||
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery MCP error succeeded")
|
||||
}
|
||||
server.Close()
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery network error succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
@@ -732,7 +569,7 @@ func TestCrossPlatformCoverageEventCommandValidationCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageVersionCompletionCoverage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
|
||||
oldVersion, oldBuild, oldCommit := version, buildTime, gitCommit
|
||||
t.Cleanup(func() { version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit })
|
||||
version, buildTime, gitCommit = "1.0", "unknown", "unknown"
|
||||
@@ -753,6 +590,25 @@ func TestCrossPlatformCoverageVersionCompletionCoverage(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
_ = root.PersistentFlags().Set("format", format)
|
||||
child := &cobra.Command{Use: "child"}
|
||||
root.AddCommand(child)
|
||||
child.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
|
||||
t.Fatalf("cache %s: %v", format, err)
|
||||
}
|
||||
root.RemoveCommand(child)
|
||||
}
|
||||
cache := newCacheCommand()
|
||||
cache.SetOut(io.Discard)
|
||||
cache.SetArgs([]string{"status"})
|
||||
if err := cache.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
completionRoot := &cobra.Command{Use: "dws"}
|
||||
completionRoot.AddCommand(&cobra.Command{Use: "child"})
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
@@ -2146,7 +2002,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
|
||||
multi := filepath.Join(t.TempDir(), "multi")
|
||||
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
dir := filepath.Join(multi, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2171,10 +2027,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
|
||||
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
|
||||
t.Fatalf("multi setup = %q, %v", output, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
|
||||
@@ -2198,7 +2054,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
|
||||
for _, tc := range []struct {
|
||||
include []string
|
||||
exclude []string
|
||||
@@ -2216,7 +2072,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
|
||||
}
|
||||
}
|
||||
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
|
||||
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
|
||||
_ = ensureMandatorySharedSkill(selected, all)
|
||||
}
|
||||
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
|
||||
|
||||
@@ -300,8 +300,8 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
_ = newCompletionCommand(dedupRoot)
|
||||
_ = newCatalogCommand()
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(&GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
+518
-39
@@ -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.
|
||||
@@ -442,19 +694,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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -473,13 +743,14 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
newProfileCommand(),
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
newCatalogCommand(),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newEventCommand(),
|
||||
newRecoveryCommand(),
|
||||
newEventCommand(flags),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
@@ -721,10 +992,10 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
// hideNonDirectRuntimeCommands) and reservedCommands (the plugin-override
|
||||
// blocklist) derive from this single set so they cannot drift apart.
|
||||
var builtinCommandNames = map[string]bool{
|
||||
"auth": true, "api": true, "audit": true, "config": true,
|
||||
"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.
|
||||
@@ -853,6 +1124,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
|
||||
@@ -865,32 +1184,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 == "" {
|
||||
@@ -1332,6 +1799,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,10 +419,14 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheCommandRemoved(t *testing.T) {
|
||||
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
if cmd, _, err := root.Find([]string{"cache"}); err == nil && cmd != nil && cmd != root {
|
||||
t.Fatalf("dws cache compatibility stub must be removed, found %q", cmd.CommandPath())
|
||||
cmd, _, err := root.Find([]string{"cache", "refresh"})
|
||||
if err != nil || cmd == nil || cmd == root {
|
||||
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
|
||||
}
|
||||
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 = 266
|
||||
publicShortcutCount = 378
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 267
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 379
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 266
|
||||
publiclyDeliveredShortcutCount = 378
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -194,17 +394,9 @@ func assertDeliveryShortcutSafetyAndInterface(
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
risk := declared.Risk
|
||||
if risk == "" {
|
||||
risk = shortcut.RiskRead
|
||||
}
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
|
||||
switch risk {
|
||||
case shortcut.RiskWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
|
||||
case shortcut.RiskHighWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
|
||||
}
|
||||
safety := shortcut.EffectiveSafety(declared)
|
||||
wantEffect, wantRisk := safety.Effect, safety.Risk
|
||||
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
|
||||
for field, want := range map[string]string{
|
||||
"effect": wantEffect,
|
||||
"risk": wantRisk,
|
||||
@@ -234,6 +426,15 @@ func assertDeliveryShortcutParameters(
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
publicFlags = append(publicFlags, flag)
|
||||
if flag.AliasesVisible {
|
||||
for _, alias := range flag.Aliases {
|
||||
aliasFlag := flag
|
||||
aliasFlag.Name = alias
|
||||
aliasFlag.Default = ""
|
||||
aliasFlag.Aliases = nil
|
||||
publicFlags = append(publicFlags, aliasFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if got, want := len(parameters), len(publicFlags); got != want {
|
||||
@@ -299,6 +500,13 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
|
||||
if flag.Name == flagName && flag.Required {
|
||||
return true
|
||||
}
|
||||
if flag.Required && flag.AliasesVisible {
|
||||
for _, alias := range flag.Aliases {
|
||||
if alias == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public := make(map[string]bool, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
|
||||
@@ -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") }
|
||||
+584
-21
@@ -51,6 +51,8 @@ var (
|
||||
skillSetupInstallMono = installSkillToHomes
|
||||
skillSetupInstallMulti = installMultiSkillToHomes
|
||||
skillSetupCopyDir = copyDir
|
||||
skillSetupMkdirTemp = os.MkdirTemp
|
||||
skillSetupRename = os.Rename
|
||||
skillSetupRunForm = (*huh.Form).Run
|
||||
skillSetupInteractive = isInteractiveTerminal
|
||||
skillSetupReadDir = os.ReadDir
|
||||
@@ -75,10 +77,8 @@ func newSkillSetupCommand() *cobra.Command {
|
||||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||||
|
||||
支持两种模式:
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
||||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
||||
生产前请评估,问题请提 issue 反馈
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 多 skill—— 按产品拆 N 个独立 skill
|
||||
|
||||
multi 模式支持按产品挑选:
|
||||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||||
@@ -138,6 +138,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
|
||||
var multiSkillNames []string
|
||||
var foldedEventMiscTargets []string
|
||||
var migrateEventMiscTargets []string
|
||||
var installsEventMiscCompanion bool
|
||||
if mode == skillSetupModeMulti {
|
||||
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
|
||||
if listErr != nil {
|
||||
@@ -150,9 +153,33 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
if filterErr != nil {
|
||||
return filterErr
|
||||
}
|
||||
// dws-shared carries the global rules every product skill declares as a
|
||||
// dingtalk-shared carries the global rules every product skill declares as a
|
||||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
|
||||
foldedEventMiscTargets = findFoldedEventMiscTargets(dests)
|
||||
if len(foldedEventMiscTargets) > 0 {
|
||||
hasEvent := containsSkillName(multiSkillNames, multiEventSkill)
|
||||
hasMisc := containsSkillName(multiSkillNames, multiMiscSkill)
|
||||
switch {
|
||||
case normalizedSkillListContains(excludeRaw, multiEventSkill):
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能显式 --exclude event,请先完成 dingtalk-event 迁移")
|
||||
case hasMisc && !hasEvent:
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能只覆盖 dingtalk-misc,必须同时迁移 dingtalk-event")
|
||||
case hasEvent:
|
||||
if normalizedSkillListContains(excludeRaw, multiMiscSkill) {
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;本次安装 dingtalk-event 必须同时迁移 dingtalk-misc,不能显式 --exclude misc")
|
||||
}
|
||||
if !containsSkillName(allMultiSkillNames, multiMiscSkill) {
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由,但当前 multi 源缺少迁移所需的 %s", multiMiscSkill)
|
||||
}
|
||||
if err := validateEventMiscMigrationSource(skillSrc); err != nil {
|
||||
return err
|
||||
}
|
||||
migrateEventMiscTargets = append(migrateEventMiscTargets, foldedEventMiscTargets...)
|
||||
installsEventMiscCompanion = !hasMisc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
|
||||
@@ -164,11 +191,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
|
||||
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
|
||||
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
if mode == skillSetupModeMulti {
|
||||
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
|
||||
}
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -177,8 +208,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintln(out, "已取消。")
|
||||
return nil
|
||||
}
|
||||
} else if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
||||
}
|
||||
|
||||
var installed, skipped int
|
||||
@@ -186,7 +215,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
case skillSetupModeMono:
|
||||
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
|
||||
case skillSetupModeMulti:
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
|
||||
installed, skipped, err = installMultiSkillsWithEventMigration(
|
||||
skillSrc,
|
||||
multiSkillNames,
|
||||
dests,
|
||||
migrateEventMiscTargets,
|
||||
out,
|
||||
errOut,
|
||||
)
|
||||
default:
|
||||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||||
}
|
||||
@@ -195,6 +231,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
|
||||
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -205,8 +242,228 @@ const multiSkillPrefix = "dingtalk-"
|
||||
// multiSharedSkill is the shared, non-product skill that every per-product
|
||||
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
||||
// regardless of --skill / --exclude, otherwise the product skills reference a
|
||||
// dws-shared that was never installed.
|
||||
const multiSharedSkill = "dws-shared"
|
||||
// dingtalk-shared that was never installed.
|
||||
const multiSharedSkill = "dingtalk-shared"
|
||||
|
||||
// legacyMultiSharedSkill is the retired name shipped by older multi-skill
|
||||
// bundles. Once the replacement has been installed successfully, remove this
|
||||
// exact directory so Agent discovery cannot load both routing contracts.
|
||||
const legacyMultiSharedSkill = "dws-shared"
|
||||
|
||||
const (
|
||||
multiEventSkill = "dingtalk-event"
|
||||
multiMiscSkill = "dingtalk-misc"
|
||||
)
|
||||
|
||||
var eventMigrationRequiredReferences = []string{
|
||||
"event-im.md",
|
||||
"event-im-keys.md",
|
||||
"event-im-lifecycle.md",
|
||||
"event-im-operations.md",
|
||||
"event-im-output.md",
|
||||
"event-oa.md",
|
||||
}
|
||||
|
||||
func containsSkillName(names []string, want string) bool {
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizedSkillListContains(raw []string, want string) bool {
|
||||
for _, name := range raw {
|
||||
if normalizeMultiSkillName(name) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findFoldedEventMiscTargets identifies the short-lived multi-skill layout in
|
||||
// which personal Event routing lived inside dingtalk-misc. Both markers are
|
||||
// required so an unrelated misc install is never treated as a migration target.
|
||||
func findFoldedEventMiscTargets(dests []string) []string {
|
||||
var targets []string
|
||||
for _, dest := range dests {
|
||||
miscRoot := filepath.Join(dest, multiMiscSkill)
|
||||
skillBody, err := os.ReadFile(filepath.Join(miscRoot, "SKILL.md"))
|
||||
if err != nil || !containsPersonalEventRoute(skillBody) {
|
||||
continue
|
||||
}
|
||||
eventRef, err := skillSetupStat(filepath.Join(miscRoot, "references", "event.md"))
|
||||
if err != nil || eventRef.IsDir() {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, dest)
|
||||
}
|
||||
sort.Strings(targets)
|
||||
return targets
|
||||
}
|
||||
|
||||
func containsPersonalEventRoute(skillBody []byte) bool {
|
||||
body := strings.ToLower(string(skillBody))
|
||||
for _, marker := range []string{
|
||||
"dws event",
|
||||
"个人 event",
|
||||
"个人 im 事件",
|
||||
"个人 im/oa",
|
||||
"personal event",
|
||||
} {
|
||||
if strings.Contains(body, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func printEventMiscMigrationPreview(out io.Writer, targets []string, installsCompanion bool) {
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
action := "将原子切换 dingtalk-event 与本次已选择的干净 dingtalk-misc"
|
||||
if installsCompanion {
|
||||
action = "将原子切换 dingtalk-event,并额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)"
|
||||
}
|
||||
fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action)
|
||||
for _, target := range targets {
|
||||
fmt.Fprintf(out, " - %s\n", target)
|
||||
}
|
||||
}
|
||||
|
||||
func validateEventMiscMigrationSource(src string) error {
|
||||
if err := validateEventMigrationSkillRoot(filepath.Join(src, multiEventSkill)); err != nil {
|
||||
return fmt.Errorf("event Skill 迁移源无效: %w", err)
|
||||
}
|
||||
if err := validateMigrationSkillRoot(filepath.Join(src, multiMiscSkill), multiMiscSkill, nil); err != nil {
|
||||
return fmt.Errorf("event Skill 迁移源无效: %w", err)
|
||||
}
|
||||
|
||||
if err := validateCleanEventMiscRoot(filepath.Join(src, multiMiscSkill)); err != nil {
|
||||
return fmt.Errorf("event Skill 迁移源无效: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEventMigrationSkillRoot(root string) error {
|
||||
required := make([]string, 0, len(eventMigrationRequiredReferences))
|
||||
for _, name := range eventMigrationRequiredReferences {
|
||||
required = append(required, filepath.Join("references", name))
|
||||
}
|
||||
return validateMigrationSkillRoot(root, multiEventSkill, required)
|
||||
}
|
||||
|
||||
func validateMigrationSkillRoot(root, expectedName string, requiredFiles []string) error {
|
||||
skillPath := filepath.Join(root, "SKILL.md")
|
||||
skillBody, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法读取 %s: %w", skillPath, err)
|
||||
}
|
||||
name, err := parseMigrationSkillFrontmatter(skillBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 无效: %w", skillPath, err)
|
||||
}
|
||||
if name != expectedName {
|
||||
return fmt.Errorf("%s 的 name=%q,期望 %q", skillPath, name, expectedName)
|
||||
}
|
||||
for _, rel := range requiredFiles {
|
||||
path := filepath.Join(root, rel)
|
||||
info, statErr := skillSetupStat(path)
|
||||
if statErr != nil || info.IsDir() {
|
||||
if statErr == nil {
|
||||
statErr = errors.New("is a directory")
|
||||
}
|
||||
return fmt.Errorf("缺少有效文件 %s: %w", path, statErr)
|
||||
}
|
||||
body, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("无法读取 %s: %w", path, readErr)
|
||||
}
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
return fmt.Errorf("文件为空 %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMigrationSkillFrontmatter(body []byte) (string, error) {
|
||||
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
|
||||
lines := strings.Split(normalized, "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return "", errors.New("缺少 YAML frontmatter")
|
||||
}
|
||||
name := ""
|
||||
description := ""
|
||||
closingLine := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
rawLine := lines[i]
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "---" {
|
||||
closingLine = i
|
||||
break
|
||||
}
|
||||
// Only inspect top-level frontmatter keys. Nested metadata may legally
|
||||
// contain its own `name` without changing the Skill identity.
|
||||
if strings.TrimLeft(rawLine, " \t") != rawLine {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.Trim(strings.TrimSpace(value), "\"'")
|
||||
switch strings.TrimSpace(key) {
|
||||
case "name":
|
||||
if name != "" {
|
||||
return "", errors.New("frontmatter 含重复 name")
|
||||
}
|
||||
name = value
|
||||
case "description":
|
||||
description = value
|
||||
}
|
||||
}
|
||||
if closingLine < 0 {
|
||||
return "", errors.New("YAML frontmatter 未闭合")
|
||||
}
|
||||
if name == "" {
|
||||
return "", errors.New("frontmatter 缺少 name")
|
||||
}
|
||||
if description == "" {
|
||||
return "", errors.New("frontmatter 缺少 description")
|
||||
}
|
||||
if strings.TrimSpace(strings.Join(lines[closingLine+1:], "\n")) == "" {
|
||||
return "", errors.New("SKILL.md 正文为空")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func validateCleanEventMiscRoot(miscRoot string) error {
|
||||
miscSkillPath := filepath.Join(miscRoot, "SKILL.md")
|
||||
miscBody, err := os.ReadFile(miscSkillPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法读取 %s: %w", miscSkillPath, err)
|
||||
}
|
||||
if containsPersonalEventRoute(miscBody) {
|
||||
return fmt.Errorf("%s 仍包含个人 Event 路由", miscSkillPath)
|
||||
}
|
||||
refsRoot := filepath.Join(miscRoot, "references")
|
||||
entries, err := skillSetupReadDir(refsRoot)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法检查 %s: %w", refsRoot, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := strings.ToLower(entry.Name())
|
||||
if !entry.IsDir() && strings.HasPrefix(name, "event") && strings.HasSuffix(name, ".md") {
|
||||
return fmt.Errorf("%s 仍存在折叠 Event 参考页", filepath.Join(refsRoot, entry.Name()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||||
@@ -365,10 +622,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("选择 dws skill 安装模式").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
|
||||
Options(
|
||||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
||||
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
@@ -526,15 +783,6 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
}
|
||||
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
|
||||
}
|
||||
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
||||
@@ -620,6 +868,21 @@ func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupLegacyMultiSharedSkill(dest string, out, errOut io.Writer) {
|
||||
legacyPath := filepath.Join(dest, legacyMultiSharedSkill)
|
||||
if _, err := skillSetupStat(legacyPath); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Fprintf(errOut, " ⚠️ 无法检查已退役 Skill 残留 %s: %v\n", legacyPath, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := skillSetupRemoveAll(legacyPath); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 已退役 Skill 清理失败(已安装 %s) %s: %v\n", multiSharedSkill, legacyPath, err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " × 已清理已退役 Skill 残留 %s\n", legacyPath)
|
||||
}
|
||||
|
||||
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
sort.Strings(dests)
|
||||
for _, dest := range dests {
|
||||
@@ -647,6 +910,299 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
func installMultiSkillsWithEventMigration(
|
||||
src string,
|
||||
skillNames []string,
|
||||
dests []string,
|
||||
migrationTargets []string,
|
||||
out, errOut io.Writer,
|
||||
) (installed, skipped int, err error) {
|
||||
if len(migrationTargets) == 0 {
|
||||
return skillSetupInstallMulti(src, skillNames, dests, out, errOut)
|
||||
}
|
||||
|
||||
migrationSet := make(map[string]struct{}, len(migrationTargets))
|
||||
for _, dest := range migrationTargets {
|
||||
migrationSet[dest] = struct{}{}
|
||||
}
|
||||
var ordinaryTargets []string
|
||||
for _, dest := range dests {
|
||||
if _, migrates := migrationSet[dest]; !migrates {
|
||||
ordinaryTargets = append(ordinaryTargets, dest)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ordinaryTargets) > 0 {
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
return installed, skipped, err
|
||||
}
|
||||
if nSkipped > 0 {
|
||||
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
// The folded pair is excluded from the ordinary best-effort installer. All
|
||||
// other selected skills (especially dingtalk-shared) must succeed before the
|
||||
// old Event route is touched.
|
||||
for _, dest := range migrationTargets {
|
||||
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
|
||||
}
|
||||
var prerequisiteNames []string
|
||||
for _, name := range skillNames {
|
||||
if name != multiEventSkill && name != multiMiscSkill {
|
||||
prerequisiteNames = append(prerequisiteNames, name)
|
||||
}
|
||||
}
|
||||
if len(prerequisiteNames) > 0 {
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
return installed, skipped, err
|
||||
}
|
||||
if nSkipped > 0 {
|
||||
return installed, skipped, fmt.Errorf("event Skill 迁移前置安装不完整(skipped=%d);已保留折叠版 Event/misc", nSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
|
||||
installed += migrated
|
||||
if migrationErr != nil {
|
||||
return installed, skipped, migrationErr
|
||||
}
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
type eventMiscMigration struct {
|
||||
dest string
|
||||
|
||||
stageRoot string
|
||||
stagedEvent string
|
||||
stagedMisc string
|
||||
backupEvent string
|
||||
backupMisc string
|
||||
|
||||
eventPath string
|
||||
miscPath string
|
||||
|
||||
eventBackedUp bool
|
||||
miscBackedUp bool
|
||||
newEventEnabled bool
|
||||
newMiscEnabled bool
|
||||
}
|
||||
|
||||
func prepareEventMiscMigration(src, dest string) (*eventMiscMigration, error) {
|
||||
stageRoot, err := skillSetupMkdirTemp(dest, ".dws-event-migration-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法在目标文件系统创建 Event Skill 迁移 staging %s: %w", dest, err)
|
||||
}
|
||||
migration := &eventMiscMigration{
|
||||
dest: dest,
|
||||
stageRoot: stageRoot,
|
||||
stagedEvent: filepath.Join(stageRoot, "new-event"),
|
||||
stagedMisc: filepath.Join(stageRoot, "new-misc"),
|
||||
backupEvent: filepath.Join(stageRoot, "old-event"),
|
||||
backupMisc: filepath.Join(stageRoot, "old-misc"),
|
||||
eventPath: filepath.Join(dest, multiEventSkill),
|
||||
miscPath: filepath.Join(dest, multiMiscSkill),
|
||||
}
|
||||
cleanupOnError := func(cause error) (*eventMiscMigration, error) {
|
||||
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
|
||||
cause = errors.Join(cause, fmt.Errorf("清理 staging %s 失败: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
return nil, cause
|
||||
}
|
||||
|
||||
if err := skillSetupCopyDir(filepath.Join(src, multiEventSkill), migration.stagedEvent); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("预备 dingtalk-event 失败 %s: %w", dest, err))
|
||||
}
|
||||
if err := skillSetupCopyDir(filepath.Join(src, multiMiscSkill), migration.stagedMisc); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("预备 dingtalk-misc 失败 %s: %w", dest, err))
|
||||
}
|
||||
if err := validateEventMigrationSkillRoot(migration.stagedEvent); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedEvent, err))
|
||||
}
|
||||
if err := validateMigrationSkillRoot(migration.stagedMisc, multiMiscSkill, nil); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(migration.stagedMisc); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
|
||||
}
|
||||
return migration, nil
|
||||
}
|
||||
|
||||
func migrateEventMiscAtomically(src string, dests []string, out, errOut io.Writer) (int, error) {
|
||||
sortedDests := append([]string(nil), dests...)
|
||||
sort.Strings(sortedDests)
|
||||
migrations := make([]*eventMiscMigration, 0, len(sortedDests))
|
||||
|
||||
// Stage every target before switching any target. This prevents a source or
|
||||
// copy failure on a later Agent home from leaving earlier homes upgraded.
|
||||
for _, dest := range sortedDests {
|
||||
migration, err := prepareEventMiscMigration(src, dest)
|
||||
if err != nil {
|
||||
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
|
||||
err = errors.Join(err, cleanupErr)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
migrations = append(migrations, migration)
|
||||
}
|
||||
|
||||
committed := make([]*eventMiscMigration, 0, len(migrations))
|
||||
for _, migration := range migrations {
|
||||
if err := commitEventMiscMigration(migration); err != nil {
|
||||
rollbackErr := rollbackEventMiscMigrations(committed)
|
||||
if rollbackErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("已切换目标回滚失败: %w", rollbackErr))
|
||||
}
|
||||
var recoveryRoots []string
|
||||
for _, candidate := range migrations {
|
||||
if eventMiscMigrationNeedsRecovery(candidate) {
|
||||
recoveryRoots = append(recoveryRoots, candidate.stageRoot)
|
||||
}
|
||||
}
|
||||
if len(recoveryRoots) > 0 {
|
||||
err = errors.Join(err, fmt.Errorf("回滚不完整,已保留恢复目录(请勿删除): %s", strings.Join(recoveryRoots, ", ")))
|
||||
}
|
||||
if cleanupErr := cleanupEventMiscStages(migrations, true, errOut); cleanupErr != nil {
|
||||
err = errors.Join(err, cleanupErr)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
committed = append(committed, migration)
|
||||
}
|
||||
|
||||
for _, migration := range migrations {
|
||||
fmt.Fprintf(out, " ✓ %s\n", migration.eventPath)
|
||||
fmt.Fprintf(out, " ✓ %s(Event 原子迁移)\n", migration.miscPath)
|
||||
}
|
||||
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ Event Skill 迁移已完成,但 staging 清理不完整: %v\n", cleanupErr)
|
||||
}
|
||||
return len(migrations) * 2, nil
|
||||
}
|
||||
|
||||
func cleanupEventMiscStages(migrations []*eventMiscMigration, preserveRecovery bool, errOut io.Writer) error {
|
||||
var cleanupErr error
|
||||
for _, migration := range migrations {
|
||||
if preserveRecovery && eventMiscMigrationNeedsRecovery(migration) {
|
||||
fmt.Fprintf(errOut, " ⚠️ 已保留 Event Skill 恢复目录 %s\n", migration.stageRoot)
|
||||
continue
|
||||
}
|
||||
if err := skillSetupRemoveAll(migration.stageRoot); err != nil {
|
||||
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("清理 Event Skill staging %s 失败: %w", migration.stageRoot, err))
|
||||
}
|
||||
}
|
||||
return cleanupErr
|
||||
}
|
||||
|
||||
func eventMiscMigrationNeedsRecovery(migration *eventMiscMigration) bool {
|
||||
return migration.eventBackedUp || migration.miscBackedUp || migration.newEventEnabled || migration.newMiscEnabled
|
||||
}
|
||||
|
||||
func commitEventMiscMigration(migration *eventMiscMigration) error {
|
||||
eventExists, err := skillSetupPathExists(migration.eventPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法检查旧 dingtalk-event %s: %w", migration.dest, err)
|
||||
}
|
||||
miscExists, err := skillSetupPathExists(migration.miscPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法检查旧 dingtalk-misc %s: %w", migration.dest, err)
|
||||
}
|
||||
if !miscExists {
|
||||
return fmt.Errorf("event Skill 迁移中止:折叠版 dingtalk-misc 已不存在 %s", migration.dest)
|
||||
}
|
||||
|
||||
rollbackFailure := func(cause error) error {
|
||||
if rollbackErr := rollbackEventMiscMigration(migration); rollbackErr != nil {
|
||||
return errors.Join(cause, fmt.Errorf("回滚 Event/misc 失败 %s: %w", migration.dest, rollbackErr))
|
||||
}
|
||||
return cause
|
||||
}
|
||||
if eventExists {
|
||||
if err := skillSetupRename(migration.eventPath, migration.backupEvent); err != nil {
|
||||
return fmt.Errorf("备份旧 dingtalk-event 失败 %s: %w", migration.dest, err)
|
||||
}
|
||||
migration.eventBackedUp = true
|
||||
}
|
||||
if err := skillSetupRename(migration.stagedEvent, migration.eventPath); err != nil {
|
||||
return rollbackFailure(fmt.Errorf("切换 dingtalk-event 失败 %s: %w", migration.dest, err))
|
||||
}
|
||||
migration.newEventEnabled = true
|
||||
if err := skillSetupRename(migration.miscPath, migration.backupMisc); err != nil {
|
||||
return rollbackFailure(fmt.Errorf("备份旧 dingtalk-misc 失败 %s: %w", migration.dest, err))
|
||||
}
|
||||
migration.miscBackedUp = true
|
||||
if err := skillSetupRename(migration.stagedMisc, migration.miscPath); err != nil {
|
||||
return rollbackFailure(fmt.Errorf("切换 dingtalk-misc 失败 %s: %w", migration.dest, err))
|
||||
}
|
||||
migration.newMiscEnabled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func rollbackEventMiscMigrations(migrations []*eventMiscMigration) error {
|
||||
var rollbackErr error
|
||||
for i := len(migrations) - 1; i >= 0; i-- {
|
||||
if err := rollbackEventMiscMigration(migrations[i]); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
return rollbackErr
|
||||
}
|
||||
|
||||
func rollbackEventMiscMigration(migration *eventMiscMigration) error {
|
||||
move := func(enabled *bool, from, to, label string) error {
|
||||
if !*enabled {
|
||||
return nil
|
||||
}
|
||||
if err := skillSetupRename(from, to); err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
*enabled = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop at the first rollback failure. In particular, do not remove the
|
||||
// already-working standalone Event while the folded misc route has not been
|
||||
// restored: even an incomplete rollback must leave at least one Event entry
|
||||
// point live and preserve the remaining assets in staging for recovery.
|
||||
steps := []struct {
|
||||
enabled *bool
|
||||
from string
|
||||
to string
|
||||
label string
|
||||
}{
|
||||
{&migration.newMiscEnabled, migration.miscPath, migration.stagedMisc, "移出新 dingtalk-misc"},
|
||||
{&migration.miscBackedUp, migration.backupMisc, migration.miscPath, "恢复旧 dingtalk-misc"},
|
||||
{&migration.newEventEnabled, migration.eventPath, migration.stagedEvent, "移出新 dingtalk-event"},
|
||||
{&migration.eventBackedUp, migration.backupEvent, migration.eventPath, "恢复旧 dingtalk-event"},
|
||||
}
|
||||
for _, step := range steps {
|
||||
if err := move(step.enabled, step.from, step.to, step.label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillSetupPathExists(path string) (bool, error) {
|
||||
_, err := skillSetupStat(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
|
||||
// dest as a sibling skill directory. installed/skipped is counted per
|
||||
// (agent-home × sub-skill) pair so the user sees granular progress.
|
||||
@@ -662,6 +1218,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
continue
|
||||
}
|
||||
|
||||
sharedInstalled := false
|
||||
for _, name := range skillNames {
|
||||
subSrc := filepath.Join(src, name)
|
||||
subDest := filepath.Join(dest, name)
|
||||
@@ -677,6 +1234,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
}
|
||||
fmt.Fprintf(out, " ✓ %s\n", subDest)
|
||||
installed++
|
||||
if name == multiSharedSkill {
|
||||
sharedInstalled = true
|
||||
}
|
||||
}
|
||||
if sharedInstalled {
|
||||
cleanupLegacyMultiSharedSkill(dest, out, errOut)
|
||||
}
|
||||
}
|
||||
return installed, skipped, nil
|
||||
|
||||
@@ -58,8 +58,9 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
|
||||
// contains both the shared routing skill and the PAT product skill. Structured
|
||||
// Schema hints are build inputs and must not become a third installable mode.
|
||||
// contains the standalone Event skill, shared routing skill, and clean misc
|
||||
// (including PAT docs). Structured Schema hints are build inputs and must not
|
||||
// become a third installable mode.
|
||||
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
if err != nil {
|
||||
@@ -71,14 +72,21 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("dws-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "references", "pat.md"),
|
||||
filepath.Join("dingtalk-event", "SKILL.md"),
|
||||
filepath.Join("dingtalk-event", "references", "event-oa.md"),
|
||||
filepath.Join("dingtalk-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "references", "pat.md"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
||||
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "dingtalk-misc", "references", "event.md")); err == nil {
|
||||
t.Fatal("embedded misc must not retain the folded personal Event reference")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat embedded misc event reference: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
|
||||
t.Fatal("embedded multi skill must not contain build-only schema-hints")
|
||||
} else if !os.IsNotExist(err) {
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -73,7 +75,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("empty multi source should fail")
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-shared", "dingtalk-doc"}, nil }
|
||||
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
@@ -120,12 +122,112 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
|
||||
customPath := filepath.Join(home, "custom-skill")
|
||||
for _, path := range []string{legacyPath, customPath} {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("legacy or custom\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
src,
|
||||
[]string{multiSharedSkill, "dingtalk-chat"},
|
||||
[]string{home},
|
||||
&out,
|
||||
&errOut,
|
||||
)
|
||||
if err != nil || installed != 2 || skipped != 0 {
|
||||
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("legacy shared skill still exists: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiSharedSkill, "SKILL.md")); err != nil {
|
||||
t.Fatalf("replacement shared skill missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
|
||||
t.Fatalf("unrelated custom skill changed: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
|
||||
t.Fatalf("legacy cleanup was not reported: %s", out.String())
|
||||
}
|
||||
|
||||
t.Run("failed replacement preserves legacy", func(t *testing.T) {
|
||||
missingSource := t.TempDir()
|
||||
failureHome := filepath.Join(t.TempDir(), "skills")
|
||||
failureLegacy := filepath.Join(failureHome, legacyMultiSharedSkill)
|
||||
if err := os.MkdirAll(failureLegacy, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(failureLegacy, "SKILL.md"), []byte("legacy\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var failureOut, failureErr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
missingSource,
|
||||
[]string{multiSharedSkill},
|
||||
[]string{failureHome},
|
||||
&failureOut,
|
||||
&failureErr,
|
||||
)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
|
||||
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupLegacySharedCleanupFailures(t *testing.T) {
|
||||
fail := errors.New("legacy cleanup failure")
|
||||
|
||||
t.Run("missing legacy is a no-op", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupLegacyMultiSharedSkill(t.TempDir(), &out, &errOut)
|
||||
if out.Len() != 0 || errOut.Len() != 0 {
|
||||
t.Fatalf("missing legacy emitted output: stdout=%q stderr=%q", out.String(), errOut.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stat failure is reported", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
|
||||
if out.Len() != 0 || !strings.Contains(errOut.String(), "无法检查已退役 Skill 残留") {
|
||||
t.Fatalf("stat failure output: stdout=%q stderr=%q", out.String(), errOut.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remove failure is reported", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
return skillSetupFileInfo{name: filepath.Base(path), mode: os.ModeDir}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return fail })
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
|
||||
if out.Len() != 0 || !strings.Contains(errOut.String(), "已退役 Skill 清理失败") {
|
||||
t.Fatalf("remove failure output: stdout=%q stderr=%q", out.String(), errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) {
|
||||
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
|
||||
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
|
||||
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
|
||||
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
|
||||
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
|
||||
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
|
||||
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
|
||||
t.Cleanup(func() {
|
||||
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
|
||||
@@ -133,6 +235,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
|
||||
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
|
||||
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
|
||||
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
|
||||
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
@@ -310,3 +413,236 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
_ = fs.ValidPath("path")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing.T) {
|
||||
fail := errors.New("injected failure")
|
||||
validSkill := func(name string) []byte {
|
||||
return []byte("---\nname: " + name + "\ndescription: valid migration skill\n---\n\n# Skill\n")
|
||||
}
|
||||
|
||||
t.Run("folded discovery rejects directory reference", func(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
miscRoot := filepath.Join(dest, multiMiscSkill)
|
||||
if err := os.MkdirAll(miscRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("dws event\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if strings.HasSuffix(path, filepath.Join("references", "event.md")) {
|
||||
return skillSetupFileInfo{name: "event.md", mode: os.ModeDir}, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
if got := findFoldedEventMiscTargets([]string{dest}); len(got) != 0 {
|
||||
t.Fatalf("directory event reference accepted: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migration root validation failures", func(t *testing.T) {
|
||||
if err := validateMigrationSkillRoot(filepath.Join(t.TempDir(), "missing"), multiEventSkill, nil); err == nil {
|
||||
t.Fatal("missing SKILL.md succeeded")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "references", "directory.md"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{filepath.Join("references", "directory.md")}); err == nil || !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Fatalf("directory required file = %v", err)
|
||||
}
|
||||
|
||||
missing := filepath.Join("references", "missing.md")
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if path == filepath.Join(root, missing) {
|
||||
return skillSetupFileInfo{name: "missing.md"}, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{missing}); err == nil || !strings.Contains(err.Error(), "无法读取") {
|
||||
t.Fatalf("unreadable required file = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("frontmatter validation branches", func(t *testing.T) {
|
||||
validWithIgnoredLine := []byte("---\nignored line\nname: dingtalk-event\ndescription: valid\n---\n\nbody\n")
|
||||
if name, err := parseMigrationSkillFrontmatter(validWithIgnoredLine); err != nil || name != multiEventSkill {
|
||||
t.Fatalf("ignored frontmatter line = %q, %v", name, err)
|
||||
}
|
||||
for name, body := range map[string][]byte{
|
||||
"duplicate name": []byte("---\nname: one\nname: two\ndescription: valid\n---\nbody\n"),
|
||||
"unclosed": []byte("---\nname: one\ndescription: valid\nbody\n"),
|
||||
"missing name": []byte("---\ndescription: valid\n---\nbody\n"),
|
||||
"missing desc": []byte("---\nname: one\n---\nbody\n"),
|
||||
"empty body": []byte("---\nname: one\ndescription: valid\n---\n \n"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := parseMigrationSkillFrontmatter(body); err == nil {
|
||||
t.Fatal("invalid frontmatter succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clean misc validation branches", func(t *testing.T) {
|
||||
if err := validateCleanEventMiscRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing misc root succeeded")
|
||||
}
|
||||
|
||||
routed := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(routed, "SKILL.md"), append(validSkill(multiMiscSkill), []byte("dws event\n")...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(routed); err == nil || !strings.Contains(err.Error(), "仍包含个人 Event 路由") {
|
||||
t.Fatalf("routed misc = %v", err)
|
||||
}
|
||||
|
||||
clean := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(clean, "SKILL.md"), validSkill(multiMiscSkill), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(clean); err != nil {
|
||||
t.Fatalf("missing references should be clean: %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, fail })
|
||||
if err := validateCleanEventMiscRoot(clean); !errors.Is(err, fail) {
|
||||
t.Fatalf("read-dir failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
return 0, 0, fail
|
||||
})
|
||||
migration := filepath.Join(t.TempDir(), "migration")
|
||||
ordinary := filepath.Join(t.TempDir(), "ordinary")
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
t.Fatalf("ordinary install failure = %v", err)
|
||||
}
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
t.Fatalf("prerequisite install failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preparation cleanup and staged misc validation", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
dest := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return fail })
|
||||
cleanupFail := errors.New("cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
if _, err := prepareEventMiscMigration(src, dest); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
|
||||
t.Fatalf("joined preparation cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid staged misc root", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := prepareEventMiscMigration(src, t.TempDir()); err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
|
||||
t.Fatalf("invalid staged misc = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("later staging failure joins cleanup error", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
first := filepath.Join(t.TempDir(), "a")
|
||||
second := filepath.Join(t.TempDir(), "b")
|
||||
if err := os.MkdirAll(first, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(second, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalMkdirTemp := skillSetupMkdirTemp
|
||||
calls := 0
|
||||
testseam.Swap(t, &skillSetupMkdirTemp, func(dir, pattern string) (string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return "", fail
|
||||
}
|
||||
return originalMkdirTemp(dir, pattern)
|
||||
})
|
||||
cleanupFail := errors.New("stage cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
|
||||
t.Fatalf("later preparation failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("successful migration reports cleanup warning", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
cleanupFail := errors.New("final cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
var stderr bytes.Buffer
|
||||
installed, err := migrateEventMiscAtomically(src, []string{home}, io.Discard, &stderr)
|
||||
if err != nil || installed != 2 || !strings.Contains(stderr.String(), cleanupFail.Error()) {
|
||||
t.Fatalf("successful migration cleanup warning: installed=%d err=%v stderr=%s", installed, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit rollback and cleanup failures are joined", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "a", "skills")
|
||||
second := filepath.Join(root, "b", "skills")
|
||||
for _, home := range []string{first, second} {
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
}
|
||||
commitFail := errors.New("second commit failure")
|
||||
rollbackFail := errors.New("first rollback failure")
|
||||
originalRename := skillSetupRename
|
||||
testseam.Swap(t, &skillSetupRename, func(oldPath, newPath string) error {
|
||||
if filepath.Base(oldPath) == "new-misc" && newPath == filepath.Join(second, multiMiscSkill) {
|
||||
return commitFail
|
||||
}
|
||||
if filepath.Base(oldPath) == "old-misc" && newPath == filepath.Join(first, multiMiscSkill) {
|
||||
return rollbackFail
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
})
|
||||
cleanupFail := errors.New("post-rollback cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, commitFail) || !errors.Is(err, rollbackFail) || !errors.Is(err, cleanupFail) {
|
||||
t.Fatalf("joined commit/rollback/cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit preflight and rollback aggregation", func(t *testing.T) {
|
||||
migration := &eventMiscMigration{dest: "dest", eventPath: "event", miscPath: "misc"}
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
|
||||
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
|
||||
t.Fatalf("event stat failure = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if path == migration.eventPath {
|
||||
return skillSetupFileInfo{name: "event", mode: os.ModeDir}, nil
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
|
||||
t.Fatalf("misc stat failure = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
if err := commitEventMiscMigration(migration); err == nil || !strings.Contains(err.Error(), "已不存在") {
|
||||
t.Fatalf("missing misc = %v", err)
|
||||
}
|
||||
|
||||
migration.newMiscEnabled = true
|
||||
testseam.Swap(t, &skillSetupRename, func(string, string) error { return fail })
|
||||
if err := rollbackEventMiscMigrations([]*eventMiscMigration{migration}); !errors.Is(err, fail) {
|
||||
t.Fatalf("rollback aggregation = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -184,12 +187,20 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
|
||||
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
|
||||
skillBody := "---\nname: " + n + "\ndescription: test skill\n---\n\n# " + n + "\n"
|
||||
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte(skillBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n == multiEventSkill {
|
||||
for _, ref := range eventMigrationRequiredReferences {
|
||||
if err := os.WriteFile(filepath.Join(sub, "references", ref), []byte("ref "+ref+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
@@ -549,3 +560,787 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
|
||||
t.Fatalf("expected %s, got %s", multiDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
originalTargets := skillSetupResolveTargets
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) {
|
||||
return append([]string(nil), dests...), nil
|
||||
}
|
||||
t.Cleanup(func() { skillSetupResolveTargets = originalTargets })
|
||||
|
||||
cmd := newSkillSetupCommand()
|
||||
cmd.Flags().Bool("dry-run", false, "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
baseArgs := []string{"--mode", "multi", "--source", src}
|
||||
cmd.SetArgs(append(baseArgs, args...))
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func writeFoldedEventMisc(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
miscRoot := filepath.Join(agentHome, multiMiscSkill)
|
||||
if err := os.MkdirAll(filepath.Join(miscRoot, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("personal IM route: dws event consume\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(miscRoot, "references", "event.md"), []byte("folded event docs\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeOldStandaloneEvent(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
eventRoot := filepath.Join(agentHome, multiEventSkill)
|
||||
if err := os.MkdirAll(eventRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(eventRoot, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOldEventMiscPair(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
eventBody, err := os.ReadFile(filepath.Join(agentHome, multiEventSkill, "SKILL.md"))
|
||||
if err != nil || string(eventBody) != "old standalone event\n" {
|
||||
t.Fatalf("old standalone event was not restored: body=%q err=%v", eventBody, err)
|
||||
}
|
||||
miscBody, err := os.ReadFile(filepath.Join(agentHome, multiMiscSkill, "SKILL.md"))
|
||||
if err != nil || !strings.Contains(string(miscBody), "dws event") {
|
||||
t.Fatalf("folded misc was not restored: body=%q err=%v", miscBody, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentHome, multiMiscSkill, "references", "event.md")); err != nil {
|
||||
t.Fatalf("folded Event reference was not restored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoEventMigrationStages(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(agentHome)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
|
||||
t.Fatalf("unexpected leftover Event migration stage %s", filepath.Join(agentHome, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
|
||||
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
|
||||
writeFoldedEventMisc(t, foldedHome)
|
||||
if err := os.MkdirAll(filepath.Join(foldedHome, multiEventSkill), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(foldedHome, "dingtalk-chat"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
|
||||
if err != nil {
|
||||
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
|
||||
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "重新加载 Skills") {
|
||||
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
|
||||
}
|
||||
|
||||
for _, home := range []string{freshHome, foldedHome} {
|
||||
for _, name := range []string{multiSharedSkill, multiEventSkill} {
|
||||
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("%s missing from %s: %v", name, home, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc")); !os.IsNotExist(err) {
|
||||
t.Errorf("unselected doc appeared in %s: %v", home, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(freshHome, multiMiscSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("fresh selective target must not receive misc, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("folded event reference survived clean misc replacement, stat err=%v", err)
|
||||
}
|
||||
eventBody, err := os.ReadFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"))
|
||||
if err != nil || strings.Contains(string(eventBody), "old standalone") {
|
||||
t.Fatalf("old standalone event was not replaced: body=%q err=%v", eventBody, err)
|
||||
}
|
||||
siblingBody, err := os.ReadFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"))
|
||||
if err != nil || string(siblingBody) != "keep sibling\n" {
|
||||
t.Fatalf("unrelated sibling changed: body=%q err=%v", siblingBody, err)
|
||||
}
|
||||
|
||||
// A second selective run sees the already-clean misc, does not plan another
|
||||
// migration, and leaves that unselected sibling in place.
|
||||
stdout, stderr, err = executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent event setup failed: %v\nstderr=%s", err, stderr)
|
||||
}
|
||||
if strings.Contains(stdout, "迁移伴侣") {
|
||||
t.Fatalf("clean second run should not re-detect folded misc: %s", stdout)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "SKILL.md")); err != nil {
|
||||
t.Fatalf("second selective run removed clean misc: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
|
||||
t.Run("dry run reports companion without writes", func(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--dry-run", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("dry run failed: %v\nstderr=%s", err, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "DRY-RUN") || !strings.Contains(stdout, "迁移伴侣") {
|
||||
t.Fatalf("dry run did not expose migration: %s", stdout)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("dry run changed folded misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("dry run installed event, stat err=%v", statErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("excluding required misc fails before writes", func(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--exclude", "misc", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "不能显式 --exclude misc") {
|
||||
t.Fatalf("expected clear migration exclusion error, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed migration installed event, stat err=%v", statErr)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("failed migration changed misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRequiresCleanMiscInSource(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "当前 multi 源缺少") {
|
||||
t.Fatalf("expected missing migration companion error, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed preflight installed event, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationAcceptsShippedMultiBundle(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Clean(filepath.Join(wd, "..", "..", "skills", "multi"))
|
||||
if err := validateEventMiscMigrationSource(src); err != nil {
|
||||
t.Fatalf("shipped multi bundle is not a valid Event migration source: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRejectsInvalidSkillBundlesBeforeWrites(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(t *testing.T, src string)
|
||||
}{
|
||||
{
|
||||
name: "empty event root",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong event name",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
body := "---\nname: dingtalk-chat\ndescription: wrong skill\n---\n\n# Wrong\n"
|
||||
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing event reference",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
if err := os.Remove(filepath.Join(src, multiEventSkill, "references", "event-oa.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty event reference",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "references", "event-im.md"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong misc name",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
body := "---\nname: dingtalk-event\ndescription: wrong skill\n---\n\n# Wrong\n"
|
||||
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
test.mutate(t, src)
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "迁移源无效") {
|
||||
t.Fatalf("invalid migration source was accepted: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("invalid migration source reported success: %s", stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("invalid source wrote shared skill: %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("misc migration companion ran after a primary install skip")
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
|
||||
t.Fatalf("expected preserved-fallback error, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("install calls = %d, want 1", calls)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("partial migration reported success: %s", stdout)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("primary skip changed folded misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); statErr != nil {
|
||||
t.Fatalf("primary skip removed folded Event reference: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
|
||||
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
|
||||
writeFoldedEventMisc(t, foldedHome)
|
||||
writeOldStandaloneEvent(t, foldedHome)
|
||||
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("folded target prerequisites ran after fresh target failure")
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(
|
||||
t,
|
||||
src,
|
||||
[]string{foldedHome, freshHome},
|
||||
"--skill", "event",
|
||||
"--yes",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
|
||||
t.Fatalf("fresh target failure did not block migration: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("partial mixed-target install reported success: %s", stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, foldedHome)
|
||||
assertNoEventMigrationStages(t, foldedHome)
|
||||
}
|
||||
|
||||
func TestSkillSetupUnrelatedSelectiveInstallLeavesFoldedPairUntouched(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "doc", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated selective install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if strings.Contains(stdout, "Event Skill 迁移") || strings.Contains(stdout, "Event 原子迁移") {
|
||||
t.Fatalf("unrelated selective install planned Event migration: %s", stdout)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("selected doc was not installed: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventAtomicStageFailurePreservesFoldedPair(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if strings.HasSuffix(dest, "new-misc") {
|
||||
return errors.New("injected stage failure")
|
||||
}
|
||||
return originalCopyDir(src, dest)
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "预备 dingtalk-misc 失败") {
|
||||
t.Fatalf("expected atomic staging error, got %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("partial atomic migration reported success: %s", stdout)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("stage failure changed folded misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("stage failure installed standalone event, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationPreparationFailuresPreserveFoldedPair(t *testing.T) {
|
||||
t.Run("same-filesystem staging creation", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalMkdirTemp := skillSetupMkdirTemp
|
||||
t.Cleanup(func() { skillSetupMkdirTemp = originalMkdirTemp })
|
||||
skillSetupMkdirTemp = func(dir, pattern string) (string, error) {
|
||||
if dir != home {
|
||||
t.Fatalf("staging dir = %s, want target filesystem root %s", dir, home)
|
||||
}
|
||||
return "", errors.New("injected mkdir-temp failure")
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "injected mkdir-temp failure") {
|
||||
t.Fatalf("staging creation failure was not returned: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
})
|
||||
|
||||
t.Run("event staging copy", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if strings.HasSuffix(dest, "new-event") {
|
||||
return errors.New("injected event copy failure")
|
||||
}
|
||||
return originalCopyDir(src, dest)
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "injected event copy failure") {
|
||||
t.Fatalf("event staging failure was not returned: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventRejectsCorruptStagedMisc(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if err := originalCopyDir(src, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(dest, "new-misc") {
|
||||
return os.WriteFile(filepath.Join(dest, "references", "event-partial.md"), []byte("corrupt\n"), 0o644)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
|
||||
t.Fatalf("corrupt staged misc was accepted: %v", err)
|
||||
}
|
||||
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
|
||||
t.Fatalf("staging validation failure changed folded misc: body=%q err=%v", miscBody, readErr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("staging validation failure installed event: %v", err)
|
||||
}
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventRejectsIncompleteStagedEvent(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if err := originalCopyDir(src, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(dest, "new-event") {
|
||||
return os.Remove(filepath.Join(dest, "references", "event-oa.md"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
|
||||
t.Fatalf("incomplete staged Event was accepted: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupFullEventMigrationIsAtomicAndPreservesSiblings(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
sibling := filepath.Join(home, "dingtalk-private-sibling")
|
||||
if err := os.MkdirAll(sibling, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sibling, "SKILL.md"), []byte("keep\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("full setup migration failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "Event 原子迁移") || !strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("full setup did not report atomic migration success: %s", stdout)
|
||||
}
|
||||
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill, "dingtalk-doc"} {
|
||||
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("full setup missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("full setup retained folded Event reference: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(sibling, "SKILL.md"))
|
||||
if err != nil || string(body) != "keep\n" {
|
||||
t.Fatalf("full setup changed unrelated sibling: body=%q err=%v", body, err)
|
||||
}
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationWithoutSharedStillCleansMonoLeftover(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
monoLeftover := filepath.Join(home, "dws")
|
||||
if err := os.MkdirAll(monoLeftover, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("migration without shared failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
|
||||
t.Fatalf("migration without prerequisites retained mono leftover: %v", err)
|
||||
}
|
||||
for _, name := range []string{multiEventSkill, multiMiscSkill} {
|
||||
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("migration without shared missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupFoldedEventMigrationSelectionPreflight(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "misc only", args: []string{"--skill", "misc", "--yes"}, want: "不能只覆盖 dingtalk-misc"},
|
||||
{name: "explicitly excludes event", args: []string{"--exclude", "event", "--yes"}, want: "不能显式 --exclude event"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("preflight error = %v, want %q", err, tt.want)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight failure wrote shared skill: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight failure wrote event skill: %v", err)
|
||||
}
|
||||
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
|
||||
t.Fatalf("preflight failure changed folded misc: body=%q err=%v", miscBody, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRejectsEveryFoldedReferenceVariant(t *testing.T) {
|
||||
for _, filename := range []string{"event.md", "event-im.md", "event-oa.md", "EVENT-legacy.MD"} {
|
||||
t.Run(filename, func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "references", filename), []byte("stale\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "仍存在折叠 Event 参考页") {
|
||||
t.Fatalf("source with %s was accepted: %v", filename, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("invalid source wrote shared skill: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRenameFailuresRollbackPair(t *testing.T) {
|
||||
for failAt := 1; failAt <= 4; failAt++ {
|
||||
t.Run(fmt.Sprintf("rename_%d", failAt), func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
renameCalls := 0
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
renameCalls++
|
||||
if renameCalls == failAt {
|
||||
return errors.New("injected rename failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "injected rename failure") {
|
||||
t.Fatalf("rename failure %d was not returned: %v", failAt, err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("rename failure %d reported success: %s", failAt, stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationFailureRollsBackEarlierTargets(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
root := t.TempDir()
|
||||
firstHome := filepath.Join(root, "a", "skills")
|
||||
secondHome := filepath.Join(root, "b", "skills")
|
||||
for _, home := range []string{firstHome, secondHome} {
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
}
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
failed := false
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
if !failed && oldPath == filepath.Join(secondHome, multiMiscSkill) {
|
||||
failed = true
|
||||
return errors.New("second target failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{secondHome, firstHome}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "second target failure") {
|
||||
t.Fatalf("second target failure was not returned: %v", err)
|
||||
}
|
||||
for _, home := range []string{firstHome, secondHome} {
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRollbackFailurePreservesRecoveryDirectory(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("commit failure")
|
||||
}
|
||||
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("rollback restore failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
_, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "回滚不完整") || !strings.Contains(err.Error(), "恢复目录") {
|
||||
t.Fatalf("rollback failure did not expose recovery directory: %v", err)
|
||||
}
|
||||
entries, readErr := os.ReadDir(home)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
var recoveryRoot string
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
|
||||
recoveryRoot = filepath.Join(home, entry.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
if recoveryRoot == "" {
|
||||
t.Fatal("rollback failure deleted the only recovery directory")
|
||||
}
|
||||
if !strings.Contains(err.Error(), recoveryRoot) || !strings.Contains(stderr, recoveryRoot) {
|
||||
t.Fatalf("recovery directory was not reported: err=%v stderr=%s", err, stderr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-misc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("old folded misc backup is missing from recovery directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-event", "SKILL.md")); err != nil {
|
||||
t.Fatalf("old standalone Event backup is missing from recovery directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); err != nil {
|
||||
t.Fatalf("rollback failure removed the live standalone Event entry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRollbackFailureKeepsNewEventWithoutOldStandalone(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("commit failure")
|
||||
}
|
||||
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("rollback restore failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "回滚不完整") {
|
||||
t.Fatalf("rollback failure was not returned: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); statErr != nil {
|
||||
t.Fatalf("rollback failure removed the only live Event entry: %v", statErr)
|
||||
}
|
||||
entries, readErr := os.ReadDir(home)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
|
||||
continue
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, entry.Name(), "old-misc", "SKILL.md")); statErr != nil {
|
||||
t.Fatalf("rollback failure lost the folded misc recovery copy: %v", statErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("rollback failure did not preserve a recovery directory")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
{
|
||||
"source_report": "param_hallucination_20260728_120943",
|
||||
"source_file": "raw_dashscope_qwen3_7-max_20260728_120943.json",
|
||||
"source_dws_commit": "a8e83e5",
|
||||
"model": "dashscope/qwen3.7-max",
|
||||
"expected_count": 52,
|
||||
"badcases": [
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command0",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members --group \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command1",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members --group-name \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command2",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members \"dws测试群01\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run1-command1",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 1,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-dm",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-dm --open-dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run1-command2",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 1,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-dm",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-dm --dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run8-command4",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 8,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-single --user-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command0",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot --open-dingtalk-id \"段旭彬\" --message \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command1",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot --open-id \"段旭彬\" --msg \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command2",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot \"段旭彬\" \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run1-command3",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 1,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-media",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-media --user-id \"489149\" --file-path \"建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run1-command4",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 1,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-media",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-media --receiver \"489149\" --file-path \"建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run9-command1",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 9,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 489149 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case__ysfhvr3/建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run9-command2",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 9,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --dry-run --format json 2>&1 | head -50",
|
||||
"original_exit_code": 0,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run1-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 1,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --to \"符咏畅\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run1-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 1,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --user \"符咏畅\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run2-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 2,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run2-command8",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 2,
|
||||
"command_index": 8,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run3-command1",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 3,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run3-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 3,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run4-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 4,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --help",
|
||||
"original_exit_code": 64,
|
||||
"is_help": true
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command4",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command5",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --to \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command6",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --name \"符咏畅\" --path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run7-command1",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 7,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id \"519483\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run7-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 7,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --format json --userid \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\"",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0016-run1-command7",
|
||||
"case_id": "dws_im_v3_0016",
|
||||
"run": 1,
|
||||
"command_index": 7,
|
||||
"turn": 3,
|
||||
"source_path": "chat +rename-group",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +rename-group --id \"cideWy3UCPNZ4k8Mr4BL/8KRQ==\" --name \"测试废物群-20260713\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command6",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 6,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --group \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command7",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 7,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command9",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 9,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --format json 2>&1",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run5-command0",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 5,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +group-member-list",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +group-member-list --group-name \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command6",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 6,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-text --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command7",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 7,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-text --conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --at-user-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command8",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 8,
|
||||
"turn": 2,
|
||||
"source_path": "chat +group-send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +group-send-text --group-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0018-run10-command5",
|
||||
"case_id": "dws_im_v3_0018",
|
||||
"run": 10,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --group \"cidyfbDkUpmy4ReyI7vfZy+zw==\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_z7y6p6_0/dog.png\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run4-command1",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 4,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --user-id 489149 --image /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run4-command2",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 4,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --to 489149 --file /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run6-command3",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 6,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --receiver-id 489149 --image \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_09930iej/landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command2",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command3",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver-id 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command6",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --to 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command9",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 9,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-to",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-to --uid 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run9-command0",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 9,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --receiver 489149 --file-path \"./landscape.jpg\" --format json 2>&1",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command3",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --user-id 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command4",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --uid 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command5",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --staff-id 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command6",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --open-dingtalk-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command7",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 7,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --target 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command8",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 8,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single 550582 --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run5-command1",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 5,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +message-list",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +message-list --format json 2>&1",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run4-command5",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 4,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-robot",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-robot \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run5-command15",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 5,
|
||||
"command_index": 15,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-group-bots",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-group-bots --id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run9-command9",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 9,
|
||||
"command_index": 9,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-robots",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-robots --cid \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -44,6 +44,10 @@ import (
|
||||
// test binary never launches a page on the developer's machine; tests that
|
||||
// need to assert the URL can still replace openBrowserFunc locally.
|
||||
func TestMain(m *testing.M) {
|
||||
if code, ok := runRuntimeTokenDetachedE2EChild(); ok {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
|
||||
if err != nil {
|
||||
panic("create test keychain tempdir: " + err.Error())
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// 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 auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataUsesSelectorGrammarAndReturnsCopy(t *testing.T) {
|
||||
cfg := &ProfilesConfig{
|
||||
Version: 3,
|
||||
Profiles: []Profile{
|
||||
{Name: "Historical", CorpID: "corp-1", CorpName: "Example Org"},
|
||||
{Name: "Exact", CorpID: "corp-1", CorpName: "Example Org", UserID: "user-1", UserName: "Example User", ClientID: "client-1"},
|
||||
},
|
||||
OrgCurrentProfiles: map[string]string{"corp-1": "corp-1:user-1"},
|
||||
}
|
||||
|
||||
for _, selector := range []string{"corp-1", "Example Org", "corp-1:Example User", "Example Org:Example User"} {
|
||||
profile, err := ResolveProfileMetadata(cfg, selector)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfileMetadata(%q) error = %v", selector, err)
|
||||
}
|
||||
if profile == nil || profile.UserID != "user-1" || profile.ClientID != "client-1" {
|
||||
t.Fatalf("ResolveProfileMetadata(%q) = %#v", selector, profile)
|
||||
}
|
||||
}
|
||||
|
||||
profile, err := ResolveProfileMetadata(cfg, "corp-1:user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfileMetadata(exact) error = %v", err)
|
||||
}
|
||||
profile.Name = "mutated copy"
|
||||
if cfg.Profiles[1].Name != "Exact" {
|
||||
t.Fatalf("ResolveProfileMetadata returned registry-owned pointer")
|
||||
}
|
||||
if _, err := ResolveProfileMetadata(cfg, "missing"); err == nil {
|
||||
t.Fatal("ResolveProfileMetadata(missing) unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
@@ -738,6 +738,20 @@ func ResolveProfileWithScope(configDir, selector string) (*Profile, bool, error)
|
||||
return result, exact, err
|
||||
}
|
||||
|
||||
// ResolveProfileMetadata applies the public profile-selector grammar to an
|
||||
// already-loaded, non-sensitive profiles registry. It performs no migration,
|
||||
// keychain access, token loading, or persistence, making it suitable for
|
||||
// callers that carry an externally managed bearer credential and need only
|
||||
// corp/user/client identity metadata.
|
||||
func ResolveProfileMetadata(cfg *ProfilesConfig, selector string) (*Profile, error) {
|
||||
profile, _, err := resolveProfileSelection("", cfg, selector)
|
||||
if err != nil || profile == nil {
|
||||
return nil, err
|
||||
}
|
||||
copy := *profile
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func resolveProfileWithScopeLocked(configDir, selector string) (*Profile, bool, error) {
|
||||
if err := profilesEnsureMigration(configDir); err != nil {
|
||||
return nil, false, err
|
||||
|
||||
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束、安全语义和已评审的返回契约。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束、安全语义和返回契约")
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const commandPathFallbacksSchemaRef = "./command_path_fallbacks.schema.json"
|
||||
|
||||
// command_path_fallbacks.json is a reviewed recovery policy for invalid paths
|
||||
// observed in evaluation. It is deliberately not a command identity source:
|
||||
// Help, Skill, Schema, and normal navigation continue to publish canonical
|
||||
// paths from the CommandRegistry and the real Cobra tree only.
|
||||
|
||||
//go:embed command_path_fallbacks.json
|
||||
var embeddedCommandPathFallbacksJSON []byte
|
||||
|
||||
//go:embed command_path_fallbacks.schema.json
|
||||
var embeddedCommandPathFallbacksSchemaJSON []byte
|
||||
|
||||
var commandFallbackPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$`)
|
||||
|
||||
// CommandPathFallbackMode determines whether an invalid reviewed path can be
|
||||
// normalized exactly or must stop and surface reviewed candidates.
|
||||
type CommandPathFallbackMode string
|
||||
|
||||
const (
|
||||
CommandPathFallbackRewrite CommandPathFallbackMode = "rewrite"
|
||||
CommandPathFallbackAmbiguous CommandPathFallbackMode = "ambiguous"
|
||||
)
|
||||
|
||||
// CommandPathFallback is one validated, generated runtime recovery record.
|
||||
// From is never advertised as a stable alias. To and Candidates are canonical
|
||||
// real Cobra paths validated at generation time.
|
||||
type CommandPathFallback struct {
|
||||
From string `json:"from"`
|
||||
Mode CommandPathFallbackMode `json:"mode"`
|
||||
To string `json:"to,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
type commandPathFallbackSnapshot struct {
|
||||
Schema string `json:"$schema"`
|
||||
Version int `json:"version"`
|
||||
Entries []commandPathFallbackEntrySpec `json:"entries"`
|
||||
}
|
||||
|
||||
type commandPathFallbackEntrySpec struct {
|
||||
From string `json:"from"`
|
||||
Mode CommandPathFallbackMode `json:"mode"`
|
||||
To string `json:"to,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedCommandPathFallbacksOnce sync.Once
|
||||
embeddedCommandPathFallbacksData []CommandPathFallback
|
||||
embeddedCommandPathFallbacksErr error
|
||||
loadReviewedCommandPathFallbacks = loadEmbeddedCommandPathFallbacks
|
||||
)
|
||||
|
||||
// LoadCommandPathFallbacks decodes and validates the authored recovery table.
|
||||
// Callers receive a clone so generation and tests cannot mutate shared data.
|
||||
func LoadCommandPathFallbacks() ([]CommandPathFallback, error) {
|
||||
return loadReviewedCommandPathFallbacks()
|
||||
}
|
||||
|
||||
func loadEmbeddedCommandPathFallbacks() ([]CommandPathFallback, error) {
|
||||
embeddedCommandPathFallbacksOnce.Do(func() {
|
||||
embeddedCommandPathFallbacksData, embeddedCommandPathFallbacksErr = decodeCommandPathFallbacks(embeddedCommandPathFallbacksJSON)
|
||||
})
|
||||
return cloneCommandPathFallbacks(embeddedCommandPathFallbacksData), embeddedCommandPathFallbacksErr
|
||||
}
|
||||
|
||||
func decodeCommandPathFallbacks(data []byte) ([]CommandPathFallback, error) {
|
||||
var snapshot commandPathFallbackSnapshot
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&snapshot); err != nil {
|
||||
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("multiple JSON values")
|
||||
}
|
||||
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
|
||||
}
|
||||
if snapshot.Version != 1 {
|
||||
return nil, fmt.Errorf("unsupported command path fallbacks version %d", snapshot.Version)
|
||||
}
|
||||
if strings.TrimSpace(snapshot.Schema) != commandPathFallbacksSchemaRef {
|
||||
return nil, fmt.Errorf("command path fallbacks must declare $schema=%q", commandPathFallbacksSchemaRef)
|
||||
}
|
||||
if len(snapshot.Entries) == 0 {
|
||||
return nil, fmt.Errorf("command path fallbacks declares no entries")
|
||||
}
|
||||
|
||||
entries := make([]CommandPathFallback, 0, len(snapshot.Entries))
|
||||
seenFrom := make(map[string]bool, len(snapshot.Entries))
|
||||
for index, spec := range snapshot.Entries {
|
||||
from, err := validateAuthoredCommandFallbackPath(spec.From)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("command path fallback entry %d from: %w", index, err)
|
||||
}
|
||||
if seenFrom[from] {
|
||||
return nil, fmt.Errorf("command path fallbacks contains duplicate from path %q", from)
|
||||
}
|
||||
seenFrom[from] = true
|
||||
reason := strings.TrimSpace(spec.ReviewReason)
|
||||
if !spec.Reviewed || reason == "" {
|
||||
return nil, fmt.Errorf("command path fallback %q requires reviewed=true and non-empty review_reason", from)
|
||||
}
|
||||
|
||||
entry := CommandPathFallback{
|
||||
From: from,
|
||||
Mode: spec.Mode,
|
||||
Reviewed: true,
|
||||
ReviewReason: reason,
|
||||
}
|
||||
switch spec.Mode {
|
||||
case CommandPathFallbackRewrite:
|
||||
if len(spec.Candidates) > 0 {
|
||||
return nil, fmt.Errorf("rewrite command path fallback %q must not declare candidates", from)
|
||||
}
|
||||
to, pathErr := validateAuthoredCommandFallbackPath(spec.To)
|
||||
if pathErr != nil {
|
||||
return nil, fmt.Errorf("rewrite command path fallback %q to: %w", from, pathErr)
|
||||
}
|
||||
entry.To = to
|
||||
case CommandPathFallbackAmbiguous:
|
||||
if strings.TrimSpace(spec.To) != "" {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q must not declare to", from)
|
||||
}
|
||||
if len(spec.Candidates) < 2 {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q requires at least two candidates", from)
|
||||
}
|
||||
seenCandidates := make(map[string]bool, len(spec.Candidates))
|
||||
for _, rawCandidate := range spec.Candidates {
|
||||
candidate, pathErr := validateAuthoredCommandFallbackPath(rawCandidate)
|
||||
if pathErr != nil {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q candidate: %w", from, pathErr)
|
||||
}
|
||||
if seenCandidates[candidate] {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q repeats candidate %q", from, candidate)
|
||||
}
|
||||
seenCandidates[candidate] = true
|
||||
entry.Candidates = append(entry.Candidates, candidate)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("command path fallback %q has invalid mode %q", from, spec.Mode)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].From < entries[j].From })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func validateAuthoredCommandFallbackPath(raw string) (string, error) {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("path is empty")
|
||||
}
|
||||
if path != raw || normalizeSchemaCLIPath(path) != path || !commandFallbackPathPattern.MatchString(path) {
|
||||
return "", fmt.Errorf("path %q is not a normalized command path without leading dws or flags", raw)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ReduceCommandPathFallbacks validates the reviewed recovery table against the
|
||||
// live distribution-owned Cobra tree. It refuses to turn an existing command
|
||||
// or stable alias into a hidden rewrite. The sole exception is an explicitly
|
||||
// annotated hint-only compatibility node, which contains no business action.
|
||||
func ReduceCommandPathFallbacks(root *cobra.Command) ([]CommandPathFallback, error) {
|
||||
if root == nil {
|
||||
return nil, fmt.Errorf("command path fallback source root is nil")
|
||||
}
|
||||
entries, err := LoadCommandPathFallbacks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byFrom := make(map[string]CommandPathFallback, len(entries))
|
||||
for _, entry := range entries {
|
||||
byFrom[entry.From] = entry
|
||||
}
|
||||
var problems []string
|
||||
for _, entry := range entries {
|
||||
match, resolveErr := resolveExactCobraPath(root, entry.From)
|
||||
if resolveErr != nil {
|
||||
problems = append(problems, fmt.Sprintf("from %q cannot be resolved safely: %v", entry.From, resolveErr))
|
||||
} else if match.Command != nil && !cmdutil.IsHintOnlyCommand(match.Command) {
|
||||
problems = append(problems, fmt.Sprintf("from %q collides with a real Cobra command or alias", entry.From))
|
||||
}
|
||||
|
||||
switch entry.Mode {
|
||||
case CommandPathFallbackRewrite:
|
||||
if _, chained := byFrom[entry.To]; chained {
|
||||
problems = append(problems, fmt.Sprintf("rewrite %q targets fallback source %q; chained fallbacks are forbidden", entry.From, entry.To))
|
||||
}
|
||||
problems = append(problems, validateCommandFallbackTarget(root, entry.From, entry.To, true)...)
|
||||
case CommandPathFallbackAmbiguous:
|
||||
for _, candidate := range entry.Candidates {
|
||||
if _, chained := byFrom[candidate]; chained {
|
||||
problems = append(problems, fmt.Sprintf("ambiguous fallback %q candidate %q is another fallback source", entry.From, candidate))
|
||||
}
|
||||
// Ambiguous recovery never dispatches. It may therefore point the
|
||||
// caller at both canonical shortcuts and native leaves, while exact
|
||||
// rewrites keep the stricter +shortcut identity boundary.
|
||||
problems = append(problems, validateCommandFallbackTarget(root, entry.From, candidate, false)...)
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("fallback %q has unsupported mode %q", entry.From, entry.Mode))
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, fmt.Errorf("command path fallback reduction failed:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
return cloneCommandPathFallbacks(entries), nil
|
||||
}
|
||||
|
||||
func validateCommandFallbackTarget(root *cobra.Command, from, target string, requireShortcutParity bool) []string {
|
||||
var problems []string
|
||||
if commandFallbackService(from) != commandFallbackService(target) {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q crosses service boundary to %q", from, target))
|
||||
}
|
||||
if requireShortcutParity && commandFallbackHasShortcut(from) != commandFallbackHasShortcut(target) {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q and target %q disagree on +shortcut identity", from, target))
|
||||
}
|
||||
match, err := resolveExactCobraPath(root, target)
|
||||
if err != nil {
|
||||
return append(problems, fmt.Sprintf("fallback %q target %q cannot be resolved safely: %v", from, target, err))
|
||||
}
|
||||
if match.Command == nil {
|
||||
return append(problems, fmt.Sprintf("fallback %q target %q does not exist", from, target))
|
||||
}
|
||||
if match.UsedAlias {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q target %q must use canonical Cobra names, not an alias", from, target))
|
||||
}
|
||||
if !runnableSchemaLeaf(match.Command) || !match.Command.IsAvailableCommand() {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q target %q is not a public runnable Cobra leaf", from, target))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func commandFallbackService(path string) string {
|
||||
parts := strings.Fields(path)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func commandFallbackHasShortcut(path string) bool {
|
||||
for _, part := range strings.Fields(path) {
|
||||
if strings.HasPrefix(part, "+") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneCommandPathFallbacks(entries []CommandPathFallback) []CommandPathFallback {
|
||||
cloned := make([]CommandPathFallback, len(entries))
|
||||
for index, entry := range entries {
|
||||
cloned[index] = entry
|
||||
cloned[index].Candidates = append([]string(nil), entry.Candidates...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"$schema": "./command_path_fallbacks.schema.json",
|
||||
"version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"from": "chat +group-search",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-search",
|
||||
"reviewed": true,
|
||||
"review_reason": "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation."
|
||||
},
|
||||
{
|
||||
"from": "chat +members",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +group-members",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent."
|
||||
},
|
||||
{
|
||||
"from": "chat +group-member-list",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +group-members",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-group-bots",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-robot",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-robots",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +message-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +chat-messages",
|
||||
"chat +messages-list-direct",
|
||||
"chat +search-msg",
|
||||
"chat +unread-chats"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +read-single",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-list-direct",
|
||||
"chat +chat-messages"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch."
|
||||
},
|
||||
{
|
||||
"from": "chat +rename-group",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-update",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility."
|
||||
},
|
||||
{
|
||||
"from": "chat +send",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-message",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-text",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-to",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-dm",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +dm",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-single",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +dm",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-by-bot",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send-by-bot"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path."
|
||||
},
|
||||
{
|
||||
"from": "chat +group-send-text",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +send-to-group",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-file",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-image",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-media",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow."
|
||||
},
|
||||
{
|
||||
"from": "oa +list-processes",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"oa +list-forms",
|
||||
"oa +my-initiated",
|
||||
"oa approval list-initiated"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-detail",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +conversation-info",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation."
|
||||
},
|
||||
{
|
||||
"from": "chat +bot-list",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-category-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +category-list",
|
||||
"chat +category-list-conversations"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-group-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +category-list-conversations",
|
||||
"chat +conversation-list"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-my-groups",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +my-groups",
|
||||
"chat +chat-list-mine",
|
||||
"chat +chat-list"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics."
|
||||
},
|
||||
{
|
||||
"from": "doc +list-templates",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +template-list",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation."
|
||||
},
|
||||
{
|
||||
"from": "doc +search-template",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +template-search",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value."
|
||||
},
|
||||
{
|
||||
"from": "doc +template",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"doc +template-list",
|
||||
"doc +template-search",
|
||||
"doc +create-from-template"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows."
|
||||
},
|
||||
{
|
||||
"from": "doc +version",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"doc +history-list",
|
||||
"doc +history-save",
|
||||
"doc +history-revert"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop."
|
||||
},
|
||||
{
|
||||
"from": "doc +create-version",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation."
|
||||
},
|
||||
{
|
||||
"from": "doc +save-version",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target."
|
||||
},
|
||||
{
|
||||
"from": "doc +snapshot",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow."
|
||||
},
|
||||
{
|
||||
"from": "doc +version-create",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/command_path_fallbacks.schema.json",
|
||||
"title": "DWS Reviewed Command Path Fallbacks",
|
||||
"description": "Human-reviewed recovery-only normalization for invalid model-emitted command paths. These entries are not stable command identity, navigation, Help, Skill, or Schema aliases.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"$schema",
|
||||
"version",
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"const": "./command_path_fallbacks.schema.json"
|
||||
},
|
||||
"version": {
|
||||
"const": 1
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"commandPath": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
|
||||
"description": "Exact CLI path without a leading dws or flags."
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"mode",
|
||||
"reviewed",
|
||||
"review_reason"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"rewrite",
|
||||
"ambiguous"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"uniqueItems": true,
|
||||
"description": "Canonical public runnable paths offered for an ambiguous source. Because ambiguous recovery never dispatches, candidates may mix reviewed shortcuts and native leaves within the same service.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
}
|
||||
},
|
||||
"reviewed": {
|
||||
"const": true
|
||||
},
|
||||
"review_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "rewrite"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"to"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"candidates"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "ambiguous"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"candidates"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"to"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.
|
||||
// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.
|
||||
// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).
|
||||
|
||||
package cli
|
||||
|
||||
var generatedCommandPathFallbacks = []CommandPathFallback{
|
||||
{
|
||||
From: "chat +bot-list",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-category-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +category-list", "chat +category-list-conversations"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-detail",
|
||||
Mode: "rewrite",
|
||||
To: "chat +conversation-info",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-group-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +category-list-conversations", "chat +conversation-list"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-member-list",
|
||||
Mode: "rewrite",
|
||||
To: "chat +group-members",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-search",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-search",
|
||||
Reviewed: true,
|
||||
ReviewReason: "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-send-text",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +send-to-group", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-group-bots",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-my-groups",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-robot",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-robots",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +members",
|
||||
Mode: "rewrite",
|
||||
To: "chat +group-members",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent.",
|
||||
},
|
||||
{
|
||||
From: "chat +message-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +read-single",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-list-direct", "chat +chat-messages"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch.",
|
||||
},
|
||||
{
|
||||
From: "chat +rename-group",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-update",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility.",
|
||||
},
|
||||
{
|
||||
From: "chat +send",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-by-bot",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send-by-bot"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-dm",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +dm", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-file",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-image",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-media",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-message",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-single",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +dm", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-text",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-to",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically.",
|
||||
},
|
||||
{
|
||||
From: "doc +create-version",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation.",
|
||||
},
|
||||
{
|
||||
From: "doc +list-templates",
|
||||
Mode: "rewrite",
|
||||
To: "doc +template-list",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation.",
|
||||
},
|
||||
{
|
||||
From: "doc +save-version",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target.",
|
||||
},
|
||||
{
|
||||
From: "doc +search-template",
|
||||
Mode: "rewrite",
|
||||
To: "doc +template-search",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value.",
|
||||
},
|
||||
{
|
||||
From: "doc +snapshot",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow.",
|
||||
},
|
||||
{
|
||||
From: "doc +template",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows.",
|
||||
},
|
||||
{
|
||||
From: "doc +version",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop.",
|
||||
},
|
||||
{
|
||||
From: "doc +version-create",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative.",
|
||||
},
|
||||
{
|
||||
From: "oa +list-processes",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
|
||||
},
|
||||
}
|
||||
|
||||
func loadGeneratedCommandPathFallbacks() []CommandPathFallback {
|
||||
return generatedCommandPathFallbacks
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
commandPathFallbackIndexOnce sync.Once
|
||||
commandPathFallbackIndex map[string]CommandPathFallback
|
||||
)
|
||||
|
||||
func buildCommandPathFallbackIndex() {
|
||||
entries := loadGeneratedCommandPathFallbacks()
|
||||
commandPathFallbackIndex = make(map[string]CommandPathFallback, len(entries))
|
||||
for _, entry := range entries {
|
||||
commandPathFallbackIndex[entry.From] = entry
|
||||
}
|
||||
}
|
||||
|
||||
// LookupCommandPathFallback performs an exact O(1) lookup of a reviewed
|
||||
// recovery-only path. Normalization only removes a leading dws and folds
|
||||
// whitespace; it does not apply prefix, typo, or semantic matching.
|
||||
func LookupCommandPathFallback(rawPath string) (CommandPathFallback, bool) {
|
||||
commandPathFallbackIndexOnce.Do(buildCommandPathFallbackIndex)
|
||||
entry, ok := commandPathFallbackIndex[normalizeSchemaCLIPath(rawPath)]
|
||||
if !ok {
|
||||
return CommandPathFallback{}, false
|
||||
}
|
||||
entry.Candidates = append([]string(nil), entry.Candidates...)
|
||||
return entry, true
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func commandFallbackJSON(entries string) []byte {
|
||||
return []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":1,"entries":` + entries + `}`)
|
||||
}
|
||||
|
||||
func validCommandFallbackEntry(from, to string) string {
|
||||
return `{"from":"` + from + `","mode":"rewrite","to":"` + to + `","reviewed":true,"review_reason":"reviewed fixture"}`
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDecodeCommandPathFallbacksStrictContract(t *testing.T) {
|
||||
valid := commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]`)
|
||||
entries, err := decodeCommandPathFallbacks(valid)
|
||||
if err != nil || len(entries) != 1 || entries[0].From != "chat +bad" || entries[0].To != "chat +good" {
|
||||
t.Fatalf("decode valid = %#v, %v", entries, err)
|
||||
}
|
||||
entries[0].Candidates = append(entries[0].Candidates, "mutated")
|
||||
cloned := cloneCommandPathFallbacks(entries)
|
||||
cloned[0].Candidates[0] = "clone"
|
||||
if entries[0].Candidates[0] != "mutated" {
|
||||
t.Fatal("cloneCommandPathFallbacks aliases candidate storage")
|
||||
}
|
||||
|
||||
tests := map[string]struct {
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
"unknown field": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":"x","extra":true}]`),
|
||||
want: "unknown field",
|
||||
},
|
||||
"wrong schema": {
|
||||
data: []byte(`{"$schema":"wrong","version":1,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
||||
want: "$schema",
|
||||
},
|
||||
"wrong version": {
|
||||
data: []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":2,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
||||
want: "unsupported",
|
||||
},
|
||||
"empty entries": {data: commandFallbackJSON(`[]`), want: "no entries"},
|
||||
"unnormalized path": {
|
||||
data: commandFallbackJSON(`[` + validCommandFallbackEntry(` chat +bad `, `chat +good`) + `]`),
|
||||
want: "not a normalized",
|
||||
},
|
||||
"unreviewed": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":false,"review_reason":"x"}]`),
|
||||
want: "reviewed=true",
|
||||
},
|
||||
"empty reason": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":" "}]`),
|
||||
want: "reviewed=true",
|
||||
},
|
||||
"invalid mode": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"guess","reviewed":true,"review_reason":"x"}]`),
|
||||
want: "invalid mode",
|
||||
},
|
||||
"rewrite candidates": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "must not declare candidates",
|
||||
},
|
||||
"empty rewrite target": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","reviewed":true,"review_reason":"x"}]`),
|
||||
want: "path is empty",
|
||||
},
|
||||
"ambiguous target": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "must not declare to",
|
||||
},
|
||||
"ambiguous too small": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "at least two",
|
||||
},
|
||||
"empty ambiguous candidate": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one",""],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "path is empty",
|
||||
},
|
||||
"duplicate candidate": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one","chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "repeats candidate",
|
||||
},
|
||||
"duplicate from": {
|
||||
data: commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `,` + validCommandFallbackEntry(`chat +bad`, `chat +other`) + `]`),
|
||||
want: "duplicate from",
|
||||
},
|
||||
"multiple documents": {
|
||||
data: append(valid, []byte(` {}`)...),
|
||||
want: "multiple JSON values",
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := decodeCommandPathFallbacks(test.data); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("decode error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(embeddedCommandPathFallbacksSchemaJSON, &schema); err != nil {
|
||||
t.Fatalf("decode embedded schema: %v", err)
|
||||
}
|
||||
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
|
||||
t.Fatalf("schema header = %#v", schema)
|
||||
}
|
||||
entries, err := LoadCommandPathFallbacks()
|
||||
if err != nil || len(entries) != 34 {
|
||||
t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err)
|
||||
}
|
||||
if got, ok := LookupCommandPathFallback("dws chat +group-search"); !ok || got.To != "chat +chat-search" {
|
||||
t.Fatalf("generated lookup = %#v, %v", got, ok)
|
||||
}
|
||||
if _, ok := LookupCommandPathFallback("chat +missing"); ok {
|
||||
t.Fatal("generated lookup accepted unknown path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
|
||||
rewrites := map[string]string{
|
||||
"chat +bot-list": "chat +chat-bots",
|
||||
"chat +conversation-detail": "chat +conversation-info",
|
||||
"chat +group-search": "chat +chat-search",
|
||||
"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",
|
||||
"doc +create-version": "doc +history-save",
|
||||
"doc +list-templates": "doc +template-list",
|
||||
"doc +save-version": "doc +history-save",
|
||||
"doc +search-template": "doc +template-search",
|
||||
"doc +snapshot": "doc +history-save",
|
||||
"doc +version-create": "doc +history-save",
|
||||
}
|
||||
for from, to := range rewrites {
|
||||
entry, ok := LookupCommandPathFallback(from)
|
||||
if !ok || entry.Mode != CommandPathFallbackRewrite || entry.To != to {
|
||||
t.Errorf("rewrite %q = %#v, %v; want %q", from, entry, ok, to)
|
||||
}
|
||||
}
|
||||
|
||||
ambiguous := map[string][]string{
|
||||
"chat +conversation-category-list": {"chat +category-list", "chat +category-list-conversations"},
|
||||
"chat +conversation-group-list": {"chat +category-list-conversations", "chat +conversation-list"},
|
||||
"chat +group-send-text": {"chat +send-to-group", "chat +messages-send"},
|
||||
"chat +list-my-groups": {"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
|
||||
"chat +message-list": {"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
|
||||
"chat +read-single": {"chat +messages-list-direct", "chat +chat-messages"},
|
||||
"chat +send": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-by-bot": {"chat +messages-send", "chat message send-by-bot"},
|
||||
"chat +send-dm": {"chat +dm", "chat +messages-send"},
|
||||
"chat +send-message": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-single": {"chat +dm", "chat +messages-send"},
|
||||
"chat +send-text": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-to": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-file": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-image": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-media": {"chat +messages-send", "chat message send"},
|
||||
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
}
|
||||
for from, candidates := range ambiguous {
|
||||
entry, ok := LookupCommandPathFallback(from)
|
||||
if !ok || entry.Mode != CommandPathFallbackAmbiguous || !reflect.DeepEqual(entry.Candidates, candidates) {
|
||||
t.Errorf("ambiguous %q = %#v, %v; want %v", from, entry, ok, candidates)
|
||||
}
|
||||
}
|
||||
|
||||
if entry, ok := LookupCommandPathFallback("chat +definitely-unknown"); ok {
|
||||
t.Errorf("unreviewed path unexpectedly falls back: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceCommandPathFallbacksValidatesLiveTree(t *testing.T) {
|
||||
root := commandFallbackTestRoot()
|
||||
entries := []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good", Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat group search", Mode: CommandPathFallbackRewrite, To: "chat search", Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +other"}, Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat +mixed", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat search"}, Reviewed: true, ReviewReason: "fixture"},
|
||||
}
|
||||
withCommandFallbackSource(t, entries)
|
||||
got, err := ReduceCommandPathFallbacks(root)
|
||||
if err != nil || len(got) != len(entries) {
|
||||
t.Fatalf("ReduceCommandPathFallbacks() = %#v, %v", got, err)
|
||||
}
|
||||
got[2].Candidates[0] = "mutated"
|
||||
again, err := ReduceCommandPathFallbacks(root)
|
||||
if err != nil || again[2].Candidates[0] == "mutated" {
|
||||
t.Fatalf("reduction did not clone candidates: %#v, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceCommandPathFallbacksRejectsUnsafeMappings(t *testing.T) {
|
||||
baseRoot := commandFallbackTestRoot
|
||||
tests := map[string]struct {
|
||||
entries []CommandPathFallback
|
||||
mutate func(*cobra.Command)
|
||||
want string
|
||||
}{
|
||||
"missing target": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +missing"}},
|
||||
want: "does not exist",
|
||||
},
|
||||
"real source collision": {
|
||||
entries: []CommandPathFallback{{From: "chat +other", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
want: "collides with a real Cobra command or alias",
|
||||
},
|
||||
"cobra alias collision": {
|
||||
entries: []CommandPathFallback{{From: "chat +official-alias", Mode: CommandPathFallbackRewrite, To: "chat +other"}},
|
||||
want: "collides with a real Cobra command or alias",
|
||||
},
|
||||
"cross service": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "mail +good"}},
|
||||
want: "crosses service boundary",
|
||||
},
|
||||
"shortcut mismatch": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat search"}},
|
||||
want: "disagree on +shortcut identity",
|
||||
},
|
||||
"chained rewrite": {
|
||||
entries: []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +next"},
|
||||
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +good"},
|
||||
},
|
||||
want: "chained fallbacks are forbidden",
|
||||
},
|
||||
"chained ambiguous candidate": {
|
||||
entries: []CommandPathFallback{
|
||||
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +next"}},
|
||||
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +other"},
|
||||
},
|
||||
want: "is another fallback source",
|
||||
},
|
||||
"missing ambiguous candidate": {
|
||||
entries: []CommandPathFallback{{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +missing"}}},
|
||||
want: "does not exist",
|
||||
},
|
||||
"hidden target": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
exactSchemaCommand(root, "chat +good").Hidden = true
|
||||
},
|
||||
want: "not a public runnable",
|
||||
},
|
||||
"target is alias": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +official-alias"}},
|
||||
want: "must use canonical Cobra names",
|
||||
},
|
||||
"ambiguous source command": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
chat := exactSchemaCommand(root, "chat")
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
},
|
||||
want: "cannot be resolved safely",
|
||||
},
|
||||
"ambiguous target command": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +duplicate"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
chat := exactSchemaCommand(root, "chat")
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
},
|
||||
want: "target \"chat +duplicate\" cannot be resolved safely",
|
||||
},
|
||||
"unsupported reduced mode": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackMode("invalid")}},
|
||||
want: "unsupported mode",
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := baseRoot()
|
||||
if test.mutate != nil {
|
||||
test.mutate(root)
|
||||
}
|
||||
for index := range test.entries {
|
||||
test.entries[index].Reviewed = true
|
||||
test.entries[index].ReviewReason = "fixture"
|
||||
}
|
||||
withCommandFallbackSource(t, test.entries)
|
||||
if _, err := ReduceCommandPathFallbacks(root); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("reduction error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
withCommandFallbackLoadError(t, errors.New("fixture load"))
|
||||
if _, err := ReduceCommandPathFallbacks(baseRoot()); err == nil || !strings.Contains(err.Error(), "fixture load") {
|
||||
t.Fatalf("load error = %v", err)
|
||||
}
|
||||
if _, err := ReduceCommandPathFallbacks(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
|
||||
t.Fatalf("nil root error = %v", err)
|
||||
}
|
||||
if got := commandFallbackService(""); got != "" {
|
||||
t.Fatalf("commandFallbackService(empty) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func commandFallbackTestRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
good := &cobra.Command{Use: "+good", Aliases: []string{"+official-alias"}, Run: func(*cobra.Command, []string) {}}
|
||||
chat.AddCommand(
|
||||
good,
|
||||
&cobra.Command{Use: "+other", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "search", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
group := &cobra.Command{Use: "group"}
|
||||
group.AddCommand(cmdutil.HintSubCmd("search", "use chat search"))
|
||||
chat.AddCommand(group)
|
||||
mail := &cobra.Command{Use: "mail"}
|
||||
mail.AddCommand(&cobra.Command{Use: "+good", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(chat, mail)
|
||||
return root
|
||||
}
|
||||
|
||||
func withCommandFallbackSource(t *testing.T, entries []CommandPathFallback) {
|
||||
t.Helper()
|
||||
old := loadReviewedCommandPathFallbacks
|
||||
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) {
|
||||
return cloneCommandPathFallbacks(entries), nil
|
||||
}
|
||||
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
||||
}
|
||||
|
||||
func withCommandFallbackLoadError(t *testing.T, want error) {
|
||||
t.Helper()
|
||||
old := loadReviewedCommandPathFallbacks
|
||||
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { return nil, want }
|
||||
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackGeneratedTableWellFormed(t *testing.T) {
|
||||
entries := loadGeneratedCommandPathFallbacks()
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("generated command path fallback table is empty")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if seen[entry.From] || !entry.Reviewed || strings.TrimSpace(entry.ReviewReason) == "" {
|
||||
t.Fatalf("invalid generated entry: %#v", entry)
|
||||
}
|
||||
seen[entry.From] = true
|
||||
}
|
||||
if !reflect.DeepEqual(entries, cloneCommandPathFallbacks(entries)) {
|
||||
t.Fatal("generated table clone changed values")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,44 +22,13 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "reply"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("text", "", "text")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
|
||||
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
}
|
||||
metadata := runtimeSchemaMetadataSources{
|
||||
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
|
||||
"text": {Type: "string"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
|
||||
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("mode", "", "usage")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "mode", "mode", "string", false)
|
||||
@@ -68,6 +38,10 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
||||
Effect: "write", Confirmation: "user_required", Idempotency: "none",
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string","description":"Created object ID"}}}`),
|
||||
},
|
||||
Selection: &contract.SelectionSpec{
|
||||
AgentSummary: "from contract",
|
||||
UseWhen: []string{"create things"},
|
||||
@@ -101,6 +75,9 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
||||
if spec.DryRun == nil || spec.DryRun.PreviewKind != contract.DryRunPreviewInvocation {
|
||||
t.Fatalf("dry_run = %#v", spec.DryRun)
|
||||
}
|
||||
if spec.Result == nil || string(spec.Result.DataSchema) != `{"properties":{"id":{"type":"string","description":"Created object ID"}},"type":"object"}` {
|
||||
t.Fatalf("result = %#v", spec.Result)
|
||||
}
|
||||
if spec.Selection.AgentSummary != "from contract" {
|
||||
t.Fatalf("selection = %#v", spec.Selection)
|
||||
}
|
||||
@@ -112,6 +89,39 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeToolSpecHidesUnifiedResultForInactiveRollout(t *testing.T) {
|
||||
for _, state := range []output.RolloutState{output.RolloutLegacyOnly, output.RolloutDualValidate} {
|
||||
t.Run(string(state), func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
output.SetCommandRollout(cmd, state)
|
||||
cmd.Flags().String("cursor", "", "cursor")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "cursor", "cursor", "string", false)
|
||||
final := contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "list_things", CanonicalPath: "dev.list_things",
|
||||
CLIPath: "dev list", PrimaryCLIPath: "dev list",
|
||||
},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{"type":"object"}`),
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||
}
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev", ToolName: "list_things", CLIName: "list",
|
||||
CLIPath: "dev list", PrimaryCLIPath: "dev list", ProductName: "Dev", Command: cmd,
|
||||
}
|
||||
spec, err := runtimeToolSpecFromContractFinal(entry, final, runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Result != nil || spec.Pagination != nil {
|
||||
t.Fatalf("inactive rollout published result=%#v pagination=%#v", spec.Result, spec.Pagination)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalIdentityMismatchFails(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
@@ -318,7 +328,7 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFa
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
|
||||
oldParameters := resolveRuntimeParameters
|
||||
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return nil, errors.New("parameters failed")
|
||||
}
|
||||
entry := runtimeSchemaEntry{
|
||||
|
||||
+5
-1
@@ -27,7 +27,9 @@
|
||||
// 3. schema_parameter_mapping_ledger.go mapping exclusions / removals (Go)
|
||||
// (active bindings retired; ParamDecl.Property owns delivery)
|
||||
// 4. param_concepts.json + schema reviewed parameter synonym policy
|
||||
// 5. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
// 5. command_path_fallbacks.json + schema
|
||||
// reviewed recovery-only invalid paths
|
||||
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
//
|
||||
// schema_hints/, schema_agent_metadata/, schema_command_registry/,
|
||||
// schema_mcp_metadata.json, and schema_mcp_service_review(.json|/ledger) are
|
||||
@@ -35,7 +37,9 @@
|
||||
//
|
||||
// Remaining generated output from this file:
|
||||
// - param_aliases_generated.go per-command parameter normalization
|
||||
// - command_path_fallbacks_generated.go invalid-path recovery normalization
|
||||
|
||||
package cli
|
||||
|
||||
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
|
||||
//go:generate go run ../generator/cmd_command_path_fallbacks -root ../.. -output command_path_fallbacks_generated.go
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
func TestCrossPlatformCoverageGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read internal/cli: %v", err)
|
||||
@@ -50,6 +50,7 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
for _, generator := range []string{
|
||||
"cmd_schema_catalog",
|
||||
"cmd_param_aliases",
|
||||
"cmd_command_path_fallbacks",
|
||||
} {
|
||||
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
|
||||
t.Errorf("gen.go does not register %s", generator)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,35 +10,41 @@
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
|
||||
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
|
||||
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
|
||||
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
|
||||
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
|
||||
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
|
||||
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
|
||||
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
|
||||
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat +chat-get-by-id", "chat group get-by-group-id"], "risk": "yellow"},
|
||||
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +category-add-conversation", "chat +category-remove-conversation", "chat +chat-add-bot", "chat +chat-audit-join", "chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-members-get", "chat +chat-mute", "chat +chat-mute-member", "chat +chat-quit", "chat +chat-remove-bot", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-remove", "chat +chat-role-remove-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-transfer-owner", "chat +chat-update-alias", "chat +chat-update-icon", "chat +chat-update-nick", "chat +chat-update-settings", "chat +conversation-clear-messages", "chat +conversation-clear-red-point", "chat +conversation-hide", "chat +conversation-info", "chat +conversation-mark-read", "chat +conversation-mark-unread", "chat +conversation-mute", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-list-pin", "chat +messages-read-status", "chat +messages-recall-by-bot", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-reply", "chat +messages-resource-download", "chat +messages-resource-url", "chat +messages-send-by-bot", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
|
||||
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
|
||||
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
|
||||
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
|
||||
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +conversation-mark-read", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-forward", "chat +messages-read-status", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-resource-download", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"},
|
||||
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
|
||||
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
|
||||
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
|
||||
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
|
||||
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
@@ -91,10 +97,24 @@
|
||||
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc +comment-create": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
|
||||
"doc +comment-reply": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
|
||||
"doc media insert": {"scoped_aliases": {"ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "block": ["before-block-id", "parent-block", "parent-block-id"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Media insertion supports a reference block but no parent-block role. --before-block-id additionally needs --where before; role-free --block-id is left ambiguous."},
|
||||
"doc read": {"block": ["before-block-id", "parent-block-id", "ref-block-id", "reference-block-id"], "ambiguous": ["block-id"], "note": "A section read requires --scope section plus a start/end boundary. A role-free --block-id cannot be reduced to one flag without inventing the missing scope/boundary role."},
|
||||
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
|
||||
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
|
||||
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
|
||||
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
|
||||
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
|
||||
@@ -111,7 +131,48 @@
|
||||
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
|
||||
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
|
||||
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
|
||||
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +chat-add-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is the group's openConversationId; robotCode and openBotId remain different domains."},
|
||||
"chat +chat-audit-join": {"scoped_aliases": {"applicant-user-id": "applicant", "inviter-user-id": "inviter"}, "ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "scope_strict": true, "note": "A role-free user identifier cannot choose between applicant and inviter."},
|
||||
"chat +chat-create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users field is a list and may contain mixed identifier domains; a singular value is not promoted."},
|
||||
"chat +chat-get-by-id": {"block": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "note": "The real --group-id is numeric groupId; no CID or group-name spelling can be value-preservingly converted."},
|
||||
"chat +chat-members-get": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["group", "group-name", "user-id", "user-ids"], "note": "The real --id is openConversationId and --users is an openDingTalkId list. The observed --group spelling carried a natural group name and is blocked; explicit CID spellings and --chat remain value-preserving aliases."},
|
||||
"chat +chat-members-list": {"scoped_aliases": {"chat-id": "conversation-id", "id": "conversation-id"}, "block": ["query", "keyword", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "Native --chat/--open-conversation-id stay native; member filtering by query is unsupported and group-name resolution stays on --group/--chat-query."},
|
||||
"chat +chat-mute-member": {"scoped_aliases": {"user-ids": "users", "open-dingtalk-ids": "users"}, "block": ["user", "user-id", "open-dingtalk-id"], "scope_strict": true, "note": "The target accepts a mixed identifier list; list spellings preserve values, but singular inputs are not promoted."},
|
||||
"chat +chat-remove-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is openConversationId; --bot-id is separately governed by open_bot_id."},
|
||||
"chat +chat-role-remove": {"block": ["role-ids"], "note": "The command removes one role ID; list cardinality is not reduced."},
|
||||
"chat +chat-role-remove-user": {"scoped_aliases": {"user-id": "user", "open-dingtalk-id": "user"}, "block": ["role-id"], "scope_strict": true, "note": "The single --user accepts either identifier domain; --role-ids remains a list."},
|
||||
"chat +chat-transfer-owner": {"scoped_aliases": {"user-id": "new-owner", "open-dingtalk-id": "new-owner"}, "scope_strict": true, "note": "The only user role is the new owner, and the target accepts either userId or openDingTalkId without changing the value."},
|
||||
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
|
||||
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
|
||||
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
|
||||
"chat +flag-list": {"scoped_aliases": {"limit": "page-size"}, "block": ["max", "max-results", "max-size", "count", "page", "per-page"], "scope_strict": true, "note": "Native --page-size is the canonical page bound, native --size is its command-owned compatibility alias, and --limit is reviewed as value-preservingly equivalent to --page-size; total-count and page-number spellings are not equivalent."},
|
||||
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
|
||||
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
|
||||
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
|
||||
"chat +messages-forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles and the source-message role remain explicit; role-free message IDs and list cardinality are not inferred."},
|
||||
"chat +messages-list": {"scoped_aliases": {"start": "time"}, "block": ["before", "before-time", "end", "direction", "page-all", "count", "max", "max-results", "max-size", "page-size"], "scope_strict": true, "note": "start preserves the same boundary value; before/direction require multi-parameter or value transforms and page-all requires iteration. Native --conversation-id/--id/--size remain native."},
|
||||
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
|
||||
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
|
||||
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
|
||||
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
|
||||
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
|
||||
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
@@ -258,8 +319,117 @@
|
||||
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
|
||||
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "doc +comment-create", "emitted": "node-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +doc-append", "emitted": "node", "expect": "doc", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +find-doc", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
|
||||
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc comment reply", "emitted": "mentioned-open-conversation-ids", "expect": "mentioned-open-conversation-id", "via": "override:scoped-role-list"},
|
||||
{"command": "doc comment reply", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:numeric-group-id-vs-open-conversation-id"},
|
||||
{"command": "doc +comment-create", "emitted": "mentioned-open-conversation-id", "expect": "did-you-mean:blocked", "via": "guard:shortcut-missing-capability"},
|
||||
{"command": "doc block insert", "emitted": "parent-block-id", "expect": "parent-block", "via": "override:scoped-block-role"},
|
||||
{"command": "doc block insert", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:parent-vs-reference-block-role"},
|
||||
{"command": "doc media insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-plus-where"},
|
||||
{"command": "doc read", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:requires-scope-and-boundary-role"},
|
||||
{"command": "doc export get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "guard:document-node-vs-export-job"},
|
||||
{"command": "doc import get", "emitted": "job-id", "expect": "did-you-mean:blocked", "via": "guard:export-job-vs-import-task"},
|
||||
{"command": "doc +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "concept:doc_version_number+exclude"},
|
||||
{"command": "doc update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +share-doc", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
|
||||
{"command": "doc +comment-create", "emitted": "body", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc +doc-append", "emitted": "content", "expect": "text", "via": "concept:content_text"},
|
||||
{"command": "doc +export-submit", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +move", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +template-list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +version-list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +version-save", "emitted": "file-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc comment create", "emitted": "text", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc comment create-inline", "emitted": "body", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc comment delete", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc comment update", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc version revert", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +chat-update", "emitted": "chat-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "open-conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
|
||||
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +flag-list", "emitted": "limit", "expect": "page-size", "via": "override:scoped-page-bound"},
|
||||
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +chat-members-list", "emitted": "chat-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-members-list", "emitted": "id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-members-list", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:unsupported-member-filter"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "groups", "expect": "did-you-mean:blocked", "via": "guard:group-name-or-list-ambiguity"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "top", "expect": "did-you-mean:blocked", "via": "guard:inverse-boolean-semantics"},
|
||||
{"command": "chat +chat-members-get", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-members-get", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
|
||||
{"command": "chat +chat-members-get", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +chat-members-get", "emitted": "chat", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-get-by-id", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-numeric-group-id"},
|
||||
{"command": "chat +messages-list", "emitted": "start", "expect": "time", "via": "override:scoped-time-boundary"},
|
||||
{"command": "chat +messages-list", "emitted": "count", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +messages-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +messages-list", "emitted": "page-all", "expect": "did-you-mean:blocked", "via": "guard:requires-pagination-loop"},
|
||||
{"command": "chat +messages-reply", "emitted": "msg-id", "expect": "ref-msg-id", "via": "override:scoped-reference-message"},
|
||||
{"command": "chat +messages-reply", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +messages-reply", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-cancel", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-cancel", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +chat-mute-member", "emitted": "user-ids", "expect": "users", "via": "override:scoped-mixed-id-list"},
|
||||
{"command": "chat +chat-mute-member", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +chat-remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
|
||||
{"command": "chat +chat-role-remove", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "guard:list-vs-single"},
|
||||
{"command": "chat +chat-role-remove-user", "emitted": "open-dingtalk-id", "expect": "user", "via": "override:scoped-mixed-id"},
|
||||
{"command": "chat +chat-transfer-owner", "emitted": "user-id", "expect": "new-owner", "via": "override:scoped-owner-role"},
|
||||
{"command": "chat +feed-group-query-item", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
|
||||
{"command": "chat +feed-group-query-item", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +messages-batch-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
|
||||
{"command": "chat +messages-combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-combine-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
|
||||
{"command": "chat +messages-forward", "emitted": "source-message-id", "expect": "msg-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
|
||||
{"command": "chat +messages-forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-source-role"},
|
||||
{"command": "chat +messages-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
|
||||
{"command": "chat +messages-resource-download", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +messages-resource-download", "emitted": "download-dir", "expect": "did-you-mean:blocked", "via": "guard:output-file-or-directory-contract"},
|
||||
{"command": "chat +messages-set-pin", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
|
||||
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
|
||||
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
|
||||
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
|
||||
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
|
||||
{"command": "doc +fetch", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:start-vs-end-boundary-role"},
|
||||
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
|
||||
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+24
-140
@@ -89,9 +89,9 @@ func RegisterRuntimeSchemaConstraints(canonicalPath string, constraints RuntimeS
|
||||
}
|
||||
|
||||
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
|
||||
// schema_mcp_metadata.json is deleted; production assembly does not embed or
|
||||
// load a pinned MCP snapshot. Test fixtures may still inject non-empty maps
|
||||
// through schemaRegistryForTestWithMetadata.
|
||||
// schema_mcp_metadata.json is deleted; Schema parameter assembly never loads
|
||||
// or ranks MCP pin candidates. Optional Interface-registry validators may
|
||||
// still pass this empty shape when they only need ContractFinal self-checks.
|
||||
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
|
||||
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
|
||||
}
|
||||
@@ -217,62 +217,6 @@ func collectRuntimeSchemaEntriesFromBound(bound BoundCommandRegistry) ([]runtime
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func pinnedMCPMetadataForEntryFrom(entry runtimeSchemaEntry, agentMetadata agentMetadata, mcpMetadata embeddedMCPMetadata) (embeddedMCPToolMetadata, bool) {
|
||||
// Optional test/diagnostic lookup only. Production mcpMetadata is empty;
|
||||
// Contract/ParamDecl own interface facts. When a non-empty fixture is
|
||||
// injected, ContractFinal Interface.Ref remaps CLI canonical names onto
|
||||
// fixture keys (e.g. reply_personal_message → chat.send_personal_message).
|
||||
if len(mcpMetadata.Tools) == 0 {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
if entry.Command != nil {
|
||||
if final, ok := RuntimeContractFinal(entry.Command); ok && final.Interface != nil && final.Interface.Ref != nil {
|
||||
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, final.Interface.Ref.ProductID, final.Interface.Ref.RPCName); found {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
}
|
||||
paths := []string{
|
||||
entry.PrimaryCLIPath,
|
||||
entry.CLIPath,
|
||||
entry.ProductID + "." + entry.ToolName,
|
||||
}
|
||||
paths = append(paths, entry.Aliases...)
|
||||
if toolMetadata, ok := lookupAgentToolMetadataFrom(agentMetadata, paths...); ok && toolMetadata.InterfaceRef != nil {
|
||||
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, toolMetadata.InterfaceRef.ProductID, toolMetadata.InterfaceRef.RPCName); found {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{
|
||||
entry.SourceProductID + "." + entry.ToolName,
|
||||
entry.ProductID + "." + entry.ToolName,
|
||||
} {
|
||||
key = strings.Trim(key, ".")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if meta, ok := mcpMetadata.Tools[key]; ok {
|
||||
return meta, true
|
||||
}
|
||||
}
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
|
||||
func mcpMetadataForInterfaceRef(mcpMetadata embeddedMCPMetadata, productID, rpcName string) (embeddedMCPToolMetadata, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
rpcName = strings.TrimSpace(rpcName)
|
||||
key := strings.Trim(productID+"."+rpcName, ".")
|
||||
if key == "" {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
metadata, exists := mcpMetadata.Tools[key]
|
||||
if !exists {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
metadata.InterfaceRef = &embeddedMCPInterfaceRef{ProductID: productID, RPCName: rpcName}
|
||||
return metadata, true
|
||||
}
|
||||
|
||||
func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source string) {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return "", "", ""
|
||||
@@ -322,7 +266,6 @@ const (
|
||||
runtimeSchemaRankDefault = 0
|
||||
runtimeSchemaRankDerived = 50
|
||||
runtimeSchemaRankInference = 100
|
||||
runtimeSchemaRankMCP = 400
|
||||
runtimeSchemaRankCobraHelp = 450
|
||||
runtimeSchemaRankCobraDefault = 600
|
||||
runtimeSchemaRankCobraContract = 610
|
||||
@@ -332,7 +275,7 @@ const (
|
||||
runtimeSchemaRankVersionedBinding = 650
|
||||
// ParamDecl.Property (dws.schema.property) outranks residual versioned
|
||||
// binding candidates (active bindings JSON is empty after Phase 2).
|
||||
// Mapping exclusions stay highest so an explicit "no MCP property" review
|
||||
// Mapping exclusions stay highest so an explicit "no RPC property" review
|
||||
// cannot be overridden by a leaf ParamDecl that still carries a Property.
|
||||
runtimeSchemaRankParamDeclProperty = 655
|
||||
runtimeSchemaRankMappingExclusion = 660
|
||||
@@ -340,7 +283,6 @@ const (
|
||||
runtimeSchemaPrecedenceDefault = "default"
|
||||
runtimeSchemaPrecedenceDerived = "derived_resolution"
|
||||
runtimeSchemaPrecedenceInference = "inference"
|
||||
runtimeSchemaPrecedenceMCP = "mcp_metadata"
|
||||
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
|
||||
runtimeSchemaPrecedenceCobra = "cobra_contract"
|
||||
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
|
||||
@@ -533,8 +475,6 @@ func runtimeSchemaSourcePriority(source string) (int, string) {
|
||||
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
|
||||
}
|
||||
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
|
||||
case "mcp_metadata", "pinned_mcp_metadata":
|
||||
return runtimeSchemaRankMCP, runtimeSchemaPrecedenceMCP
|
||||
case "cobra_help":
|
||||
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
|
||||
case "flag_name_inference", "usage_required_inference", "usage_format_inference":
|
||||
@@ -575,9 +515,9 @@ func runtimeSchemaParameterMappingKey(canonicalPath, flagName string) string {
|
||||
|
||||
// runtimeSchemaParameterMappingCandidates resolves the two reviewed,
|
||||
// versioned property-mapping inputs. An exclusion is an explicit statement
|
||||
// that the CLI parameter is not a direct MCP property: it therefore supplies
|
||||
// a present empty candidate (rather than allowing name inference to survive)
|
||||
// and keeps the review reason in provenance.
|
||||
// that the CLI parameter is not a direct RPC/interface property: it therefore
|
||||
// supplies a present empty candidate (rather than allowing name inference to
|
||||
// survive) and keeps the review reason in provenance.
|
||||
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
|
||||
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
|
||||
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
|
||||
@@ -605,34 +545,24 @@ func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnap
|
||||
type runtimeParameterFieldContext struct {
|
||||
flag *pflag.Flag
|
||||
metadata RuntimeSchemaParameterMetadata
|
||||
pinnedParam embeddedMCPParamMeta
|
||||
hasPinned bool
|
||||
paramType string
|
||||
constraints RuntimeSchemaConstraints
|
||||
property string
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagTypeAnnotation), "native_annotation"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Type, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates,
|
||||
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
|
||||
runtimeSchemaStringCandidate(c.flag.Usage, "cobra_usage"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Description, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate("", true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldCandidate {
|
||||
@@ -649,7 +579,7 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
|
||||
break
|
||||
}
|
||||
}
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
constraintRequired,
|
||||
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
|
||||
runtimeSchemaAnnotatedBoolCandidate(c.flag, runtimeSchemaFlagMetadataRequiredAnnotation, "typed_parameter_metadata"),
|
||||
@@ -657,50 +587,36 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
|
||||
runtimeSchemaCandidate(true, runtimeFlagCobraHardRequired(c.flag), "cobra_hard_required"),
|
||||
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
|
||||
runtimeSchemaCandidate(usageRequired, usageRequired, "usage_required_inference"),
|
||||
runtimeSchemaCandidate(false, true, "default"),
|
||||
}
|
||||
if c.hasPinned && c.pinnedParam.Required != nil {
|
||||
candidates = append(candidates, runtimeSchemaCandidate(*c.pinnedParam.Required, true, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate(false, true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) requiredWhenCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagRequiredWhenAnnotation), "native_annotation"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.RequiredWhen, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate("", true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) formatCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, "x-cli-format"), "native_annotation"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Format, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates,
|
||||
runtimeSchemaStringCandidate(inferredRuntimeFlagFormat(c.flag), "usage_format_inference"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaEnumCandidate(runtimeFlagEnum(c.flag), "native_annotation"),
|
||||
runtimeSchemaCandidate([]string{}, true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaEnumCandidate(c.pinnedParam.Enum, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate([]string{}, true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCandidate {
|
||||
@@ -717,7 +633,8 @@ func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCa
|
||||
// source may intentionally raise or lower type/mapping/description semantics.
|
||||
// required is different: Cobra MarkFlagRequired is a hard floor that no
|
||||
// lower-priority source may demote (see resolveRequiredProjection).
|
||||
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
// MCP pin / mcp_metadata is not a candidate source.
|
||||
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
if cmd == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -758,18 +675,10 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
return
|
||||
}
|
||||
property, _ := propertyWinner.Value.(string)
|
||||
// pinnedParams remains for test fixtures that inject MCP-shaped maps;
|
||||
// production assembly always passes an empty map (pin retired).
|
||||
pinnedParam, hasPinnedParam := embeddedMCPParamMeta{}, false
|
||||
if len(pinnedParams) > 0 && strings.TrimSpace(property) != "" {
|
||||
pinnedParam, hasPinnedParam = lookupPinnedMCPParam(pinnedParams, property, flag.Name)
|
||||
}
|
||||
paramType := runtimeFlagCLIType(flag)
|
||||
fieldCtx := runtimeParameterFieldContext{
|
||||
flag: flag,
|
||||
metadata: metadata,
|
||||
pinnedParam: pinnedParam,
|
||||
hasPinned: hasPinnedParam,
|
||||
paramType: paramType,
|
||||
constraints: constraints,
|
||||
property: property,
|
||||
@@ -794,10 +703,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
return
|
||||
}
|
||||
description, _ := descriptionWinner.Value.(string)
|
||||
interfaceDescription := ""
|
||||
if hasPinnedParam {
|
||||
interfaceDescription = strings.TrimSpace(pinnedParam.Description)
|
||||
}
|
||||
|
||||
// Required uses field-level safe merge: higher sources may raise required, but
|
||||
// Cobra MarkFlagRequired cannot be projected away as optional.
|
||||
@@ -838,9 +743,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
runtimeSchemaCandidate(true, true, "cobra_hard_required"),
|
||||
)
|
||||
}
|
||||
if interfaceDescription != "" && interfaceDescription != description {
|
||||
parameter.InterfaceDescription = interfaceDescription
|
||||
}
|
||||
if interfaceType != "" && interfaceType != paramType {
|
||||
parameter.InterfaceType = interfaceType
|
||||
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
|
||||
@@ -855,12 +757,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
if def := runtimeFlagDefault(flag); def != "" {
|
||||
parameter.Default = runtimeSchemaJSONString(def)
|
||||
}
|
||||
if hasPinnedParam {
|
||||
interfaceDefault := strings.TrimSpace(pinnedParam.Default)
|
||||
if interfaceDefault != "" && interfaceDefault != runtimeFlagDefault(flag) {
|
||||
parameter.InterfaceDefault = runtimeSchemaJSONString(interfaceDefault)
|
||||
}
|
||||
}
|
||||
formatWinner, ok := resolveField("format", fieldCtx.formatCandidates())
|
||||
if !ok {
|
||||
return
|
||||
@@ -991,19 +887,6 @@ func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool {
|
||||
return runtimeannotate.ConstraintsEmpty(constraints)
|
||||
}
|
||||
|
||||
func lookupPinnedMCPParam(params map[string]embeddedMCPParamMeta, property, flagName string) (embeddedMCPParamMeta, bool) {
|
||||
if len(params) == 0 {
|
||||
return embeddedMCPParamMeta{}, false
|
||||
}
|
||||
if meta, ok := params[property]; ok {
|
||||
return meta, true
|
||||
}
|
||||
if meta, ok := params[flagName]; ok {
|
||||
return meta, true
|
||||
}
|
||||
return embeddedMCPParamMeta{}, false
|
||||
}
|
||||
|
||||
func isGenericPayloadFlag(flag *pflag.Flag) bool {
|
||||
if flag == nil {
|
||||
return false
|
||||
@@ -1176,6 +1059,7 @@ var schemaCompactPayloadKeys = map[string]bool{
|
||||
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
|
||||
"interface_mode": true, "availability": true, "interface_reason": true,
|
||||
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
|
||||
"result": true, "pagination": true,
|
||||
"examples": true, "use_when": true, "avoid_when": true,
|
||||
}
|
||||
|
||||
@@ -1191,8 +1075,8 @@ var schemaCompactParamKeys = map[string]bool{
|
||||
|
||||
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
|
||||
// Agent-view allowlist. Structural product/tool children are projected
|
||||
// recursively; constraint, positional and dry-run values are already typed
|
||||
// contract data and are retained verbatim.
|
||||
// recursively; result, constraint, positional and dry-run values are already
|
||||
// typed contract data and are retained verbatim.
|
||||
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
|
||||
if payload == nil {
|
||||
return nil
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user