Compare commits
688 Commits
refactor/a
...
feat/opena
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa876cc95f | ||
|
|
94d9134719 | ||
|
|
a4524c215f | ||
|
|
96abb8ea22 | ||
|
|
216151ffa7 | ||
|
|
7b0660b45b | ||
|
|
a4f949f173 | ||
|
|
0ed392fb62 | ||
|
|
3f0032f20b | ||
|
|
bfd5636bf0 | ||
|
|
f79ed842fa | ||
|
|
a67cd87eb0 | ||
|
|
c005932db9 | ||
|
|
af7f6abb95 | ||
|
|
2a1211baa5 | ||
|
|
3813c4d552 | ||
|
|
8fc8178ff7 | ||
|
|
fb0e55f5e7 | ||
|
|
fb9b97b26d | ||
|
|
80b34b5ab6 | ||
|
|
8db1712ccd | ||
|
|
6dc9de24d8 | ||
|
|
9c11e36cc5 | ||
|
|
1d02154638 | ||
|
|
b4bc25cab6 | ||
|
|
948fd166dd | ||
|
|
38f42f357e | ||
|
|
f24586323b | ||
|
|
bc0279572e | ||
|
|
20d9f15933 | ||
|
|
d42276a5c0 | ||
|
|
5c4489911c | ||
|
|
657624154d | ||
|
|
be8744264f | ||
|
|
0fec636da5 | ||
|
|
ce27ef9dc3 | ||
|
|
f7e5946d81 | ||
|
|
90f7fbd468 | ||
|
|
1545517fe8 | ||
|
|
1b9ddf9ae5 | ||
|
|
9bb9935f44 | ||
|
|
1ff3d3db68 | ||
|
|
18fea7ff0d | ||
|
|
357f697b16 | ||
|
|
e6f1d77ed5 | ||
|
|
4f8bf32b72 | ||
|
|
d8187b6e51 | ||
|
|
51940eac9c | ||
|
|
005f019a80 | ||
|
|
5f6bb5e6d3 | ||
|
|
7020120197 | ||
|
|
fe5dfd756f | ||
|
|
4808976d0c | ||
|
|
1b21b8220d | ||
|
|
bf55d1f6a6 | ||
|
|
855842410a | ||
|
|
33b2f09325 | ||
|
|
4713c3853b | ||
|
|
89e6b71b2d | ||
|
|
27cdf50c7d | ||
|
|
2b88685fbd | ||
|
|
52dcf83de6 | ||
|
|
a4a18e0652 | ||
|
|
6441436b0c | ||
|
|
0c5f2507d0 | ||
|
|
2515cf5b48 | ||
|
|
5283462286 | ||
|
|
f95edc85f5 | ||
|
|
f3107fee3f | ||
|
|
c42ee2fbac | ||
|
|
fc5053edf0 | ||
|
|
d8ec14027e | ||
|
|
96f444e3c6 | ||
|
|
9d4629c946 | ||
|
|
11f6a350f1 | ||
|
|
1366cadbb4 | ||
|
|
174a908786 | ||
|
|
2062d5e892 | ||
|
|
fb7b00365c | ||
|
|
1c535791cb | ||
|
|
090f7d923c | ||
|
|
3dce05e09e | ||
|
|
a154a4d674 | ||
|
|
934662cc48 | ||
|
|
b470071c4c | ||
|
|
d6c96ce1a5 | ||
|
|
60744157d8 | ||
|
|
43f64090e1 | ||
|
|
12f65f887a | ||
|
|
725611b2e9 | ||
|
|
e6fa1a1dd0 | ||
|
|
414cce2f70 | ||
|
|
7a7dc6011a | ||
|
|
3960b57102 | ||
|
|
698ecd3e62 | ||
|
|
f4602cc1fe | ||
|
|
2d813b2a87 | ||
|
|
d86435ee1e | ||
|
|
f6a1878ceb | ||
|
|
e5b5a1cc4c | ||
|
|
6824611958 | ||
|
|
672d2e2bb7 | ||
|
|
0755abe47f | ||
|
|
50589420b1 | ||
|
|
90e590e48d | ||
|
|
a7f890129e | ||
|
|
18e0cb102b | ||
|
|
7ee0f6af7e | ||
|
|
54c2928734 | ||
|
|
ef33df5e22 | ||
|
|
edf1a57b7b | ||
|
|
4c2f022bb2 | ||
|
|
bba6aad652 | ||
|
|
0e4ecfa890 | ||
|
|
a405b00a3f | ||
|
|
a1622aea28 | ||
|
|
102497957d | ||
|
|
74af4f2308 | ||
|
|
989ba1a78f | ||
|
|
635143cb88 | ||
|
|
24c6815889 | ||
|
|
ddb9f1ba8c | ||
|
|
b9f1ddaff4 | ||
|
|
97c0aaa005 | ||
|
|
98b7e92191 | ||
|
|
8778c6b985 | ||
|
|
b7d4cbf00f | ||
|
|
570317ecde | ||
|
|
bd08f6b0e3 | ||
|
|
8785f45e93 | ||
|
|
5df8a4c89c | ||
|
|
8300811b8c | ||
|
|
28ed561740 | ||
|
|
ff27ef544e | ||
|
|
7feebe3405 | ||
|
|
2f9fa672d0 | ||
|
|
70e973b9ab | ||
|
|
580a4165f2 | ||
|
|
7cc0898f58 | ||
|
|
e43ea0986e | ||
|
|
e9550e50d0 | ||
|
|
200da319f2 | ||
|
|
4144850837 | ||
|
|
d3d3248e3b | ||
|
|
f832cff60c | ||
|
|
53aa73e416 | ||
|
|
16100f839f | ||
|
|
67c0157d00 | ||
|
|
eb7235f17c | ||
|
|
5f66253fab | ||
|
|
699489b95f | ||
|
|
d1391c2ed5 | ||
|
|
518bd238f5 | ||
|
|
f6561d8cc0 | ||
|
|
486bcb0b08 | ||
|
|
236b90a90c | ||
|
|
6f636b2f49 | ||
|
|
11883543d1 | ||
|
|
8b7f903cff | ||
|
|
1a09485013 | ||
|
|
efac2084e1 | ||
|
|
7f4a5c2413 | ||
|
|
f0078b7935 | ||
|
|
14d8eecea6 | ||
|
|
bc94f7ead5 | ||
|
|
0a428a52e6 | ||
|
|
3bf5f2c47e | ||
|
|
584b62adc5 | ||
|
|
286ee05711 | ||
|
|
3113343c4d | ||
|
|
1d76929d5a | ||
|
|
401490c033 | ||
|
|
2fa4c57026 | ||
|
|
79e962d664 | ||
|
|
9daff34bb5 | ||
|
|
4af693ccef | ||
|
|
16d3302bff | ||
|
|
792d154608 | ||
|
|
d08f554ff4 | ||
|
|
e11a20ace4 | ||
|
|
1aafa481f3 | ||
|
|
9c210ea670 | ||
|
|
0c36f4a104 | ||
|
|
5f899e428a | ||
|
|
232f29c355 | ||
|
|
1bbb604d32 | ||
|
|
960bea8bae | ||
|
|
654a49d1bc | ||
|
|
75ba8d1dc4 | ||
|
|
d566192843 | ||
|
|
c592610ed3 | ||
|
|
2480e5a274 | ||
|
|
f547ef8726 | ||
|
|
639d410108 | ||
|
|
c9e59a5590 | ||
|
|
0f6a6227f5 | ||
|
|
a068c47ea9 | ||
|
|
bacf0c761f | ||
|
|
3a017e8ccc | ||
|
|
1fc040183c | ||
|
|
5b6e850b25 | ||
|
|
ce2eaab9f7 | ||
|
|
9badde4d65 | ||
|
|
dfc3bbe513 | ||
|
|
dced031c95 | ||
|
|
509f3407a5 | ||
|
|
963a28b497 | ||
|
|
db9b9b8993 | ||
|
|
f117048f41 | ||
|
|
1ea81f33ff | ||
|
|
54cc748a36 | ||
|
|
0cc53efe06 | ||
|
|
8253405c0b | ||
|
|
7ed4ab0b32 | ||
|
|
c3d5b5fa4d | ||
|
|
a78f716216 | ||
|
|
c1316dd30a | ||
|
|
27ea5e5790 | ||
|
|
cb044daf60 | ||
|
|
26d4b9ead8 | ||
|
|
67ab356116 | ||
|
|
4192299ad4 | ||
|
|
6d358eb680 | ||
|
|
f7dbed5d86 | ||
|
|
93c8b0fe18 | ||
|
|
ab666bc448 | ||
|
|
2da0db7df5 | ||
|
|
6f61401a91 | ||
|
|
404ba6a805 | ||
|
|
7a590a5b41 | ||
|
|
01b64254de | ||
|
|
fe09df3afb | ||
|
|
1ff8a5ff4f | ||
|
|
a50918dfe8 | ||
|
|
86cfb29b28 | ||
|
|
a7b1151a2c | ||
|
|
89ffe391be | ||
|
|
6fa4c9458a | ||
|
|
139ce49430 | ||
|
|
f25c4a3eaf | ||
|
|
befb18a86f | ||
|
|
bfcd0fdb39 | ||
|
|
206cf8fb94 | ||
|
|
a6b41ac6d1 | ||
|
|
151680eef9 | ||
|
|
d6491af103 | ||
|
|
11be5332bb | ||
|
|
a625c62a1d | ||
|
|
fb2dba0cf7 | ||
|
|
e811c23d37 | ||
|
|
4ee8095161 | ||
|
|
8fb2741963 | ||
|
|
905afb5be1 | ||
|
|
f7b594bf79 | ||
|
|
863ce1d70d | ||
|
|
9089ed40b9 | ||
|
|
ad7849a82b | ||
|
|
9a0961a4a9 | ||
|
|
1dfa8733a5 | ||
|
|
05df951373 | ||
|
|
bbaaaf980f | ||
|
|
af4f19840c | ||
|
|
1fa3245e83 | ||
|
|
6ba3d29f07 | ||
|
|
bf100beb97 | ||
|
|
22aff66cc0 | ||
|
|
11038c5936 | ||
|
|
99df0ca07f | ||
|
|
f9129bc500 | ||
|
|
2a66bf6f09 | ||
|
|
ee0efe0bdb | ||
|
|
679785b67e | ||
|
|
57c140144a | ||
|
|
d2c2e39ba7 | ||
|
|
3e787d11cc | ||
|
|
e78ce4db64 | ||
|
|
efd1fb0967 | ||
|
|
08a9d40096 | ||
|
|
126dee3da6 | ||
|
|
bfa243aecf | ||
|
|
38be7489ec | ||
|
|
e2bdeec273 | ||
|
|
de8433277f | ||
|
|
dace5ba264 | ||
|
|
bf189daea9 | ||
|
|
e6f0c8908c | ||
|
|
7f018fce7b | ||
|
|
6f61b91b6b | ||
|
|
57b9d73efe | ||
|
|
3a0acfedbb | ||
|
|
d96867fa58 | ||
|
|
bc38ce59f7 | ||
|
|
039bf03a31 | ||
|
|
90f0494c81 | ||
|
|
d02666d632 | ||
|
|
8e5b097011 | ||
|
|
2a3cf7edf5 | ||
|
|
ecadd45886 | ||
|
|
90df30cce9 | ||
|
|
2590d90769 | ||
|
|
b2a5b3beba | ||
|
|
6bb5e525d3 | ||
|
|
8dd33b88d8 | ||
|
|
dec1658200 | ||
|
|
b53e5f23c6 | ||
|
|
e95698d2cd | ||
|
|
78d338fe11 | ||
|
|
77cdee1f5b | ||
|
|
6f9cb382c4 | ||
|
|
f16702de80 | ||
|
|
3a88bc70a3 | ||
|
|
37bece696c | ||
|
|
b8a5c112a0 | ||
|
|
969a831b5b | ||
|
|
61dc68c3a7 | ||
|
|
4da38ea322 | ||
|
|
77d701ae81 | ||
|
|
6523584762 | ||
|
|
214aacd780 | ||
|
|
d913f3dd0e | ||
|
|
62e1c53c84 | ||
|
|
9bd86bf474 | ||
|
|
af36832bbf | ||
|
|
fad2a6c96f | ||
|
|
a24135ae83 | ||
|
|
ad9b85932b | ||
|
|
9df3e45719 | ||
|
|
9e13e339b8 | ||
|
|
697a244280 | ||
|
|
3a9636faad | ||
|
|
ceacf275b1 | ||
|
|
ce5dc6f314 | ||
|
|
b520bef0b1 | ||
|
|
d7630db953 | ||
|
|
fae098c6c0 | ||
|
|
5568aa0ea5 | ||
|
|
f12a876730 | ||
|
|
fbe69a114c | ||
|
|
d39d9ddb74 | ||
|
|
dbeebfc15d | ||
|
|
f495581a05 | ||
|
|
db6a4f5cd3 | ||
|
|
bc0636c4db | ||
|
|
e1ebd90bbe | ||
|
|
c4dbc4c830 | ||
|
|
b43889821d | ||
|
|
a81f6fed67 | ||
|
|
5fa40f1a5f | ||
|
|
13c12c1b38 | ||
|
|
a60167b390 | ||
|
|
41bc173636 | ||
|
|
3d436a7e31 | ||
|
|
104f3b239b | ||
|
|
3b8fc9c9b6 | ||
|
|
21d5e4e020 | ||
|
|
b85102c8e8 | ||
|
|
48189fedb4 | ||
|
|
d2fc434dc9 | ||
|
|
331e618961 | ||
|
|
26831445d0 | ||
|
|
d43594822b | ||
|
|
228541c15d | ||
|
|
6d88df5d7c | ||
|
|
6024141c26 | ||
|
|
7069c7127b | ||
|
|
bfa4a8b5e4 | ||
|
|
a504dfee27 | ||
|
|
1762181e58 | ||
|
|
9a5bd65e33 | ||
|
|
f418261c50 | ||
|
|
c4d4a42b4c | ||
|
|
ad9a5be560 | ||
|
|
40f859450a | ||
|
|
223039128f | ||
|
|
35e4e4f6bd | ||
|
|
d21518f8fe | ||
|
|
3edbba21b4 | ||
|
|
bfd7ac015f | ||
|
|
99ba5a57de | ||
|
|
ee151ee952 | ||
|
|
f384f9e7cf | ||
|
|
e18c171f74 | ||
|
|
b107d5abd4 | ||
|
|
bba566540d | ||
|
|
3d7f6f4496 | ||
|
|
5e76fc45b7 | ||
|
|
7dad80b1f9 | ||
|
|
93895fa7fb | ||
|
|
5e9017dbb8 | ||
|
|
81aa065608 | ||
|
|
81a65bd750 | ||
|
|
cddfe06a86 | ||
|
|
0825e6ca51 | ||
|
|
153cc72f17 | ||
|
|
28e2682986 | ||
|
|
64deb9c421 | ||
|
|
a14ebae6a4 | ||
|
|
d528dea83e | ||
|
|
8006e8e13d | ||
|
|
1203aa72c1 | ||
|
|
ff18b6de09 | ||
|
|
66bf75d1d8 | ||
|
|
f66773b63c | ||
|
|
4afc3cbd4f | ||
|
|
8b3c4db9ac | ||
|
|
f7ed1c0176 | ||
|
|
47a28bf262 | ||
|
|
fea6b6e999 | ||
|
|
e58e5b0879 | ||
|
|
a9227cf6f9 | ||
|
|
bffaccbc95 | ||
|
|
b721e438ba | ||
|
|
e6b13eaab9 | ||
|
|
9e7856f22b | ||
|
|
9c6c48b50b | ||
|
|
8b9f3016e6 | ||
|
|
9e4f0fa23a | ||
|
|
38f9d85d5a | ||
|
|
6a0039812d | ||
|
|
a4d74f7c82 | ||
|
|
07dc8ebdbb | ||
|
|
cb4d8ab5ea | ||
|
|
6afe07f944 | ||
|
|
a5bc77eaa7 | ||
|
|
6b88a6e0c4 | ||
|
|
fe3600f149 | ||
|
|
b83f02be9a | ||
|
|
04dd4d11ec | ||
|
|
318964e4dc | ||
|
|
51dd877de9 | ||
|
|
5389f70719 | ||
|
|
43ac0fce5c | ||
|
|
79effdd498 | ||
|
|
7f7afc576c | ||
|
|
085a79c748 | ||
|
|
e7981a6f19 | ||
|
|
bb02d3efcc | ||
|
|
92d64db112 | ||
|
|
c6c891430c | ||
|
|
b769a3469b | ||
|
|
aa6dceeece | ||
|
|
4cc2f632b8 | ||
|
|
5b58f51460 | ||
|
|
0cdb9e82c0 | ||
|
|
122f95b976 | ||
|
|
2de83835cd | ||
|
|
10658e428b | ||
|
|
6c252e9059 | ||
|
|
bad3a86816 | ||
|
|
98d9298828 | ||
|
|
96c8cc4383 | ||
|
|
8e022cc3c1 | ||
|
|
cd61738f33 | ||
|
|
a3b2786b32 | ||
|
|
a1bb317892 | ||
|
|
f0d69e7b39 | ||
|
|
c96bbe2e08 | ||
|
|
d441dd543e | ||
|
|
a425a127f5 | ||
|
|
66f1e6568e | ||
|
|
8387590ace | ||
|
|
2c206d2792 | ||
|
|
b3c978caa6 | ||
|
|
1a415d1e02 | ||
|
|
445b194938 | ||
|
|
44dac4a90d | ||
|
|
798fd5e4c0 | ||
|
|
c3f603c4dc | ||
|
|
1dcfe38171 | ||
|
|
31dfa21f36 | ||
|
|
3ecceb744b | ||
|
|
a43583a4a0 | ||
|
|
c529c3b2f1 | ||
|
|
e7cfe142bc | ||
|
|
4e0d7f7f9b | ||
|
|
d201dc1f09 | ||
|
|
f1da874b53 | ||
|
|
63bd623a63 | ||
|
|
3e44b51d7a | ||
|
|
f1d88f79f4 | ||
|
|
5f02726207 | ||
|
|
d4d2a4a988 | ||
|
|
90aa225cf0 | ||
|
|
dfe27fc75b | ||
|
|
8bdf01be3b | ||
|
|
7f59b65b6a | ||
|
|
608b1d10e0 | ||
|
|
5feddcb96a | ||
|
|
bce802c61f | ||
|
|
9e6c0c20d2 | ||
|
|
e7eb030109 | ||
|
|
40955e72a9 | ||
|
|
8c208ad206 | ||
|
|
4724779fe2 | ||
|
|
f70cea50c9 | ||
|
|
0468eee02f | ||
|
|
06f58809f2 | ||
|
|
647fec55d9 | ||
|
|
a1a1a42c42 | ||
|
|
87b4de1cda | ||
|
|
8c5dd51446 | ||
|
|
1007cf8294 | ||
|
|
adedbf1f12 | ||
|
|
57a6ccbd3b | ||
|
|
ebb875e0a5 | ||
|
|
677f2cf43f | ||
|
|
f71047f31a | ||
|
|
a062155841 | ||
|
|
a7cc6913af | ||
|
|
8e8a3c5414 | ||
|
|
f89c3e6ec7 | ||
|
|
e8c3419d1f | ||
|
|
4f29213cdc | ||
|
|
0f232ac4e7 | ||
|
|
4223b8eb0f | ||
|
|
62adebcf4e | ||
|
|
b577502a9b | ||
|
|
603176c17d | ||
|
|
6e91ee2694 | ||
|
|
cb1a8963e3 | ||
|
|
112e33a665 | ||
|
|
3f76dbe67a | ||
|
|
cd031ab423 | ||
|
|
dc9dd9c559 | ||
|
|
e65f45f17a | ||
|
|
8a69aa2884 | ||
|
|
f1c2917038 | ||
|
|
0669e4e033 | ||
|
|
0b8435da71 | ||
|
|
bb1797722f | ||
|
|
455951056a | ||
|
|
0c443b7666 | ||
|
|
cc44574543 | ||
|
|
75f203cdb9 | ||
|
|
da6a1b01d1 | ||
|
|
bb2945a1a3 | ||
|
|
bec2c6787a | ||
|
|
89df7a670a | ||
|
|
d60873dd76 | ||
|
|
fc062bfc6d | ||
|
|
e34b2f9045 | ||
|
|
dfe5d8af31 | ||
|
|
09ddcbe01e | ||
|
|
398addf0c1 | ||
|
|
1c4bcff490 | ||
|
|
bcda6b4570 | ||
|
|
e4eb3d0d92 | ||
|
|
e75b5f6bc9 | ||
|
|
0955b8b810 | ||
|
|
c9e0118ae9 | ||
|
|
2f00e647ef | ||
|
|
f3e5f41a3c | ||
|
|
80428a693a | ||
|
|
f3154e347e | ||
|
|
2873abd647 | ||
|
|
93d6ff3889 | ||
|
|
67f7a5517a | ||
|
|
2cdfe24c18 | ||
|
|
12d23062d4 | ||
|
|
a233b2f53a | ||
|
|
9e0763e189 | ||
|
|
970bbeca8d | ||
|
|
8acdb1538b | ||
|
|
a67f62e9aa | ||
|
|
efe506a0f5 | ||
|
|
d978fe710f | ||
|
|
7ba2cfb30b | ||
|
|
146b193ef7 | ||
|
|
c157e59d58 | ||
|
|
1006519012 | ||
|
|
e26dcf55a9 | ||
|
|
d43a23d8f5 | ||
|
|
22189f69f6 | ||
|
|
749d1f8d04 | ||
|
|
216180c7fd | ||
|
|
eeff2c01b1 | ||
|
|
4f463b1c46 | ||
|
|
8980506597 | ||
|
|
0df092d3c7 | ||
|
|
d138effca8 | ||
|
|
a57e04aa49 | ||
|
|
93287f22a8 | ||
|
|
8fc11b2927 | ||
|
|
a9cb17eb6c | ||
|
|
715bc7cd95 | ||
|
|
75f867da85 | ||
|
|
d5be01b253 | ||
|
|
b93101459d | ||
|
|
c17a96f02f | ||
|
|
60656a60cf | ||
|
|
eba589db89 | ||
|
|
fc0136647f | ||
|
|
c7423ffb2d | ||
|
|
5c0d3c4393 | ||
|
|
3be0cabf0a | ||
|
|
40f0b8a2a0 | ||
|
|
5e51fe2640 | ||
|
|
2df92204e9 | ||
|
|
5f978fec37 | ||
|
|
11a9627cbb | ||
|
|
62a286332a | ||
|
|
d91672c6d3 | ||
|
|
56a31bab8d | ||
|
|
a1a98e1087 | ||
|
|
80f6f72e8c | ||
|
|
86134ea99e | ||
|
|
60e983c493 | ||
|
|
cf34e3ee1a | ||
|
|
3e961dbb77 | ||
|
|
789cada297 | ||
|
|
1f6dadd22c | ||
|
|
e7d34f05ba | ||
|
|
4cb70b7421 | ||
|
|
4c124174c9 | ||
|
|
762dc5e8a6 | ||
|
|
4b0e260ac5 | ||
|
|
dde1d500b5 | ||
|
|
3c55dcc3d1 | ||
|
|
fde602dadf | ||
|
|
d69b19b567 | ||
|
|
00e5786067 | ||
|
|
ad90cb9111 | ||
|
|
a2acf62bf1 | ||
|
|
08c7a2c5f3 | ||
|
|
1b4ae3bcd2 | ||
|
|
2972468bc1 | ||
|
|
d93e5aa444 | ||
|
|
e6c9846726 | ||
|
|
6f37f0661d | ||
|
|
dcb7b36ad4 | ||
|
|
067a60e0ac | ||
|
|
b15fb30ff5 | ||
|
|
bb7114c2e8 | ||
|
|
7128456d57 | ||
|
|
751023c107 | ||
|
|
16f5e8738b | ||
|
|
173e92ea24 | ||
|
|
b5ee943a63 | ||
|
|
7919df7ef5 | ||
|
|
f8930aba34 | ||
|
|
cb6c04637b | ||
|
|
949f6c4ad4 | ||
|
|
7d7390ddaf | ||
|
|
b816754c06 | ||
|
|
09f301d969 | ||
|
|
95aeb0c362 | ||
|
|
336b134a40 | ||
|
|
e0673077a7 | ||
|
|
acf18aad13 | ||
|
|
1942925eb1 | ||
|
|
f5c14295b4 | ||
|
|
5355edefe3 | ||
|
|
b3b92f11af | ||
|
|
621ab7d19b | ||
|
|
0d8ad27b14 | ||
|
|
9e68c45058 | ||
|
|
6208b33e08 | ||
|
|
cea89f784f | ||
|
|
8504aaa0ef | ||
|
|
81abb7322d | ||
|
|
5b13b7a44d | ||
|
|
2619d172dc | ||
|
|
d9ff45022b | ||
|
|
550196e3b8 | ||
|
|
8e2058a962 | ||
|
|
df0460f876 | ||
|
|
65b4091402 | ||
|
|
e6a38fd793 | ||
|
|
8c98e3fad5 | ||
|
|
24f9f07752 | ||
|
|
4beffa97e0 | ||
|
|
aee0062cb2 | ||
|
|
863930836b | ||
|
|
a702b3bb2b | ||
|
|
692010f0fc | ||
|
|
363753a0c5 | ||
|
|
e982a3de33 | ||
|
|
137eb19de9 | ||
|
|
612a929907 | ||
|
|
29acaabc6a | ||
|
|
642ad2fffe | ||
|
|
8f7cca3ae2 | ||
|
|
b015aa416f | ||
|
|
ba1d5ba172 | ||
|
|
688107c3e5 | ||
|
|
d8c3f7c5ee | ||
|
|
69484a07e1 | ||
|
|
5f4b1f867d |
@@ -1,88 +1,15 @@
|
||||
# Rule Catalog — Architecture
|
||||
# Architecture review
|
||||
|
||||
## Scope
|
||||
- Covers: controller/service/core-domain/libs/model layering, dependency direction, responsibility placement, observability-friendly flow.
|
||||
The [API agent guide](../../../../api/AGENTS.md) owns package boundaries. In particular, `api/core/` is migration-only: do not propose new files or extracted implementations there.
|
||||
|
||||
## Rules
|
||||
## Transport and domain boundaries
|
||||
|
||||
### Keep business logic out of controllers
|
||||
- Category: maintainability
|
||||
- Description: Controllers should parse input, call services, and return serialized responses. Business decisions inside controllers make behavior hard to reuse and test.
|
||||
- Suggested fix: Move domain/business logic into the service or core/domain layer. Keep controller handlers thin and orchestration-focused.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
@bp.post("/apps/<app_id>/publish")
|
||||
def publish_app(app_id: str):
|
||||
payload = request.get_json() or {}
|
||||
if payload.get("force") and current_user.role != "admin":
|
||||
raise ValueError("only admin can force publish")
|
||||
app = App.query.get(app_id)
|
||||
app.status = "published"
|
||||
db.session.commit()
|
||||
return {"result": "ok"}
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
@bp.post("/apps/<app_id>/publish")
|
||||
def publish_app(app_id: str):
|
||||
payload = PublishRequest.model_validate(request.get_json() or {})
|
||||
app_service.publish_app(app_id=app_id, force=payload.force, actor_id=current_user.id)
|
||||
return {"result": "ok"}
|
||||
```
|
||||
Controllers own request parsing, service calls, and response serialization. Trace business decisions to their service or domain owner; flag controller policy when it duplicates or bypasses that owner. Move extracted behavior to an appropriate owner outside `core/`.
|
||||
|
||||
### Preserve layer dependency direction
|
||||
- Category: best practices
|
||||
- Description: Controllers may depend on services, and services may depend on core/domain abstractions. Reversing this direction (for example, core importing controller/web modules) creates cycles and leaks transport concerns into domain code.
|
||||
- Suggested fix: Extract shared contracts into core/domain or service-level modules and make upper layers depend on lower, not the reverse.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# core/policy/publish_policy.py
|
||||
from controllers.console.app import request_context
|
||||
## Dependency direction
|
||||
|
||||
def can_publish() -> bool:
|
||||
return request_context.current_user.is_admin
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# core/policy/publish_policy.py
|
||||
def can_publish(role: str) -> bool:
|
||||
return role == "admin"
|
||||
Transport layers may depend on services and domain contracts. Domain code must not import controller or request context. Pass validated actor, tenant, and resource data explicitly instead of reaching upward for it. Check existing owners and import-linter contracts before proposing a shared abstraction.
|
||||
|
||||
# service layer adapts web/user context to domain input
|
||||
allowed = can_publish(role=current_user.role)
|
||||
```
|
||||
## Business-agnostic libraries
|
||||
|
||||
### Keep libs business-agnostic
|
||||
- Category: maintainability
|
||||
- Description: Modules under `api/libs/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions.
|
||||
- Suggested fix:
|
||||
- If business logic appears in `api/libs/`, extract it into the appropriate `services/` or `core/` module and keep `libs` focused on generic, cross-cutting helpers.
|
||||
- Keep `libs` dependencies clean: avoid importing service/controller/domain-specific modules into `api/libs/`.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# api/libs/conversation_filter.py
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
def should_archive_conversation(conversation, tenant_id: str) -> bool:
|
||||
# Domain policy and service dependency are leaking into libs.
|
||||
service = ConversationService()
|
||||
if service.has_paid_plan(tenant_id):
|
||||
return conversation.idle_days > 90
|
||||
return conversation.idle_days > 30
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# api/libs/datetime_utils.py (business-agnostic helper)
|
||||
def older_than_days(idle_days: int, threshold_days: int) -> bool:
|
||||
return idle_days > threshold_days
|
||||
|
||||
# services/conversation_service.py (business logic stays in service/core)
|
||||
from libs.datetime_utils import older_than_days
|
||||
|
||||
def should_archive_conversation(conversation, tenant_id: str) -> bool:
|
||||
threshold_days = 90 if has_paid_plan(tenant_id) else 30
|
||||
return older_than_days(conversation.idle_days, threshold_days)
|
||||
```
|
||||
`api/libs/` must not own product policy or orchestration. Flag service/controller dependencies and domain-specific decisions in library helpers. Move that behavior to its existing service or domain owner outside `core/`; retain only reusable infrastructure in `libs/`.
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
```python
|
||||
from sqlalchemy.orm import Mapped
|
||||
|
||||
|
||||
class Dataset(TypeBase):
|
||||
__tablename__ = "datasets"
|
||||
id: Mapped[str] = mapped_column(StringUUID, primary_key=True)
|
||||
@@ -59,6 +60,7 @@
|
||||
```python
|
||||
from sqlalchemy.orm import Mapped
|
||||
|
||||
|
||||
class Dataset(TypeBase):
|
||||
__tablename__ = "datasets"
|
||||
id: Mapped[str] = mapped_column(StringUUID, primary_key=True)
|
||||
@@ -101,6 +103,7 @@
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped
|
||||
|
||||
|
||||
class ToolConfig(TypeBase):
|
||||
__tablename__ = "tool_configs"
|
||||
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
@@ -111,6 +114,7 @@
|
||||
|
||||
from models.types import AdjustedJSON
|
||||
|
||||
|
||||
class ToolConfig(TypeBase):
|
||||
__tablename__ = "tool_configs"
|
||||
config: Mapped[dict] = mapped_column(AdjustedJSON(), nullable=False)
|
||||
@@ -146,7 +150,5 @@
|
||||
default_expr = sa.text("'database'::character varying") if _is_pg(conn) else sa.text("'database'")
|
||||
|
||||
with op.batch_alter_table("dataset_keyword_tables") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("data_source_type", sa.String(255), server_default=default_expr, nullable=False)
|
||||
)
|
||||
batch_op.add_column(sa.Column("data_source_type", sa.String(255), server_default=default_expr, nullable=False))
|
||||
```
|
||||
|
||||
@@ -11,16 +11,14 @@
|
||||
- Description: If a table/model already has a repository abstraction, all reads/writes/queries for that table should use the existing repository. If no repository exists, introduce one only when complexity justifies it, such as large/high-volume tables, repeated complex query logic, or likely storage-strategy variation.
|
||||
- Suggested fix:
|
||||
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access.
|
||||
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies), while preserving dependency direction (service/core depends on abstraction; infra provides implementation).
|
||||
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies). Follow `api/AGENTS.md`: put new abstractions and implementations outside migration-only `api/core/`, with services/domain code depending on abstractions and infrastructure providing implementations.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Existing repository is ignored and service uses ad-hoc table queries.
|
||||
class AppService:
|
||||
def archive_app(self, app_id: str, tenant_id: str) -> None:
|
||||
app = self.session.execute(
|
||||
select(App).where(App.id == app_id, App.tenant_id == tenant_id)
|
||||
).scalar_one()
|
||||
app = self.session.execute(select(App).where(App.id == app_id, App.tenant_id == tenant_id)).scalar_one()
|
||||
app.archived = True
|
||||
self.session.commit()
|
||||
```
|
||||
@@ -33,6 +31,7 @@
|
||||
app.archived = True
|
||||
self.app_repo.save(app)
|
||||
|
||||
|
||||
# If the query is missing, extend the existing abstraction.
|
||||
active_apps = self.app_repo.list_active_for_tenant(tenant_id=tenant_id)
|
||||
```
|
||||
@@ -50,9 +49,10 @@
|
||||
class ConversationRepository(Protocol):
|
||||
def list_recent_for_app(self, app_id: str, tenant_id: str, limit: int) -> list[Conversation]: ...
|
||||
|
||||
|
||||
class SqlAlchemyConversationRepository:
|
||||
def list_recent_for_app(self, app_id: str, tenant_id: str, limit: int) -> list[Conversation]:
|
||||
...
|
||||
def list_recent_for_app(self, app_id: str, tenant_id: str, limit: int) -> list[Conversation]: ...
|
||||
|
||||
|
||||
class ConversationService:
|
||||
def __init__(self, conversation_repo: ConversationRepository):
|
||||
|
||||
@@ -126,9 +126,7 @@
|
||||
|
||||
# 3) Pessimistic lock with SELECT ... FOR UPDATE (high contention)
|
||||
run = session.execute(
|
||||
select(WorkflowRun)
|
||||
.where(WorkflowRun.id == run_id, WorkflowRun.tenant_id == tenant_id)
|
||||
.with_for_update()
|
||||
select(WorkflowRun).where(WorkflowRun.id == run_id, WorkflowRun.tenant_id == tenant_id).with_for_update()
|
||||
).scalar_one()
|
||||
run.status = "cancelled"
|
||||
session.commit()
|
||||
|
||||
@@ -5,14 +5,14 @@ description: Use only when the user explicitly requests a review or audit of fro
|
||||
|
||||
# Frontend Code Review
|
||||
|
||||
Review the requested scope for concrete, reproducible regressions. This skill owns the review phase and routes directly to its bundled rule packs. For a combined review-and-fix request, establish findings before applying implementation or testing guidance.
|
||||
Review the requested scope for concrete defects and violations of explicit project contracts. This skill owns review decisions; its references route to canonical rules without activating another skill's implementation workflow.
|
||||
|
||||
## Evidence First
|
||||
|
||||
1. Establish the review scope from the requested files or current diff.
|
||||
2. Read the changed lines, their behavior owner, and the nearest scoped `AGENTS.md`.
|
||||
3. Trace public consumers, generated contracts, primitive APIs, or runtime configuration only when they decide correctness.
|
||||
4. Report only findings tied to an observable failure, violated contract, security boundary, or demonstrated maintenance risk.
|
||||
4. Report findings tied to an observable failure, violated contract, security boundary, or demonstrated maintenance risk. Explicit team conventions are contracts: establish their scope and exceptions, and do not invent user impact to justify a convention finding.
|
||||
|
||||
## Rule Routing
|
||||
|
||||
@@ -33,10 +33,10 @@ Read `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, `packages/dify-
|
||||
|
||||
- **P0**: security or privacy leak, data loss, production crash, or inaccessible critical workflow.
|
||||
- **P1**: user-visible regression, invalid API or authorization contract, hydration failure, or broken primary interaction.
|
||||
- **P2**: concrete maintainability, performance, test, or accessibility defect likely to cause incorrect behavior.
|
||||
- **P2**: concrete maintainability, performance, test, or accessibility defect, or a material violation of an explicit project contract.
|
||||
- **P3**: minor actionable cleanup; omit unless the user requested a thorough audit.
|
||||
|
||||
Lead with findings ordered by severity. Include a tight file and line reference, the failing contract or reproduction path, impact, and a concrete fix direction. If there are no findings, say `No issues found.` and state any material verification gap. Do not add praise sections, speculative risks, or an unsolicited offer to implement fixes.
|
||||
Lead with findings ordered by severity. Include a tight file and line reference, the observed failure or applicable project rule, and a concrete fix direction. Explain the rule's applicability for convention findings; describe downstream consequences only when supported by evidence. When no findings remain, say so briefly and state any material verification gap. Do not add praise sections, speculative risks, or an unsolicited offer to implement fixes.
|
||||
|
||||
[accessibility]: references/accessibility-ui.md
|
||||
[code-quality]: references/code-quality.md
|
||||
|
||||
@@ -2,32 +2,51 @@
|
||||
|
||||
Accessibility findings are first-class review findings. Treat broken keyboard access, missing accessible names, focus loss, and unreachable popup content as correctness bugs, not polish.
|
||||
|
||||
Before finalizing UI or accessibility findings, fetch the latest Web Interface Guidelines as a required baseline:
|
||||
## Review Evidence
|
||||
|
||||
```text
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
Do not treat that document as the complete accessibility rule set. Combine it with:
|
||||
Use the sources relevant to the changed contract:
|
||||
|
||||
- `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, and the relevant primitive implementation when code uses `@langgenius/dify-ui/*`.
|
||||
- Base UI docs and local `.d.ts` contracts when primitive semantics, focus target, labels, or popup reachability are unclear.
|
||||
- MDN or relevant WAI-ARIA/browser standards when behavior, compatibility, or deprecation status matters.
|
||||
- The current feature's product semantics, because an accessible primitive can still be used in an inaccessible workflow.
|
||||
|
||||
Consult current official documentation or standards when these sources leave a behavior unresolved. The [Web Interface Guidelines] are an optional broader UI reference, not a required fetch for each review.
|
||||
|
||||
## Semantic HTML
|
||||
|
||||
Flag:
|
||||
|
||||
- Clickable `div` or `span` used for actions.
|
||||
- Router navigation implemented with button or `onClick` when a `Link` / `<a>` is the real semantic element.
|
||||
- Icon-only buttons without `aria-label` or `aria-labelledby`.
|
||||
- Icon-only controls without an accessible name; follow the naming rules below and the Dify UI `IconButton` contract.
|
||||
- Decorative icons missing `aria-hidden="true"`.
|
||||
- Images without `alt`; use `alt=""` only when truly decorative.
|
||||
- Heading levels that skip hierarchy in page-level content.
|
||||
|
||||
Prefer semantic HTML before ARIA.
|
||||
|
||||
## Page Landmarks
|
||||
|
||||
When reviewing Web page layouts, navigation, or landmark changes, read [Page landmarks].
|
||||
Inspect the composed page with its parent layouts for missing or duplicate main regions,
|
||||
inappropriate nesting or roles, and ambiguous or broken landmark names. Check affected
|
||||
unit and E2E locators when semantic elements change. Web owns these composition rules;
|
||||
use the existing naming contract for label-source decisions.
|
||||
|
||||
## Accessible Names And Descriptions
|
||||
|
||||
Read [Accessible names and descriptions] when a change affects labels, ARIA naming, help/error relationships, or hidden text. That document owns the shared implementation and review contract.
|
||||
|
||||
Flag violations supported by the final rendered behavior:
|
||||
|
||||
- Missing or insufficient names, redundant name overrides, or naming attributes prohibited by the element's role.
|
||||
- Overrides that omit visible label wording or suppress necessary descendant information.
|
||||
- Broken or stale label/description references, including relationships lost when responsive content or overlays unmount.
|
||||
- Descriptions used instead of names, repeated help text, or essential structured content available only as a flattened description.
|
||||
|
||||
Inspect the computed name and description in the relevant state. Matching an `aria-label` string to nearby text alone does not prove redundancy.
|
||||
|
||||
## Keyboard And Focus
|
||||
|
||||
Flag:
|
||||
@@ -48,7 +67,6 @@ Flag:
|
||||
- Missing stable `name` on form fields that submit or validate.
|
||||
- Incorrect input `type`, `inputMode`, `autoComplete`, or `spellCheck` for email, token, URL, number, search, code, or username fields.
|
||||
- Labels that are not clickable.
|
||||
- Submit buttons disabled before a request starts, preventing normal submit behavior.
|
||||
- Non-submit buttons inside forms missing `type="button"`.
|
||||
- Errors not associated with fields or not reachable by screen readers.
|
||||
- Error recovery that does not focus or expose the first invalid field.
|
||||
@@ -56,16 +74,20 @@ Flag:
|
||||
- Placeholder text used as the only label.
|
||||
- Password managers accidentally triggered on non-auth fields because autocomplete is missing or wrong.
|
||||
|
||||
Prefer visible labels. If visible surrounding text already labels the control, use a visually hidden label or a precise `aria-label`.
|
||||
Prefer visible labels and associate them through the appropriate field primitive, a native `label`, or `aria-labelledby`. Do not duplicate an existing label with hidden text or `aria-label`; follow [Accessible names and descriptions] when no suitable visible label exists.
|
||||
|
||||
## Disabled, Loading, And Async States
|
||||
|
||||
Flag:
|
||||
|
||||
- Loading state without `aria-busy`, `role="status"`, or another accessible update path when it changes user interaction.
|
||||
- Loading controls whose accessible name disappears, or whose user-relevant progress has no
|
||||
feature-owned status path. Follow the Dify UI Button contract for focused loading buttons; do not
|
||||
add `aria-busy` to a button as a generic pending-state substitute.
|
||||
- The same pending state passed to both Dify UI Button `loading` and `disabled`, which duplicates
|
||||
state ownership and obscures whether `disabled` expresses independent unavailability.
|
||||
- Spinner or decorative loading icon exposed to screen readers.
|
||||
- Disabled controls that hide the reason users cannot proceed.
|
||||
- `aria-disabled` used without manually blocking click, Space, and Enter.
|
||||
- Controls marked `aria-disabled` that still activate through supported pointer or keyboard input. Check the primitive's handling before requesting manual event guards.
|
||||
- Toasts, inline validation, or async status changes that are not announced when users need the update to continue.
|
||||
- Icon-only loading/error affordances without text or accessible status where the state matters.
|
||||
|
||||
@@ -90,7 +112,7 @@ Use Popover for explanatory content, rich help, and infotips. Use Tooltip only a
|
||||
Flag:
|
||||
|
||||
- Text in flex/grid children without `min-w-0` when it can overflow.
|
||||
- Names, labels, file names, model names, workspace names, or user content lacking `truncate`, `line-clamp`, or `break-words`.
|
||||
- Long names, labels, or user content that overflow, obscure adjacent controls, or become unreadable in supported layouts.
|
||||
- Right-side icons, badges, checks, or actions that shrink before the text area.
|
||||
- Empty arrays or empty strings rendering broken layout instead of an empty state.
|
||||
- Button, tab, badge, chip, menu item, or card text that can overlap sibling controls at common viewport widths.
|
||||
@@ -107,3 +129,7 @@ Flag:
|
||||
- Images without dimensions.
|
||||
- Loading copy using `...` instead of `…`.
|
||||
- Hardcoded dates, times, numbers, or currency formats instead of `Intl.*`.
|
||||
|
||||
[Accessible names and descriptions]: ../../../../packages/dify-ui/docs/accessible-names-and-descriptions.md
|
||||
[Page landmarks]: ../../../../web/docs/landmarks.md
|
||||
[Web Interface Guidelines]: https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
|
||||
@@ -26,15 +26,17 @@ Prefer:
|
||||
|
||||
## Styling
|
||||
|
||||
For Dify UI/Base UI state styling and callback composition, apply the package's [Styling] contract,
|
||||
including its documented reasons for direct state access.
|
||||
|
||||
Flag:
|
||||
|
||||
- New CSS modules or ad hoc CSS when Tailwind utilities and Dify tokens cover the need.
|
||||
- Component-level plain `.css` files or component CSS imported through `globals.css`; use scoped `*.module.css` only when Tailwind and component variants cannot express the style.
|
||||
- Generic color utilities where Dify semantic tokens exist.
|
||||
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or documented radius mappings exist.
|
||||
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or standard Tailwind radius classes match the required design value. Apply [Styling] when choosing radius classes.
|
||||
- `!` important modifiers or important CSS overrides without a narrow, documented reason.
|
||||
- Manual string concatenation, template strings, array `.join(' ')`, or custom ternaries for conditional or multi-line classes.
|
||||
- JS conditional class branches for primitive visual states already exposed by Dify UI/Base UI `data-*` selectors.
|
||||
- Manual class-list assembly through string concatenation, template strings, array `.join(' ')`, or a custom conditional combiner instead of `cn(...)`. Conditional values passed to `cn(...)` remain valid.
|
||||
- Incoming `className` placed before default classes in `cn(...)`, preventing call-site overrides.
|
||||
- Arbitrary z-index or one-off layering fixes on overlays.
|
||||
|
||||
@@ -43,7 +45,6 @@ Use:
|
||||
- `cn(...)` from the local package or utility already used by the file.
|
||||
- Dify semantic tokens and Tailwind v4 utilities.
|
||||
- Existing component variants before one-off class forks.
|
||||
- Primitive selectors such as `data-disabled:*`, `data-checked:*`, `data-highlighted:*`, `group-data-*`, `peer-data-*`, and `has-[:focus-visible]` before adding React state or boolean props solely for styling.
|
||||
- Component-level variants, semantic tokens, and normal cascade/order before `!` overrides. Use `!` only for a contained compatibility override that cannot be expressed through the component API or local selector structure.
|
||||
|
||||
## Imports
|
||||
@@ -66,3 +67,5 @@ Flag:
|
||||
- Error messages that state only the failure and not the next step.
|
||||
|
||||
Use feature-local translation keys by default. Alias only when crossing namespaces. `pnpm i18n:check --file <name>` should pass for any touched translation namespace.
|
||||
|
||||
[Styling]: ../../../../packages/dify-ui/docs/styling.md
|
||||
|
||||
@@ -1,101 +1,31 @@
|
||||
# Component Architecture Rules
|
||||
# Component Architecture Review
|
||||
|
||||
Use these rules for React component structure, ownership, state, props, effects, and module organization.
|
||||
Use the canonical reference for the changed concern. These links share rules; they do not activate the implementation skill or its workflow.
|
||||
|
||||
## Ownership
|
||||
| Concern | Canonical rules |
|
||||
| --- | --- |
|
||||
| Vertical modules, public entrypoints, data/handler placement, wrappers, Props, and types | [Ownership] |
|
||||
| Local/Jotai state, form drafts, route identity, URL state, and persistence | [State] |
|
||||
| Effects, navigation, memoization, and subscriptions | [Runtime] |
|
||||
| Hotkeys, focus, and secondary surfaces | [Interactions] |
|
||||
|
||||
Flag:
|
||||
## Apply Rules In Their Actual Scope
|
||||
|
||||
- State, query, mutation, or handlers hoisted above the lowest component that actually uses them.
|
||||
- Parent components owning row/item actions that do not coordinate a workflow.
|
||||
- Prop drilling through multiple pass-through layers.
|
||||
- A page/tab-level section component becoming the data owner without needing a shared snapshot or shared loading/error/empty UI.
|
||||
- Feature code promoted to shared only because it appears once or might be reused later.
|
||||
Explicit team conventions are reviewable contracts, including module organization and public API boundaries. Check the documented exception before reporting a violation. Do not infer an exception solely because the code appears to work, or invent a user-facing failure for a convention finding.
|
||||
|
||||
Accept repeated TanStack Query calls in siblings when each component independently consumes the data. Cache deduplication is not a reason to hoist by itself.
|
||||
- For owner placement, trace the consumers and required lifetime. Establish whether the parent coordinates a snapshot, submission, navigation, shared UI, or persistence before asking to move state or handlers.
|
||||
- For component boundaries, identify the ownership or encapsulation the proposed extraction would improve; file length alone establishes neither.
|
||||
- For props and types, check the domain contract and public API. Do not report private props typing style alone; declaration/export syntax matters only for a documented package rule or concrete type, export, or framework defect.
|
||||
- For state and Effects, trace the source of truth, external synchronization target, and mount/reset boundary. Controlledness alone does not prove that a draft is lifted or persisted; follow the form and overlay contracts linked by [State].
|
||||
- For navigation, distinguish ordinary links from mutation success, guarded redirects, command flows, and submission side effects.
|
||||
|
||||
## Component Boundaries
|
||||
## Preserve Existing Product Contracts
|
||||
|
||||
Flag:
|
||||
During refactors, trace the interaction being moved through its real consumer. Navigation, sidebar, dropdown, webapp-list, and app-switching changes must preserve expansion controls, hover persistence, pin/delete actions, routing, keyboard/focus handling, and open-state ownership where present.
|
||||
|
||||
- React component files over 300 lines when the file mixes multiple responsibilities that can be split into focused colocated components, hooks, or utilities.
|
||||
- Shallow wrappers that only rename props or hide the real primitive.
|
||||
- Extra DOM wrappers that do not provide layout, semantics, accessibility, state ownership, or library integration.
|
||||
- Dialog/dropdown/popover hidden surfaces that obscure the parent flow when they should be extracted into a small local component.
|
||||
- Business forms, menu bodies, or one-off helpers moved away from their owner without reuse or semantic value.
|
||||
Check that the changed owner still handles reachable empty, loading, and missing optional-data states, and that primitive wrappers preserve accessible semantics and the public controlled-state contract. Report the actual lost behavior or explicit rule violation; use the package testing policy when assessing regression coverage.
|
||||
|
||||
Prefer colocated components split by actual data and state needs.
|
||||
|
||||
## Bad Component Design Patterns
|
||||
|
||||
Flag:
|
||||
|
||||
- Refactors of existing navigation, sidebar, dropdown, webapp list, or app-switching UI that do not preserve behavior-sensitive interactions such as expand/collapse arrows, hover persistence, pin/delete controls, routing, keyboard/focus handling, or open-state ownership.
|
||||
- Components that mix data fetching, mutation side effects, popup state, form validation, layout, and row rendering without a clear owner.
|
||||
- Generic components with many boolean props that encode one feature's workflow.
|
||||
- A shared component that imports feature-specific copy, routes, or API contracts.
|
||||
- A feature component that accepts pre-rendered fragments only to avoid placing ownership correctly.
|
||||
- A child component that receives both raw server data and separately derived flags for the same concept.
|
||||
- A wrapper that changes accessible semantics of the primitive it wraps.
|
||||
- A component that exposes controlled props but still keeps a competing private state for the same value.
|
||||
- A component that cannot render empty, loading, or missing optional API fields without caller-side preprocessing.
|
||||
|
||||
When existing components already own interaction logic, prefer reusing or extending them. If a refactor is necessary, preserve the old interaction contract and add or update focused tests for changed behavior.
|
||||
|
||||
## Props And Types
|
||||
|
||||
Flag:
|
||||
|
||||
- Declaration or export rewrites made only for stylistic uniformity, without changing an owned behavior or contract.
|
||||
- Named `Props` types for trivial one-off props where inline typing is clearer.
|
||||
- Props named by UI implementation instead of domain/API role.
|
||||
- API data converted too early or under a generic name that breaks traceability.
|
||||
- Callers duplicating fallback checks that the lowest rendering component already handles.
|
||||
|
||||
Do not flag `FC`, `React.FC`, function declarations, arrow functions, named exports, or default exports by syntax alone. Report them only when the chosen form causes a concrete type, lifecycle, export, framework, or enforced package-contract defect.
|
||||
|
||||
## Effects
|
||||
|
||||
Flag effects that:
|
||||
|
||||
- Transform props/state for rendering.
|
||||
- Copy one state value into another representing the same concept.
|
||||
- Handle user actions that belong in event handlers.
|
||||
- Reset local state from props or visibility when derivation, a stable semantic identity, or the intended mounted owner already expresses the lifecycle.
|
||||
- Fetch data that belongs in framework APIs or TanStack Query.
|
||||
|
||||
If an effect remains, it must synchronize with a named external system: browser API, subscription, timer, analytics-on-visibility, non-React widget, or imperative DOM integration.
|
||||
|
||||
## State Modeling
|
||||
|
||||
Flag:
|
||||
|
||||
- Storing derived booleans, disabled flags, default tabs, or loading labels that can be calculated from current query/feature state.
|
||||
- Per-session state held by a longer-lived visibility coordinator and cleared through an open-state Effect or a generated key when the primitive's mounted-content lifecycle already matches the intended state lifetime.
|
||||
- A DOM field mirrored into competing prop, default, and React state sources when editing does not require those sources to synchronize.
|
||||
- Local state used to fake server data or generated contract fields.
|
||||
- UI state persisted to localStorage when it is live app state.
|
||||
- Feature-local mock shells wired to unrelated existing APIs before the real API is confirmed.
|
||||
|
||||
Review state lifetime before its storage mechanism. For a hidden surface, distinguish the
|
||||
visibility coordinator from mounted content. State private to one mounted session belongs in that
|
||||
content owner; promote it only when the draft must survive that content owner's unmount or another
|
||||
owner coordinates it. A stable semantic identity key may create a new snapshot when the represented
|
||||
identity changes; a generated key is not a routine reset command.
|
||||
|
||||
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled
|
||||
popups, and feature UI state that has no server source. Submit-only DOM fields may remain
|
||||
uncontrolled; use local controlled state when React must own the current value to drive rendering
|
||||
or coordination. Observing change events or tracking a derived fact such as dirty state does not
|
||||
require mirroring the field value. Do not flag controlled state by itself without a concrete
|
||||
competing-source, stale-state, or ownership defect.
|
||||
|
||||
## Navigation
|
||||
|
||||
Flag:
|
||||
|
||||
- Imperative router navigation for ordinary links.
|
||||
- Button semantics used for navigation.
|
||||
- Navigation state hidden in component state when URL state is required for shareable filters, tabs, or pagination.
|
||||
|
||||
Use `Link` for normal navigation. Use router APIs for mutation success, guarded redirects, command flows, or form submission side effects.
|
||||
[Interactions]: ../../how-to-write-component/references/interactions.md
|
||||
[Ownership]: ../../how-to-write-component/references/ownership.md
|
||||
[Runtime]: ../../how-to-write-component/references/runtime.md
|
||||
[State]: ../../how-to-write-component/references/state.md
|
||||
|
||||
@@ -1,74 +1,28 @@
|
||||
# Data, Query, And Contract Rules
|
||||
# Data, Query, And Contract Review
|
||||
|
||||
Use these rules for generated contracts, TanStack Query, mutations, auth/SSR boundaries, URL state, and client persistence.
|
||||
[Data and queries] owns generated-client, Query options, mutation/cache, imperative access, SSR, authentication, and tenant rules. [State ownership] owns URL state and persistence. Read only the reference needed by the diff; reading a shared reference does not activate its implementation skill.
|
||||
|
||||
## Generated Contracts
|
||||
## Generated Contracts And Query Conventions
|
||||
|
||||
Flag:
|
||||
Review explicit team conventions as contracts, including direct generated options, `skipToken` for missing required input, and shared cache policy. A lint rule may enforce part of a convention; neither a green lint result nor a suppression proves the full contract is satisfied.
|
||||
|
||||
- New legacy service/helper wrappers around generated `queryOptions()` or `mutationOptions()`.
|
||||
- Continuing to use deprecated contract operations when a ready generated contract exists.
|
||||
- Assuming a generated file means an operation is ready without checking deprecated markers, schema shape, and the actual UI consumer.
|
||||
- Re-declaring API DTOs in components.
|
||||
- Adding compatibility layers instead of migrating the pointed line and deleting the old layer.
|
||||
- Establish whether the changed call belongs to a new or migrated surface and whether the generated operation is ready. Check deprecated markers, schema shape, and the real UI consumer before prescribing a migration.
|
||||
- Distinguish a pass-through wrapper from a feature hook with actual orchestration. Check whether an independent execution condition or Promise composition justifies the documented query/mutation exception.
|
||||
- Trace generated input and output types through their boundaries. Identify the exact DTO mirror, field widening, placeholder input, or lost intentional empty value when reporting a violation.
|
||||
- Check whether a local mutation callback owns feature feedback or replaces shared invalidation, retry, or cache defaults. Match optimistic changes to the current list/detail owner.
|
||||
|
||||
Backend Pydantic and OpenAPI schemas own API shape. Generated clients and schemas under `packages/contracts/generated/*` are authoritative at frontend boundaries and use the `{ params, query?, body? }` input shape.
|
||||
## Imperative Access And SSR
|
||||
|
||||
## Queries
|
||||
- Check freshness, projection, retries, and the caller's execution condition separately against [Data and queries]. Trace Promise ownership to distinguish an awaited hard gate from soft prefetching with an explicit failure owner.
|
||||
- For Server Components, identify who renders the data and who may revalidate it. Check dehydration, the same-key client consumer, error handling, and the intended Suspense/server-rendered-content contract when the diff changes streaming.
|
||||
- For auth, setup, roles, branding, or availability, trace authoritative data and the loading/fallback path. A static redirect or placeholder value cannot stand in for a request-dependent decision.
|
||||
|
||||
Flag:
|
||||
## Tenant, URL, And Persistence Boundaries
|
||||
|
||||
- `enabled` used to hide missing required input instead of `input: skipToken`.
|
||||
- Fake fallback IDs or placeholder inputs used to force a query to run.
|
||||
- Query results copied into local state for rendering.
|
||||
- Shared query behavior such as invalidation, stale defaults, or retry rules reimplemented at call sites.
|
||||
- `prefetchQuery` treated as a hard gate or as returning data/errors to the caller.
|
||||
- Trace the current workspace-switch flow and cache lifetime before reporting missing identity in a query key. Verify backend meaning before treating `workspace_id` and `tenant_id` as interchangeable.
|
||||
- For URL and storage changes, identify whether the value is shareable navigation state, live app state, a one-shot signal, or a low-frequency preference. Apply [State ownership] to that category and verify its write/reset owner.
|
||||
|
||||
Use `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))` directly unless a feature hook performs real orchestration.
|
||||
Report the violated rule and applicable scope or the concrete failing path. Do not invent runtime impact when the finding is a team-convention violation.
|
||||
|
||||
## Mutations
|
||||
|
||||
Flag:
|
||||
|
||||
- Deprecated `useInvalid` or `useReset`.
|
||||
- `mutateAsync` used without a need for Promise semantics.
|
||||
- Awaited mutations without `try/catch`.
|
||||
- Components owning shared cache invalidation that belongs in query defaults.
|
||||
- Optimistic updates that do not match current list/detail ownership.
|
||||
|
||||
Use generated `mutationOptions()` directly when possible. Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`.
|
||||
|
||||
## SSR, Auth, And Route Boundaries
|
||||
|
||||
Flag:
|
||||
|
||||
- Request-time auth, setup, workspace role, or tenant decisions moved into static `next.config redirects()`.
|
||||
- Dynamic role gates depending on `workspaces.current` implemented as static path redirects.
|
||||
- Authorization logic depending on soft `prefetchQuery`.
|
||||
- Removing a client fallback before server API unavailable behavior is defined.
|
||||
- Global placeholder query contracts introduced to solve a route-local Suspense issue.
|
||||
- Branding-sensitive UI reading placeholder defaults without checking pending/placeholder state.
|
||||
|
||||
Separate hard gates from soft prefetches. `fetchQuery` can be a server decision boundary; `prefetchQuery` is cache warmup.
|
||||
|
||||
## Workspace And Tenant
|
||||
|
||||
Flag:
|
||||
|
||||
- Treating workspace switch as ordinary CRUD invalidation when the current app flow performs server switch plus full reload.
|
||||
- Query keys that omit workspace/tenant identity when the query truly varies by workspace and no full reload boundary applies.
|
||||
- Mixing `workspace_id` and `tenant_id` without tracing the current backend/API contract.
|
||||
|
||||
Current Dify workspace switch should be reviewed as a tenant cache boundary first.
|
||||
|
||||
## URL State And Local Storage
|
||||
|
||||
Flag:
|
||||
|
||||
- Shareable filters, tabs, pagination, selected panels, or search state hidden only in component state.
|
||||
- One-shot navigation signals modeled as subscribed persistent state.
|
||||
- Live app state stored in localStorage.
|
||||
- Direct `window.localStorage`, `globalThis.localStorage`, or raw storage calls in app code.
|
||||
- High-frequency interaction state persisted on every change instead of on commit/settle.
|
||||
|
||||
Use URL state for shareable UI state, feature/Jotai/store state for live UI state, and `@/hooks/use-local-storage` only for low-frequency client-only preferences, dismissed notices, and UI defaults.
|
||||
[Data and queries]: ../../how-to-write-component/references/data.md
|
||||
[State ownership]: ../../how-to-write-component/references/state.md
|
||||
|
||||
@@ -16,7 +16,7 @@ owner:
|
||||
| Forms and field semantics | `packages/dify-ui/docs/forms.md` |
|
||||
| Selection and typed values | `packages/dify-ui/docs/selection.md` |
|
||||
| Portals, layers, and floating surfaces | `packages/dify-ui/docs/overlays.md` |
|
||||
| Tailwind and radius tokens | `packages/dify-ui/docs/styling.md` |
|
||||
| State styling, callbacks, and Tailwind | `packages/dify-ui/docs/styling.md` |
|
||||
| Package tests and Storybook | `packages/dify-ui/docs/testing.md` |
|
||||
|
||||
For consumer code under `web/`, also read `web/AGENTS.md` for application-owned reuse policy and
|
||||
|
||||
@@ -71,8 +71,6 @@ Flag:
|
||||
|
||||
## React Flow
|
||||
|
||||
For workflow React Flow components, keep this Dify-specific rule:
|
||||
Use [Dify invariants] for React Flow node/edge consumption and provider availability in RAG Pipe template rendering. Callback-only reads or mutations can use `useStoreApi`.
|
||||
|
||||
- UI consumption should use React Flow hooks such as `useNodes` / `useEdges`.
|
||||
- Callback-only reads or mutations can use `useStoreApi`.
|
||||
- Node components under `web/app/components/workflow/nodes/[nodeName]/node.tsx` must not depend on workflow stores that are absent in RAG Pipe template rendering.
|
||||
[Dify invariants]: dify-invariants.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Testing Review Rules
|
||||
|
||||
`web/docs/test.md` is the canonical frontend testing policy. Use this file only to translate that policy into review findings.
|
||||
Use `web/docs/test.md` for tests owned by `web/` and `packages/dify-ui/docs/testing.md` for tests owned by Dify UI. These owners define their test boundaries, environments, and checks; this reference only adds review questions.
|
||||
|
||||
## Request Missing Tests When Risk Justifies Them
|
||||
|
||||
@@ -13,7 +13,7 @@ Flag missing coverage when a change alters a reachable contract such as:
|
||||
- Accessibility-critical labels, keyboard flow, focus, disabled state, or overlay behavior.
|
||||
- A regression-prone business rule or bug fix that can be reproduced through a public boundary.
|
||||
|
||||
Do not request tests for mechanical changes, pass-through wrappers, implementation details, or visual-only styling unless they affect behavior. Low coverage alone is not a finding.
|
||||
Do not request tests for mechanical changes, pass-through wrappers, implementation details, or incidental visual styling. Low coverage alone is not a finding.
|
||||
|
||||
## Flag Low-Value or Fragile Tests
|
||||
|
||||
@@ -30,8 +30,8 @@ Flag tests that:
|
||||
|
||||
- Prefer semantic queries and accessible names.
|
||||
- Prefer real feature components when integration semantics matter.
|
||||
- Allow intentional child or provider mocks when setup would dominate the test and that boundary is covered independently.
|
||||
- Do not accept semantically inaccurate mocks of Dify UI or legacy base primitives.
|
||||
- Require a real-browser or visual verification plan when `happy-dom` cannot represent the risk.
|
||||
- Check mocks against the owning package's policy; allowed mocks must preserve the public contract and leave the behavior under review real.
|
||||
- Apply the owning package's admission policy before requesting new automation. Real-browser verification can be a one-off review; a simulator limitation alone does not justify a permanent test.
|
||||
- Check that assertions prove the claimed outcome and add evidence beyond existing tests; a browser runtime does not make weak assertions stronger.
|
||||
|
||||
Treat test quality, determinism, and regression value as the review criteria. Do not use test count or coverage percentage as a proxy for quality.
|
||||
|
||||
@@ -5,12 +5,9 @@ description: Use when writing or changing Vitest or React Testing Library tests
|
||||
|
||||
# Frontend Testing
|
||||
|
||||
`web/docs/test.md` is the single policy owner. Read it before changing frontend tests; this skill adds no separate requirements.
|
||||
Read the testing policy for the package that owns the changed tests:
|
||||
|
||||
1. Identify the observable contract and regression risk.
|
||||
2. Choose the smallest boundary that includes the behavior owner.
|
||||
3. Establish the failing case first when practical, then implement one coherent scenario.
|
||||
4. Run the focused spec before the affected suite and relevant static checks.
|
||||
5. Report the behavior verified and any remaining browser, visual, or end-to-end risk.
|
||||
- `web/`: `web/docs/test.md` owns Web test boundaries, environments, and commands.
|
||||
- `packages/dify-ui/`: `packages/dify-ui/docs/testing.md` owns primitive test boundaries, environments, Storybook, and commands.
|
||||
|
||||
Recommend deleting low-value tests as readily as adding missing behavior coverage. Use `web/docs/test.md` for policy and Web commands; use `packages/dify-ui/docs/testing.md` for Dify UI commands.
|
||||
This skill adds no parallel policy or check sequence. Follow the selected owner's requirements, including its validation commands. Recommend deleting low-value tests as readily as adding missing behavior coverage, and report the contract verified and any material verification gap.
|
||||
|
||||
@@ -7,34 +7,26 @@ description: Use when implementing or refactoring React/TypeScript components an
|
||||
|
||||
Use this skill to route component architecture decisions to its bundled references. Read only the references required by the current change.
|
||||
|
||||
## First Decisions
|
||||
|
||||
| Question | Default | Choose differently when |
|
||||
| --- | --- | --- |
|
||||
| Where should code live? | In the product workflow, route, or feature owner. | Several verticals need the same stable contract. |
|
||||
| Who owns state and handlers? | The lowest owner that consumes them and whose lifetime matches the state. | Another owner coordinates the value or it must survive the local owner's unmount. |
|
||||
| Should React control a value? | Leave submit-only DOM fields uncontrolled. | The workflow must own the current value to drive rendering or coordination. |
|
||||
| Should state enter Jotai? | Keep component and form state local. | Siblings need one source of truth or scoped workflow persistence. |
|
||||
| Who owns URL state? | Next.js route APIs and `nuqs`. | Atoms require a read-only route-identity bridge. |
|
||||
| Who owns remote state? | TanStack Query at the lowest consumer. | Atom state drives the query or shared derivations consume it. |
|
||||
| Is a wrapper needed? | Use the primitive or direct code. | The wrapper owns behavior, validation, state, or semantics. |
|
||||
| Is an Effect needed? | Derive during render or handle the user action. | A named external system must be synchronized. |
|
||||
|
||||
## Topic Routing
|
||||
|
||||
- Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership].
|
||||
- Dify UI wrappers or primitive state styling: read the package's [Public API authoring] and [Styling] contracts.
|
||||
- Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state].
|
||||
- Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data].
|
||||
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or other local session state.
|
||||
- Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime].
|
||||
|
||||
## Workflow
|
||||
## Scope And Verification
|
||||
|
||||
1. Identify the behavior owner, the required state lifetime, and the public contract being changed.
|
||||
2. Read the nearby implementation, tests, and only the routed skill references.
|
||||
3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them.
|
||||
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI.
|
||||
Identify the behavior owner, state lifetime, and public contract from the nearby implementation and relevant references. Keep the change within that vertical slice unless the contract requires changes elsewhere. Verify the changed behavior and complete the owning package's required checks. For Web, read `web/docs/test.md` for test work and `web/docs/lint.md` for static checks. Dify UI verification is owned by `packages/dify-ui/docs/testing.md`.
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
- Write canonical Tailwind v4 classes; prefer canonical utilities over equivalent arbitrary values.
|
||||
- Common forms include `w-105` instead of `w-[420px]`, `px-2.25` instead of `px-[9px]`, `bg-linear-to-b` instead of `bg-gradient-to-b`, `wrap-break-word` instead of `break-words`, and `field-sizing-content` instead of `[field-sizing:content]`.
|
||||
|
||||
[Public API authoring]: ../../../packages/dify-ui/docs/authoring.md
|
||||
[Styling]: ../../../packages/dify-ui/docs/styling.md
|
||||
[data]: references/data.md
|
||||
[interactions]: references/interactions.md
|
||||
[ownership]: references/ownership.md
|
||||
|
||||
@@ -8,9 +8,10 @@ Read this document when a component consumes generated contracts, nullable API v
|
||||
- Backend Pydantic and OpenAPI schemas own API shape. Follow the generated `{ params, query?, body? }` input shape; when it is wrong, fix the backend schema and regenerate `packages/contracts/generated/*`.
|
||||
- Do not hand-write DTO mirrors, widen generated fields or enums, edit generated output, or add a parallel frontend status layer unless it models product state absent from the API.
|
||||
- Check deprecated markers, schema shape, and the actual consumer before assuming that a generated operation is ready to use.
|
||||
- When a ready generated operation exists for the changed call, migrate deprecated operations and remove the replaced layer instead of adding compatibility wrappers.
|
||||
- Normalize only at real boundaries such as user input, search, URL params, filenames, DOM IDs, or a required legacy adapter.
|
||||
- Preserve `null`, `undefined`, and intentional empty strings until the final boundary. Do not use `value || undefined` when an empty string means clearing a field.
|
||||
- Build required values in the branch that proves them. Avoid `filter(Boolean)`, truthiness filters, non-null assertions after filters, and placeholder values used only to satisfy types.
|
||||
- Build required values in the branch that proves them. Do not use truthiness filters, non-null assertions, or placeholders to conceal missing required input or discard valid `0`, `false`, or empty-string values.
|
||||
|
||||
## Queries
|
||||
|
||||
@@ -18,25 +19,40 @@ Read this document when a component consumes generated contracts, nullable API v
|
||||
- If query input comes from atom state, keep it in `atomWithQuery`; do not unwrap the atom in a component solely to call `useQuery`.
|
||||
- For missing required input, branch the whole generated input with `skipToken`. Add `enabled` only for an independent execution condition; do not put `skipToken` inside a placeholder payload or coerce IDs to empty strings.
|
||||
- Return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly from TanStack Query atoms. Pass supported options into the generated call instead of spreading into a parallel object.
|
||||
- Share the exact options between prefetch and render when they represent the same request. Do not extract option helpers merely to reuse input construction.
|
||||
- For the same logical request, preserve key, input, operation, and result contracts. Reuse exact options only when
|
||||
transport, context, and cache policy are shared; imperative and observer freshness may differ. Do not extract a helper
|
||||
merely to reuse input construction.
|
||||
- Avoid pass-through service hooks that only rename generated options. Keep feature hooks for actual orchestration or shared domain behavior.
|
||||
|
||||
## Mutations And Cache
|
||||
|
||||
- Use generated `mutationOptions()` directly for owner-local mutations.
|
||||
- Put shared invalidation, retries, and cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Local callbacks may own toast, close, and navigation effects but must not replace shared cache policy.
|
||||
- Do not introduce deprecated `useInvalid` or `useReset` APIs.
|
||||
- Put shared invalidation, retries, and cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. In oRPC v1, caller options override defaults, including callbacks. Local callbacks may own toast, close, and navigation effects but must preserve shared cache behavior.
|
||||
- Prefer `mutate(...)`. Use `mutateAsync(...)` only when Promise composition is required, and catch awaited failures.
|
||||
- Preserve intentional empty values and current list/detail ownership when updating data. Do not add optimistic updates without a verified owner contract.
|
||||
|
||||
## Prefetch And Hidden Surfaces
|
||||
|
||||
- Prefetch expensive secondary content from the trigger or menu-open event when it benefits the visible path. Do not mount hidden subscribers solely to warm the cache.
|
||||
- `prefetchQuery` is cache warmup, not an authorization or availability gate. Use a hard fetch boundary when the server must decide whether rendering may proceed.
|
||||
- Use `query` or `infiniteQuery` for imperative access. `staleTime` defines cache acceptance; `select` projects the return
|
||||
value without replacing cached query-function data. Imperative queries default to no retries when `retry` is not
|
||||
configured, and observer-only `enabled` does not prevent an imperative call.
|
||||
- Migrate deprecated `fetchQuery`, `prefetchQuery`, `ensureQueryData`, and their infinite variants when the current `query` or `infiniteQuery` contract applies. A `staleTime` of `0` treats data as stale; a finite value accepts that freshness window; `Infinity` accepts data until invalidation; `'static'` accepts available data even after invalidation.
|
||||
- `await` blocks, `return` transfers the Promise, and `void` discards its value without handling rejection. Handle
|
||||
rejection before discarding a potentially rejecting Promise; use `.catch(noop)` only for intentional silence or
|
||||
feedback owned elsewhere. Hard server gates await the query and preserve rejection.
|
||||
|
||||
## SSR, Authentication, And Workspace
|
||||
|
||||
- Static configuration owns path-invariant routing. Request-dependent authentication, setup, role, and tenant decisions belong to SSR or runtime decision boundaries.
|
||||
- Distinguish soft SSR cache warming from authoritative decisions. Prefetched or placeholder data must not grant access or represent successful availability.
|
||||
- Treat Server Components as query prefetch-and-dehydrate owners by default. Do not render or pass an imperative query
|
||||
result when a browser observer can revalidate the same data unless ownership and freshness explicitly prevent drift.
|
||||
- Non-blocking RSC streaming requires pending-query dehydration without redacting Next.js server errors, a
|
||||
`HydrationBoundary` around the same-key client consumer, and Suspense when that content must be server-rendered.
|
||||
- Never reuse tenant-scoped state after switching workspaces. Discard it at the switch boundary or isolate it by workspace identity.
|
||||
- Trace the current switch flow before choosing cache handling: server switch plus full reload is a tenant boundary, not ordinary CRUD invalidation. Include workspace identity in varying query keys when no full-reload boundary applies, and trace the backend contract before interchanging `workspace_id` and `tenant_id`.
|
||||
- Do not make product or authorization decisions from bootstrap defaults. Wait for authoritative data, or render an explicit loading or error state.
|
||||
- Preserve an existing client fallback until server API-unavailable behavior has an explicit owner.
|
||||
- Keep loading and Suspense behavior inside the feature that owns the request. Do not add fake global data merely to bypass that boundary.
|
||||
|
||||
@@ -10,23 +10,18 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
|
||||
|
||||
## Keyboard Commands
|
||||
|
||||
- Distinguish application commands from widget-local keyboard semantics. Use `@tanstack/react-hotkeys` for application commands; keep menu navigation, dialog Escape handling, editor behavior, and ARIA widget keys in their local primitive or owner.
|
||||
- Use `useHotkey` or `useHotkeys` for registered commands. When an existing `onKeyDown` intentionally owns the command, use `matchesKeyboardEvent` rather than duplicating modifier parsing or adding another global listener.
|
||||
- Keep registration and keycap or menu display derived from one canonical command. Distinguish registered commands, held keys, and display-only accelerators.
|
||||
- Keep a single-owner command beside its component. Create a feature-local hotkey module only when several production files share it; tests alone do not justify extraction.
|
||||
- Make availability and scope explicit with `enabled`, `ignoreInputs`, and `target`. Put a target ref on the actual behavior owner rather than creating wrapper DOM solely for hotkey scope.
|
||||
- Preserve existing `preventDefault` and propagation behavior when migrating command APIs.
|
||||
- Test observable command behavior, disabled and input scope, target scope, and the registration/display contract at the owning feature boundary.
|
||||
- Keep widget navigation, dialog dismissal, and editor commands in their local primitive or owner.
|
||||
- For Web application commands, follow [Keyboard commands] for TanStack registration, typed bindings, targets, event consumption, display, and verification. Do not recreate that policy in a wrapper or a second command framework.
|
||||
|
||||
## Secondary Surfaces
|
||||
|
||||
- Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy.
|
||||
- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment.
|
||||
- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content.
|
||||
- Keep overlay open-state ownership separate from content-session ownership. A controlled root does not require controlled fields or root-owned drafts.
|
||||
- Match transient state to the primitive's content mount lifecycle. State below an unmounting content boundary gets a fresh instance after unmount; intentionally kept-mounted content needs an explicit persistence or reset policy.
|
||||
- Keep a controlled overlay root at its coordination owner so the primitive can complete exit transitions, focus restoration, and detached-handle behavior. Do not conditionally remove the root to reset content state, and use keys only for stable semantic identity.
|
||||
- Use the [overlay contract] to determine content lifetime and preserve the Root's closing lifecycle. Follow [state ownership] for draft placement and semantic identity; portal placement alone does not locate the state owner.
|
||||
- Place query subscriptions and mutation observers at the owner whose lifetime matches when they should run. Mounted-session work may belong inside content; work that must start or stop exactly with `open` needs an explicit open-state condition.
|
||||
- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root.
|
||||
|
||||
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
|
||||
[Keyboard commands]: ../../../../web/docs/hotkeys.md
|
||||
[state ownership]: state.md
|
||||
|
||||
@@ -14,27 +14,32 @@ Read this document when adding, moving, splitting, or refactoring React componen
|
||||
|
||||
- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence.
|
||||
- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
|
||||
- Repeated TanStack Query calls in siblings are acceptable when each sibling independently consumes the data; the cache already deduplicates requests.
|
||||
- Repeated TanStack Query hooks for the same key and input in Client Component siblings under one QueryClient are
|
||||
acceptable; shared cache is not a reason to hoist. Separate Server Component QueryClients do not share it, so
|
||||
request-level deduplication needs an identified request-local cache or verified framework or transport owner.
|
||||
- Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept.
|
||||
- One pass-through prop layer is acceptable. Repeated forwarding means ownership should move closer to the consumer or into feature-scoped shared state.
|
||||
- Revisit prop forwarding when intermediate components obscure the behavior owner; keep clear data flow rather than introducing shared state merely to avoid passing props.
|
||||
- Do not replace prop drilling with one large view-model hook. Move each query, derived value, and handler to the concrete owner that consumes it.
|
||||
- Keep source selection, defaults, validation, dirty checks, and payload shaping beside the workflow that owns submission.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Prefer reusing or extending the component that already owns an interaction over rebuilding that behavior in a parallel component.
|
||||
- State-heavy wizards, drawers, modals, and secondary workflows can form a small vertical surface with an entrypoint, optional feature-local state, and shallow owners matching real visual regions.
|
||||
- The entrypoint owns route integration, provider wiring, placement, and open-state coordination. A content or session owner keeps state scoped to that mounted surface.
|
||||
- Judge hook lifetime by the component that declares the hook and the primitive's mount contract, not only by where its rendered controls appear in JSX.
|
||||
- Separate hidden dialogs, dropdowns, and popovers into small local owners when their content obscures the parent flow.
|
||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they have their own state, reuse, or semantic boundary.
|
||||
- Extract components or hooks when they clarify ownership or hide a cohesive implementation; keep logic local when extraction only shortens the file or relocates the same coordination.
|
||||
- Avoid wrapper components and wrapper DOM that only rename props, pass children through, or hide the real primitive. A wrapper must own behavior, validation, state, accessibility, layout, or library integration.
|
||||
- Keep feature workflows out of shared components: do not encode one feature through many boolean props or import its copy, routes, and API contracts into a generic component. Do not pass pre-rendered fragments merely to avoid assigning the behavior to its owner.
|
||||
- Loading states for page sections, cards, lists, tables, forms, and drawers should use skeletons scoped to the loaded content. Reserve spinners for small inline busy indicators.
|
||||
|
||||
## Components And Types
|
||||
|
||||
- Choose component declaration and export forms from the actual component contract, framework requirements, and enforced package rules. Existing style is context, not authority; do not rewrite unaffected code solely to normalize `FC`, `function`, arrow-function, named-export, or default-export forms.
|
||||
- Type simple one-off props inline. Name a `Props` type when it is reused, exported, complex, or materially clearer.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep one-off UI refinements and conversions beside their owner.
|
||||
- Name props and converted data after their domain/API role, preserving traceability to the original contract.
|
||||
- Preserve domain value types for selections. Do not widen enums, unions, booleans, numbers, objects, or nullable values to `string` before a real boundary requires it.
|
||||
- Avoid generic `common.tsx` buckets and aliases that only rename another type. Name files, values, and public types after their domain role.
|
||||
- Put fallback and invariant checks in the lowest component that already renders that state. Do not extract helpers whose only purpose is hiding missing display data.
|
||||
|
||||
@@ -11,18 +11,19 @@ Read this document when a change involves Jotai, form drafts, route identity, sh
|
||||
|
||||
## Forms And Sessions
|
||||
|
||||
- Keep form state in the narrowest owner whose lifetime matches the draft. A draft scoped to one mounted surface belongs to that content or session owner; a draft that must survive its current owner's unmount belongs to an explicit longer-lived feature owner.
|
||||
- Prefer uncontrolled fields when values are only read at submit time. Use local controlled state only when React must own the current value to drive dependent UI or linked fields; track derived facts such as dirty state without mirroring the field value. Controlledness does not decide whether a draft is local or persisted.
|
||||
- Follow the [form contract] for field controlledness and the [overlay contract] for Root/content mounting and state lifetime. These primitive contracts also apply when reviewing a consumer.
|
||||
- Choose the Web draft owner from the required lifetime. Keep mounted-session drafts in the content owner; start with the lowest shared React owner when another component needs the draft or it must survive unmounting. Use feature-scoped atoms only when their coordination or persistence contract is needed.
|
||||
- For query-backed defaults, establish the form session after the required defaults are available. `defaultValue` initializes the current mount; a stable semantic identity key may create a fresh snapshot when the represented identity changes. Do not use a generated key as a routine reset command.
|
||||
- Promote drafts beyond the session only when another owner reacts to in-progress values, several workflow steps share one draft, or the draft must intentionally survive unmounting. Start with the lowest shared React owner; use feature-scoped atoms only when their coordination or persistence contract is needed.
|
||||
- Keep validation, source priority, fallback behavior, dirty checks, and payload assembly in the workflow that owns submission.
|
||||
- Derive booleans, disabled flags, default tabs, and loading labels from current state. Do not mirror one value into competing prop, default, and local sources; controlled state alone is not a competing-source defect.
|
||||
- Do not use local state to fake server data or generated contract fields, or connect a feature mock shell to an unrelated API before its actual contract is confirmed.
|
||||
|
||||
## Route And URL State
|
||||
|
||||
- Treat `useParams`, route arguments, and `nuqs` as the owners of URL identity and updates.
|
||||
- Hydrate a primitive atom at the route or surface boundary only when query atoms or shared derived atoms require route identity. Keep URL writes in route and query-state APIs.
|
||||
- Within one route-owned feature, choose one route-identity source. Do not hydrate route identity into atoms while also threading the same ID through multiple component layers.
|
||||
- Put shareable filters, tabs, pagination, and search state in the URL. Keep one-shot navigation signals and transient UI state out of persistent subscriptions.
|
||||
- Put shareable filters, tabs, selected panels, pagination, and search state in the URL. Keep one-shot navigation signals and transient UI state out of persistent subscriptions.
|
||||
|
||||
## Jotai And Query
|
||||
|
||||
@@ -38,3 +39,6 @@ Read this document when a change involves Jotai, form drafts, route identity, sh
|
||||
- Use feature-owned storage modules built on `createLocalStorageState`; callers should not scatter direct storage access or raw keys.
|
||||
- Persist high-frequency interaction state only on commit or after updates settle.
|
||||
- Do not add ad hoc global event listeners for shared state. Centralize subscriptions through the owning atom, store, or subscription hook.
|
||||
|
||||
[form contract]: ../../../../packages/dify-ui/docs/forms.md
|
||||
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Development with devcontainer
|
||||
|
||||
This project includes a devcontainer configuration that allows you to open the project in a container with a fully configured development environment.
|
||||
Both frontend and backend environments are initialized when the container is started.
|
||||
The create hook installs workspace JavaScript dependencies and `uv`; the start hook synchronizes backend dependencies. Follow the [backend setup](../api/README.md) and [frontend guide](../web/README.md) to configure environment files and start services.
|
||||
|
||||
## GitHub Codespaces
|
||||
|
||||
@@ -19,20 +19,6 @@ if you have VS Code installed, you can click the button above to open this proje
|
||||
|
||||
You can learn more in the [Dev Containers documentation](https://code.visualstudio.com/docs/devcontainers/containers).
|
||||
|
||||
## Pros of Devcontainer
|
||||
|
||||
Unified Development Environment: By using devcontainers, you can ensure that all developers are developing in the same environment, reducing the occurrence of "it works on my machine" type of issues.
|
||||
|
||||
Quick Start: New developers can set up their development environment in a few simple steps, without spending a lot of time on environment configuration.
|
||||
|
||||
Isolation: Devcontainers isolate your project from your host operating system, reducing the chance of OS updates or other application installations impacting the development environment.
|
||||
|
||||
## Cons of Devcontainer
|
||||
|
||||
Learning Curve: For developers unfamiliar with Docker and VS Code, using devcontainers may be somewhat complex.
|
||||
|
||||
Performance Impact: While usually minimal, programs running inside a devcontainer may be slightly slower than those running directly on the host.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
if you see such error message when you open this project in codespaces:
|
||||
|
||||
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -9,3 +9,6 @@
|
||||
# Codegen output must stay byte-identical across platforms so
|
||||
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
|
||||
*.generated.ts text eol=lf
|
||||
|
||||
# Skill scripts run on every platform, so keep LF line endings.
|
||||
/skills/** text=auto eol=lf
|
||||
|
||||
36
.github/CODEOWNERS
vendored
36
.github/CODEOWNERS
vendored
@@ -26,6 +26,9 @@
|
||||
/cli/ @GareArc
|
||||
/.github/workflows/cli-tests.yml @GareArc
|
||||
|
||||
# Dify Agent
|
||||
/dify-agent/ @BeautyyuYanli
|
||||
|
||||
# E2E
|
||||
/e2e/ @lyzno1
|
||||
/.github/workflows/web-e2e.yml @lyzno1
|
||||
@@ -33,18 +36,31 @@
|
||||
# Backend (default owner, more specific rules below will override)
|
||||
/api/ @QuantumGhost
|
||||
|
||||
# Backend - Tests
|
||||
# Backend - Tests (default owners, domain-specific rules below will override)
|
||||
/api/tests/ @laipz8200 @QuantumGhost
|
||||
/api/tests/*/core/rag/ @JohnJyong
|
||||
/api/tests/*/services/rag_pipeline/ @JohnJyong
|
||||
/api/tests/*/services/dataset_service/ @JohnJyong
|
||||
/api/tests/*/controllers/console/datasets/ @JohnJyong
|
||||
/api/tests/*/controllers/service_api/dataset/ @JohnJyong
|
||||
/api/tests/*/vdb/ @JohnJyong
|
||||
/api/tests/*/core/plugin/ @WH-2099
|
||||
/api/tests/*/services/plugin/ @WH-2099
|
||||
/api/tests/*/controllers/inner_api/plugin/ @WH-2099
|
||||
/api/tests/*/plugin/ @WH-2099
|
||||
/api/tests/*/controllers/trigger/ @CourTeous33
|
||||
/api/tests/*/core/trigger/ @CourTeous33
|
||||
/api/tests/*/trigger/ @CourTeous33
|
||||
/api/tests/*/controllers/console/billing/ @zyssyz123
|
||||
/api/tests/*/services/enterprise/ @GareArc
|
||||
/api/tests/*/migrations/ @snakevash @laipz8200 @MRZHUH
|
||||
|
||||
# Backend - RAG (Retrieval Augmented Generation)
|
||||
/api/core/rag/ @JohnJyong
|
||||
/api/services/knowledge/ @JohnJyong
|
||||
/api/services/rag_pipeline/ @JohnJyong
|
||||
/api/services/dataset_service.py @JohnJyong
|
||||
/api/services/knowledge_service.py @JohnJyong
|
||||
/api/services/external_knowledge_service.py @JohnJyong
|
||||
/api/services/hit_testing_service.py @JohnJyong
|
||||
/api/services/metadata_service.py @JohnJyong
|
||||
/api/services/vector_service.py @JohnJyong
|
||||
/api/services/entities/knowledge_entities/ @JohnJyong
|
||||
/api/services/entities/external_knowledge_entities/ @JohnJyong
|
||||
/api/controllers/console/datasets/ @JohnJyong
|
||||
@@ -109,8 +125,8 @@
|
||||
/api/tasks/async_workflow_tasks.py @Mairuis
|
||||
|
||||
# Backend - Billing
|
||||
/api/services/billing_service.py @hj24 @zyssyz123
|
||||
/api/controllers/console/billing/ @hj24 @zyssyz123
|
||||
/api/services/billing_service.py @zyssyz123
|
||||
/api/controllers/console/billing/ @zyssyz123
|
||||
|
||||
# Backend - Enterprise
|
||||
/api/configs/enterprise/ @GareArc
|
||||
@@ -128,6 +144,12 @@
|
||||
# Frontend
|
||||
/web/ @iamjoel
|
||||
|
||||
# Frontend - Tooling
|
||||
/lint.config.ts @hyoban @lyzno1
|
||||
/eslint.config.mjs @hyoban @lyzno1
|
||||
/knip.config.ts @hyoban @lyzno1
|
||||
/vite.config.ts @hyoban @lyzno1
|
||||
|
||||
# Frontend - Platform and Features
|
||||
/web/config/ @lyzno1
|
||||
/web/env.ts @lyzno1
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: '🕷️ Bug report'
|
||||
description: Report errors or unexpected behavior
|
||||
labels:
|
||||
- bug
|
||||
- 🐞 bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: '⭐ Feature or enhancement request'
|
||||
description: Propose something new.
|
||||
labels:
|
||||
- enhancement
|
||||
- 💪 enhancement
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
|
||||
29
.github/actions/setup-core-e2e/action.yml
vendored
Normal file
29
.github/actions/setup-core-e2e/action.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Setup Core E2E Dependencies
|
||||
description: Install API dependencies alongside web dependencies and the selected browser
|
||||
|
||||
inputs:
|
||||
browser:
|
||||
description: Browser to install (chromium or webkit)
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup web toolchain
|
||||
uses: ./.github/actions/setup-web
|
||||
with:
|
||||
run-install: 'false'
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies in parallel
|
||||
shell: bash
|
||||
env:
|
||||
E2E_INSTALL_BROWSER: ${{ inputs.browser }}
|
||||
E2E_SETUP_ACTION_PATH: ${{ github.action_path }}
|
||||
run: bash "$E2E_SETUP_ACTION_PATH/install.sh"
|
||||
56
.github/actions/setup-core-e2e/install.sh
vendored
Executable file
56
.github/actions/setup-core-e2e/install.sh
vendored
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case "${E2E_INSTALL_BROWSER:-}" in
|
||||
chromium|webkit) ;;
|
||||
*) echo "Expected chromium or webkit" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Keep both branches in this step so failures are collected before tests start.
|
||||
started=$SECONDS
|
||||
timing_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$timing_dir"' EXIT
|
||||
|
||||
(
|
||||
api_started=$SECONDS
|
||||
uv sync --project api --dev
|
||||
echo "$((SECONDS - api_started))" > "$timing_dir/api"
|
||||
) &
|
||||
api_pid=$!
|
||||
|
||||
(
|
||||
web_started=$SECONDS
|
||||
vp install --frozen-lockfile
|
||||
echo "$((SECONDS - web_started))" > "$timing_dir/web"
|
||||
browser_started=$SECONDS
|
||||
cd e2e
|
||||
vp run "e2e:install:ci:$E2E_INSTALL_BROWSER"
|
||||
echo "$((SECONDS - browser_started))" > "$timing_dir/browser"
|
||||
) &
|
||||
web_pid=$!
|
||||
|
||||
api_status=0
|
||||
web_status=0
|
||||
wait "$api_pid" || api_status=$?
|
||||
wait "$web_pid" || web_status=$?
|
||||
|
||||
{
|
||||
echo "### E2E dependency preparation ($E2E_INSTALL_BROWSER)"
|
||||
echo ""
|
||||
echo "Parallel install wall time: $((SECONDS - started))s"
|
||||
echo ""
|
||||
echo "| Phase | Seconds |"
|
||||
echo "| --- | ---: |"
|
||||
for phase in api web browser; do
|
||||
if [[ -f "$timing_dir/$phase" ]]; then
|
||||
echo "| $phase | $(cat "$timing_dir/$phase") |"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo "API exit status: $api_status; Web/browser exit status: $web_status"
|
||||
} >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
|
||||
if (( api_status != 0 || web_status != 0 )); then
|
||||
echo "E2E dependency installation failed (API: $api_status, Web/browser: $web_status)" >&2
|
||||
exit 1
|
||||
fi
|
||||
25
.github/actions/setup-web/action.yml
vendored
25
.github/actions/setup-web/action.yml
vendored
@@ -1,16 +1,29 @@
|
||||
name: Setup Web Environment
|
||||
description: Set up Node.js, Vite+, pnpm, and web dependencies
|
||||
|
||||
inputs:
|
||||
run-install:
|
||||
description: Install web dependencies after setting up the toolchain
|
||||
default: 'true'
|
||||
cache-save:
|
||||
description: Save the dependency cache after the job completes
|
||||
default: 'true'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Setup pnpm and Node.js
|
||||
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
|
||||
with:
|
||||
run_install: false
|
||||
install: false
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@1b32467adbe183473499fd9d5d372c3ed9641754 # v1.18.0
|
||||
uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1
|
||||
with:
|
||||
node-version-file: package.json
|
||||
node-manager: false
|
||||
package-manager: |
|
||||
pnpm: false
|
||||
npm: false
|
||||
cache: true
|
||||
run-install: true
|
||||
# Nested composite post actions can evaluate inputs in the outer scope.
|
||||
cache-save: ${{ inputs.cache-save || 'true' }}
|
||||
run-install: ${{ inputs.run-install }}
|
||||
|
||||
21
.github/actions/wait-e2e-web-build/action.yml
vendored
Normal file
21
.github/actions/wait-e2e-web-build/action.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Wait for Core E2E Web Build
|
||||
description: Wait for the current workflow run's Web build artifact while the producer finishes independently
|
||||
|
||||
outputs:
|
||||
artifact-id:
|
||||
description: ID of the ready Web build artifact
|
||||
value: ${{ steps.wait.outputs.result }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Wait for build artifact
|
||||
id: wait
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
E2E_WAIT_ACTION_PATH: ${{ github.action_path }}
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const waitForBuild = require(`${process.env.E2E_WAIT_ACTION_PATH}/wait.cjs`)
|
||||
return await waitForBuild({ github, context, core, runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT) })
|
||||
104
.github/actions/wait-e2e-web-build/download.cjs
vendored
Normal file
104
.github/actions/wait-e2e-web-build/download.cjs
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
const { execFile } = require('node:child_process')
|
||||
const { appendFile, mkdtemp, rm } = require('node:fs/promises')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const waitForBuild = require('./wait.cjs')
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
async function downloadWebBuild({
|
||||
env = process.env,
|
||||
execute = execFileAsync,
|
||||
webDir = path.resolve(__dirname, '../../../web'),
|
||||
} = {}) {
|
||||
const repository = env.GITHUB_REPOSITORY
|
||||
const runId = env.GITHUB_RUN_ID
|
||||
const runAttempt = Number(env.GITHUB_RUN_ATTEMPT)
|
||||
if (
|
||||
!repository ||
|
||||
!/^\d+$/.test(runId || '') ||
|
||||
!Number.isInteger(runAttempt) ||
|
||||
runAttempt < 1 ||
|
||||
!env.GH_TOKEN
|
||||
) {
|
||||
throw new Error(
|
||||
'Downloading the Web build requires GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, and GH_TOKEN.',
|
||||
)
|
||||
}
|
||||
|
||||
const run = (command, args, timeout = 60_000) =>
|
||||
execute(command, args, {
|
||||
env,
|
||||
timeout,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
const api = async (endpoint, paginate = false) => {
|
||||
const { stdout } = await run('gh', [
|
||||
'api',
|
||||
endpoint,
|
||||
...(paginate ? ['--paginate', '--slurp'] : []),
|
||||
])
|
||||
return JSON.parse(stdout)
|
||||
}
|
||||
const prefix = `repos/${repository}/actions`
|
||||
let summary = ''
|
||||
const artifactId = await waitForBuild({
|
||||
github: {
|
||||
rest: {
|
||||
actions: { listJobsForWorkflowRunAttempt: 'jobs', listWorkflowRunArtifacts: 'artifacts' },
|
||||
},
|
||||
paginate: async (kind) => {
|
||||
const endpoint =
|
||||
kind === 'jobs'
|
||||
? `${prefix}/runs/${runId}/attempts/${runAttempt}/jobs?per_page=100`
|
||||
: `${prefix}/runs/${runId}/artifacts?per_page=100`
|
||||
const pages = await api(endpoint, true)
|
||||
return pages.flatMap((page) => page[kind])
|
||||
},
|
||||
},
|
||||
context: { repo: {}, runId },
|
||||
core: {
|
||||
info: console.log,
|
||||
summary: {
|
||||
addHeading(text) {
|
||||
summary += `### ${text}\n\n`
|
||||
return this
|
||||
},
|
||||
addRaw(text) {
|
||||
summary += `${text}\n`
|
||||
return this
|
||||
},
|
||||
async write() {
|
||||
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, summary)
|
||||
},
|
||||
},
|
||||
},
|
||||
runAttempt,
|
||||
})
|
||||
|
||||
const artifact = await api(`${prefix}/artifacts/${artifactId}`)
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'dify-e2e-web-build-'))
|
||||
try {
|
||||
// Artifact names are immutable within a run. Use the name selected by wait.cjs,
|
||||
// including its matching upload timestamp when only consumer jobs are rerun.
|
||||
await run(
|
||||
'gh',
|
||||
['run', 'download', runId, '--repo', repository, '--name', artifact.name, '--dir', directory],
|
||||
300_000,
|
||||
)
|
||||
await run('tar', ['-xf', path.join(directory, 'e2e-web-build.tar'), '-C', webDir])
|
||||
console.log(`Web build artifact ${artifactId} extracted and ready.`)
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = downloadWebBuild
|
||||
|
||||
if (require.main === module) {
|
||||
downloadWebBuild().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
112
.github/actions/wait-e2e-web-build/download.test.cjs
vendored
Normal file
112
.github/actions/wait-e2e-web-build/download.test.cjs
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { access } = require('node:fs/promises')
|
||||
const { test } = require('node:test')
|
||||
const downloadWebBuild = require('./download.cjs')
|
||||
|
||||
function fixture({ downloadFails = false, producerFails = false } = {}) {
|
||||
const calls = []
|
||||
const env = {
|
||||
GITHUB_REPOSITORY: 'owner/repo',
|
||||
GITHUB_RUN_ID: '123',
|
||||
GITHUB_RUN_ATTEMPT: '4',
|
||||
GH_TOKEN: 'test-token',
|
||||
}
|
||||
const execute = async (command, args) => {
|
||||
calls.push({ command, args })
|
||||
if (command === 'tar') return { stdout: '' }
|
||||
if (args[0] === 'run') {
|
||||
if (downloadFails) throw new Error('artifact download failed')
|
||||
return { stdout: '' }
|
||||
}
|
||||
if (args[1].includes('/jobs?')) {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
jobs: [
|
||||
{
|
||||
name: 'Run Web Full-Stack E2E / Prepare Core E2E Web Build',
|
||||
status: 'completed',
|
||||
conclusion: producerFails ? 'failure' : 'success',
|
||||
steps: [
|
||||
{
|
||||
name: 'Upload Web build',
|
||||
conclusion: 'success',
|
||||
started_at: '2026-09-22T04:08:08Z',
|
||||
completed_at: '2026-09-22T04:08:12Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}
|
||||
}
|
||||
if (args[1].includes('/artifacts?')) {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{ artifacts: [] },
|
||||
{
|
||||
artifacts: [
|
||||
{
|
||||
id: 42,
|
||||
name: 'core-e2e-web-build-2',
|
||||
created_at: '2026-09-22T04:08:11Z',
|
||||
expired: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}
|
||||
}
|
||||
assert.equal(args[1], 'repos/owner/repo/actions/artifacts/42')
|
||||
return { stdout: JSON.stringify({ name: 'core-e2e-web-build-2' }) }
|
||||
}
|
||||
return { calls, options: { env, execute, webDir: '/test/web' } }
|
||||
}
|
||||
|
||||
test('downloads the producer-selected artifact on consumer-only reruns, including paginated results', async () => {
|
||||
const { calls, options } = fixture()
|
||||
await downloadWebBuild(options)
|
||||
assert.match(calls[0].args[1], /runs\/123\/attempts\/4\/jobs/)
|
||||
const download = calls.find(({ args }) => args[0] === 'run')
|
||||
assert.deepEqual(download.args.slice(0, 7), [
|
||||
'run',
|
||||
'download',
|
||||
'123',
|
||||
'--repo',
|
||||
'owner/repo',
|
||||
'--name',
|
||||
'core-e2e-web-build-2',
|
||||
])
|
||||
const extract = calls.at(-1)
|
||||
assert.equal(extract.command, 'tar')
|
||||
assert.deepEqual(extract.args.slice(-2), ['-C', '/test/web'])
|
||||
await assert.rejects(access(download.args.at(-1)), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('does not extract a failed download and removes its temporary directory', async () => {
|
||||
const { calls, options } = fixture({ downloadFails: true })
|
||||
await assert.rejects(downloadWebBuild(options), /artifact download failed/)
|
||||
assert.equal(
|
||||
calls.some(({ command }) => command === 'tar'),
|
||||
false,
|
||||
)
|
||||
await assert.rejects(access(calls.at(-1).args.at(-1)), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('does not download or extract when the producer fails', async () => {
|
||||
const { calls, options } = fixture({ producerFails: true })
|
||||
await assert.rejects(downloadWebBuild(options), /Web build finished with failure/)
|
||||
assert.equal(calls.length, 1)
|
||||
})
|
||||
|
||||
test('requires CI metadata and credentials before starting preparation', async () => {
|
||||
const { calls, options } = fixture()
|
||||
for (const key of Object.keys(options.env)) {
|
||||
await assert.rejects(
|
||||
downloadWebBuild({ ...options, env: { ...options.env, [key]: '' } }),
|
||||
/requires GITHUB/,
|
||||
)
|
||||
}
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
65
.github/actions/wait-e2e-web-build/wait.cjs
vendored
Normal file
65
.github/actions/wait-e2e-web-build/wait.cjs
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
const { setTimeout: delay } = require('node:timers/promises')
|
||||
|
||||
module.exports = async function waitForBuild({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
runAttempt,
|
||||
now = Date.now,
|
||||
sleep = delay,
|
||||
timeoutMs = 30 * 60 * 1000,
|
||||
}) {
|
||||
const started = now()
|
||||
const run = { ...context.repo, run_id: context.runId, per_page: 100 }
|
||||
core.info('Waiting for core-e2e-web-build in this workflow run')
|
||||
|
||||
while (now() - started < timeoutMs) {
|
||||
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, {
|
||||
...run,
|
||||
attempt_number: runAttempt,
|
||||
})
|
||||
const producer = jobs.find(
|
||||
(job) =>
|
||||
job.name === 'Prepare Core E2E Web Build' ||
|
||||
job.name.endsWith(' / Prepare Core E2E Web Build'),
|
||||
)
|
||||
if (producer?.status === 'completed' && producer.conclusion !== 'success') {
|
||||
throw new Error(
|
||||
`Web build finished with ${producer.conclusion} before publishing an artifact`,
|
||||
)
|
||||
}
|
||||
|
||||
if (producer && ['in_progress', 'completed'].includes(producer.status)) {
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, run)
|
||||
const upload = producer.steps?.find((step) => step.name === 'Upload Web build')
|
||||
const artifact = artifacts.find((item) => {
|
||||
if (item.expired || !/^core-e2e-web-build-[1-9]\d*$/.test(item.name)) return false
|
||||
if (producer.status === 'in_progress') {
|
||||
// A rebuilding producer must publish this attempt's immutable artifact.
|
||||
return item.name === `core-e2e-web-build-${runAttempt}`
|
||||
}
|
||||
// GitHub relabels reused successful jobs with the current run_attempt.
|
||||
// Their upload step keeps its original timestamps, identifying the artifact
|
||||
// to reuse when only consumers are rerun (including across several attempts).
|
||||
return (
|
||||
upload?.conclusion === 'success' &&
|
||||
item.created_at >= upload.started_at &&
|
||||
item.created_at <= upload.completed_at
|
||||
)
|
||||
})
|
||||
if (artifact) {
|
||||
const seconds = Math.round((now() - started) / 1000)
|
||||
core.info(`Web build artifact ${artifact.name} ready after ${seconds}s`)
|
||||
await core.summary
|
||||
.addHeading('E2E Web build wait')
|
||||
.addRaw(`Artifact wait: ${seconds}s`)
|
||||
.write()
|
||||
return String(artifact.id)
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(Math.min(10000, timeoutMs - (now() - started)))
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for core-e2e-web-build in this workflow run')
|
||||
}
|
||||
164
.github/actions/wait-e2e-web-build/wait.test.cjs
vendored
Normal file
164
.github/actions/wait-e2e-web-build/wait.test.cjs
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { test } = require('node:test')
|
||||
const waitForBuild = require('./wait.cjs')
|
||||
|
||||
function fixture({ artifacts = [], jobs = [producer], error, runAttempt = 2 } = {}) {
|
||||
let elapsed = 0
|
||||
const calls = []
|
||||
const summary = {
|
||||
addHeading() {
|
||||
return this
|
||||
},
|
||||
addRaw() {
|
||||
return this
|
||||
},
|
||||
async write() {},
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
options: {
|
||||
context: { repo: { owner: 'owner', repo: 'repo' }, runId: 123 },
|
||||
core: { info() {}, summary },
|
||||
runAttempt,
|
||||
github: {
|
||||
rest: {
|
||||
actions: { listWorkflowRunArtifacts: 'artifacts', listJobsForWorkflowRunAttempt: 'jobs' },
|
||||
},
|
||||
async paginate(endpoint, params) {
|
||||
calls.push({ endpoint, params })
|
||||
if (error) throw error
|
||||
const values = endpoint === 'artifacts' ? artifacts : jobs
|
||||
return typeof values === 'function' ? values(elapsed) : values
|
||||
},
|
||||
},
|
||||
now: () => elapsed,
|
||||
sleep: async (ms) => {
|
||||
elapsed += ms
|
||||
},
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const producer = {
|
||||
name: 'Run Web Full-Stack E2E / Prepare Core E2E Web Build',
|
||||
status: 'in_progress',
|
||||
run_attempt: 2,
|
||||
}
|
||||
const artifact = {
|
||||
id: 42,
|
||||
name: 'core-e2e-web-build-2',
|
||||
expired: false,
|
||||
created_at: '2026-09-22T04:08:11Z',
|
||||
}
|
||||
const oldArtifact = {
|
||||
...artifact,
|
||||
id: 41,
|
||||
name: 'core-e2e-web-build-1',
|
||||
created_at: '2026-09-22T03:08:11Z',
|
||||
}
|
||||
const successfulProducer = {
|
||||
...producer,
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
steps: [
|
||||
{
|
||||
name: 'Upload Web build',
|
||||
conclusion: 'success',
|
||||
started_at: '2026-09-22T04:08:08Z',
|
||||
completed_at: '2026-09-22T04:08:12Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
test('starts consuming the current artifact without waiting for producer cache saves', async () => {
|
||||
const { options, calls } = fixture({ artifacts: (elapsed) => (elapsed ? [artifact] : []) })
|
||||
assert.equal(await waitForBuild(options), '42')
|
||||
assert.equal(options.now(), 10000)
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.endpoint),
|
||||
['jobs', 'artifacts', 'jobs', 'artifacts'],
|
||||
)
|
||||
for (const { endpoint, params } of calls) {
|
||||
assert.equal(params.run_id, 123)
|
||||
assert.equal(params.owner, 'owner')
|
||||
assert.equal(params.repo, 'repo')
|
||||
if (endpoint === 'jobs') assert.equal(params.attempt_number, 2)
|
||||
}
|
||||
})
|
||||
|
||||
test('full reruns ignore the old artifact until the rebuilding producer publishes this attempt', async () => {
|
||||
const { options } = fixture({
|
||||
artifacts: (elapsed) => (elapsed ? [oldArtifact, artifact] : [oldArtifact]),
|
||||
})
|
||||
assert.equal(await waitForBuild(options), '42')
|
||||
assert.equal(options.now(), 10000)
|
||||
})
|
||||
|
||||
test('does not use an old artifact before the current producer appears or while it is queued', async () => {
|
||||
const { options } = fixture({
|
||||
artifacts: [oldArtifact],
|
||||
jobs: (elapsed) => (elapsed ? [{ ...producer, status: 'queued' }] : []),
|
||||
})
|
||||
await assert.rejects(waitForBuild(options), /Timed out/)
|
||||
})
|
||||
|
||||
test('consumer-only reruns reuse the actual successful upload despite a relabeled run_attempt', async () => {
|
||||
const { options } = fixture({
|
||||
runAttempt: 4,
|
||||
artifacts: [oldArtifact, artifact],
|
||||
jobs: [{ ...successfulProducer, run_attempt: 4 }],
|
||||
})
|
||||
assert.equal(await waitForBuild(options), '42')
|
||||
assert.equal(options.now(), 0)
|
||||
})
|
||||
|
||||
for (const conclusion of ['failure', 'cancelled', 'skipped']) {
|
||||
test(`does not fall back to an old artifact when the producer finishes with ${conclusion}`, async () => {
|
||||
const { options } = fixture({
|
||||
artifacts: [oldArtifact, artifact],
|
||||
jobs: [{ ...producer, status: 'completed', conclusion }],
|
||||
})
|
||||
await assert.rejects(waitForBuild(options), new RegExp(`Web build finished with ${conclusion}`))
|
||||
assert.equal(options.now(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('ignores unrelated and expired artifacts and stops at the deadline', async () => {
|
||||
const { options } = fixture({
|
||||
artifacts: [
|
||||
{ ...artifact, expired: true },
|
||||
{ ...artifact, name: 'other' },
|
||||
],
|
||||
})
|
||||
await assert.rejects(waitForBuild(options), /Timed out/)
|
||||
assert.equal(options.now(), 20000)
|
||||
})
|
||||
|
||||
test('completed rebuilt producers select their upload instead of the old artifact', async () => {
|
||||
const { options } = fixture({ artifacts: [oldArtifact, artifact], jobs: [successfulProducer] })
|
||||
assert.equal(await waitForBuild(options), '42')
|
||||
})
|
||||
|
||||
test('allows artifact visibility to catch up after a successful producer finishes', async () => {
|
||||
const { options } = fixture({
|
||||
artifacts: (elapsed) => (elapsed ? [oldArtifact, artifact] : [oldArtifact]),
|
||||
jobs: [successfulProducer],
|
||||
})
|
||||
assert.equal(await waitForBuild(options), '42')
|
||||
assert.equal(options.now(), 10000)
|
||||
})
|
||||
|
||||
test('does not reuse artifacts without a successful matching upload step', async () => {
|
||||
const { options } = fixture({
|
||||
artifacts: [artifact],
|
||||
jobs: [{ ...successfulProducer, steps: [] }],
|
||||
})
|
||||
await assert.rejects(waitForBuild(options), /Timed out/)
|
||||
})
|
||||
|
||||
test('reports API errors rather than silently waiting until timeout', async () => {
|
||||
const { options } = fixture({ error: new Error('Resource not accessible by integration') })
|
||||
await assert.rejects(waitForBuild(options), /Resource not accessible/)
|
||||
assert.equal(options.now(), 0)
|
||||
})
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -1,7 +1,7 @@
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> 1. Make sure you have read our [contribution guidelines](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md)
|
||||
> 1. Ensure there is an associated issue and you have been assigned to it
|
||||
> 1. Ensure there is an associated issue
|
||||
> 1. Use the correct syntax to link this PR: `Fixes #<issue number>`.
|
||||
|
||||
## Summary
|
||||
@@ -19,6 +19,6 @@
|
||||
|
||||
- [ ] This change requires a documentation update, included: [Dify Document](https://github.com/langgenius/dify-docs)
|
||||
- [ ] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
|
||||
- [ ] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
|
||||
- [ ] I've verified the change and added or updated tests where meaningful regression risk justifies coverage.
|
||||
- [ ] I've updated the documentation accordingly.
|
||||
- [ ] I ran `make lint && make type-check` (backend) and `vp staged` (frontend) to appease the lint gods
|
||||
|
||||
5
.github/scripts/generate-i18n-changes.mjs
vendored
5
.github/scripts/generate-i18n-changes.mjs
vendored
@@ -8,7 +8,8 @@ const headSha = process.env.HEAD_SHA || ''
|
||||
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
|
||||
const outputPath = process.env.I18N_CHANGES_OUTPUT_PATH || '/tmp/i18n-changes.json'
|
||||
|
||||
const englishPath = (fileStem) => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
|
||||
const englishPath = (fileStem) =>
|
||||
path.join(repoRoot, 'web', 'i18n', 'locales', 'en-US', `${fileStem}.json`)
|
||||
|
||||
const readCurrentJson = (fileStem) => {
|
||||
const filePath = englishPath(fileStem)
|
||||
@@ -21,7 +22,7 @@ const readBaseJson = (fileStem) => {
|
||||
if (!baseSha) return null
|
||||
|
||||
try {
|
||||
const relativePath = `web/i18n/en-US/${fileStem}.json`
|
||||
const relativePath = `web/i18n/locales/en-US/${fileStem}.json`
|
||||
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
94
.github/scripts/size-labeler.test.cjs
vendored
Normal file
94
.github/scripts/size-labeler.test.cjs
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
const fs = require('node:fs')
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
const source = fs.readFileSync(
|
||||
require('node:path').join(__dirname, '../workflows/size-labeler.yml'),
|
||||
'utf8',
|
||||
)
|
||||
const script = source
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split(' script: |\n')[1]
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^ /, ''))
|
||||
.join('\n')
|
||||
const run = new (Object.getPrototypeOf(async function () {}).constructor)(
|
||||
'github',
|
||||
'context',
|
||||
'core',
|
||||
script,
|
||||
)
|
||||
async function scenario(files, existing = [], changed = files.length) {
|
||||
const calls = []
|
||||
const warnings = []
|
||||
const api = {
|
||||
pulls: {
|
||||
get: async () => ({ data: { changed_files: changed } }),
|
||||
listFiles: 'files',
|
||||
},
|
||||
issues: {
|
||||
listLabelsOnIssue: 'labels',
|
||||
addLabels: async (p) => calls.push(['add', p.labels[0]]),
|
||||
removeLabel: async (p) => calls.push(['remove', p.name]),
|
||||
},
|
||||
}
|
||||
await run(
|
||||
{
|
||||
rest: api,
|
||||
paginate: async (endpoint) =>
|
||||
endpoint === 'files' ? files : existing.map((name) => ({ name })),
|
||||
},
|
||||
{
|
||||
repo: { owner: 'test', repo: 'repo' },
|
||||
payload: { pull_request: { number: 1 } },
|
||||
},
|
||||
{ warning: (s) => warnings.push(s) },
|
||||
)
|
||||
return { calls, warnings }
|
||||
}
|
||||
for (const [n, label] of [
|
||||
[0, 'XS'],
|
||||
[9, 'XS'],
|
||||
[10, 'S'],
|
||||
[29, 'S'],
|
||||
[30, 'M'],
|
||||
[99, 'M'],
|
||||
[100, 'L'],
|
||||
[499, 'L'],
|
||||
[500, 'XL'],
|
||||
[999, 'XL'],
|
||||
[1000, 'XXL'],
|
||||
]) {
|
||||
test(`threshold ${n}`, async () =>
|
||||
assert.deepEqual((await scenario([{ filename: 'a.py', additions: n, deletions: 0 }])).calls, [
|
||||
['add', `size:${label}`],
|
||||
]))
|
||||
}
|
||||
test('count deletions', async () =>
|
||||
assert.deepEqual((await scenario([{ filename: 'a.py', additions: 0, deletions: 30 }])).calls, [
|
||||
['add', 'size:M'],
|
||||
]))
|
||||
test('ignore nested lockfiles', async () =>
|
||||
assert.deepEqual(
|
||||
(
|
||||
await scenario([
|
||||
{ filename: 'api/uv.lock', additions: 9000, deletions: 5 },
|
||||
{ filename: 'web/pnpm-lock.yaml', additions: 5000, deletions: 0 },
|
||||
])
|
||||
).calls,
|
||||
[['add', 'size:XS']],
|
||||
))
|
||||
test('replace stale size only', async () =>
|
||||
assert.deepEqual(
|
||||
(await scenario([{ filename: 'a.py', additions: 10, deletions: 0 }], ['size:L', 'bug', 'web']))
|
||||
.calls,
|
||||
[
|
||||
['add', 'size:S'],
|
||||
['remove', 'size:L'],
|
||||
],
|
||||
))
|
||||
test('idempotent', async () => assert.deepEqual((await scenario([], ['size:XS', 'bug'])).calls, []))
|
||||
test('incomplete file list', async () => {
|
||||
const r = await scenario([], ['size:L'], 3001)
|
||||
assert.deepEqual(r.calls, [])
|
||||
assert.equal(r.warnings.length, 1)
|
||||
})
|
||||
4
.github/workflows/accessibility-e2e.yml
vendored
4
.github/workflows/accessibility-e2e.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
if [[ "$WCAG_PAGE" == "all" ]]; then
|
||||
vp run e2e:accessibility:${{ inputs.level }}
|
||||
else
|
||||
pnpm exec tsx ./scripts/run-cucumber.ts --full -- --tags "@axe and @wcag-${{ inputs.level }} and @wcag-page-$WCAG_PAGE"
|
||||
pnpm exec node ./scripts/run-cucumber.ts --full -- --tags "@axe and @wcag-${{ inputs.level }} and @wcag-page-$WCAG_PAGE"
|
||||
fi
|
||||
|
||||
- name: Upload accessibility Cucumber report
|
||||
|
||||
251
.github/workflows/api-tests.yml
vendored
251
.github/workflows/api-tests.yml
vendored
@@ -14,28 +14,79 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
api-unit-plan:
|
||||
name: Plan API unit shards
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
enable-cache: false
|
||||
- name: Download shared timing baseline
|
||||
# Public statistics are independent of PR refs and require no credentials.
|
||||
run: |
|
||||
if ! curl --fail --silent --show-error --location \
|
||||
--connect-timeout 5 --max-time 15 --retry 2 --retry-max-time 30 \
|
||||
https://raw.githubusercontent.com/langgenius/dify-test-infra/generated-stats/stats/test-times.json \
|
||||
--output unit-test-durations.json; then
|
||||
rm -f unit-test-durations.json
|
||||
echo '::warning::Timing baseline unavailable; using round-robin file allocation.'
|
||||
fi
|
||||
- name: Freeze file assignments for every shard
|
||||
run: |
|
||||
uv run --project api --no-sync python api/dev/pytest_sharding.py \
|
||||
--shard-index 1 --shard-total 2 \
|
||||
--durations unit-test-durations.json \
|
||||
--write-plan unit-test-plan.json \
|
||||
--split-threshold 60 \
|
||||
--ignore api/tests/unit_tests/controllers \
|
||||
api/tests/unit_tests \
|
||||
api/providers/vdb/*/tests/unit_tests \
|
||||
api/providers/trace/*/tests/unit_tests
|
||||
- name: Upload shared plan
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-unit-plan
|
||||
path: |
|
||||
unit-test-plan.json
|
||||
unit-test-durations.json
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
api-unit:
|
||||
name: API Unit Tests
|
||||
needs: api-unit-plan
|
||||
name: API Unit Tests (${{ matrix.python-version }}, shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
runs-on: depot-ubuntu-24.04
|
||||
env:
|
||||
COVERAGE_FILE: coverage-unit
|
||||
PYTHONUNBUFFERED: '1'
|
||||
COVERAGE_FILE: coverage-unit-${{ matrix.python-version }}-${{ matrix.shardIndex }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- '3.12'
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
shardTotal:
|
||||
- 2
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -47,34 +98,116 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Download shared plan
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: api-unit-plan
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
uv run --project api python api/dev/pytest_sharding.py \
|
||||
--plan unit-test-plan.json \
|
||||
--shard-index "${{ matrix.shardIndex }}" \
|
||||
--shard-total "${{ matrix.shardTotal }}" \
|
||||
--ignore api/tests/unit_tests/controllers \
|
||||
api/tests/unit_tests \
|
||||
api/providers/vdb/*/tests/unit_tests \
|
||||
api/providers/trace/*/tests/unit_tests > "$RUNNER_TEMP/unit-test-files.txt"
|
||||
uv run --project api pytest \
|
||||
-p no:benchmark \
|
||||
--timeout "${PYTEST_TIMEOUT:-20}" \
|
||||
-n auto \
|
||||
api/tests/unit_tests \
|
||||
api/providers/vdb/*/tests/unit_tests \
|
||||
api/providers/trace/*/tests/unit_tests \
|
||||
--ignore=api/tests/unit_tests/controllers
|
||||
# Controller tests register Flask routes at import time, so keep them out of xdist.
|
||||
uv run --project api pytest \
|
||||
--timeout "${PYTEST_TIMEOUT:-20}" \
|
||||
--cov-append \
|
||||
api/tests/unit_tests/controllers
|
||||
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
|
||||
--cov-report= \
|
||||
--file-shard-plan unit-test-plan.json \
|
||||
--shard-index "${{ matrix.shardIndex }}" \
|
||||
--shard-total "${{ matrix.shardTotal }}" \
|
||||
--write-test-durations "test-results/durations-${{ matrix.shardIndex }}.json" \
|
||||
--junitxml=test-results/unit.xml \
|
||||
"@$RUNNER_TEMP/unit-test-files.txt"
|
||||
|
||||
- name: Upload unit test reports
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-test-results-unit-${{ matrix.python-version }}-${{ matrix.shardIndex }}
|
||||
path: test-results/*.xml
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload successful timing observations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-unit-durations-${{ matrix.shardIndex }}
|
||||
path: test-results/durations-*.json
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload unit coverage data
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-coverage-unit
|
||||
path: coverage-unit
|
||||
name: api-coverage-unit-${{ matrix.python-version }}-${{ matrix.shardIndex }}
|
||||
path: ${{ env.COVERAGE_FILE }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
api-controllers:
|
||||
name: API Controller Tests (3.12)
|
||||
runs-on: depot-ubuntu-24.04
|
||||
env:
|
||||
PYTHONUNBUFFERED: '1'
|
||||
COVERAGE_FILE: coverage-controllers-3.12
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Check UV lockfile
|
||||
run: uv lock --project api --check
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Run Controller Tests
|
||||
# Controller tests register Flask routes at import time, so keep them out of xdist.
|
||||
run: |
|
||||
uv run --project api pytest \
|
||||
--timeout "${PYTEST_TIMEOUT:-20}" \
|
||||
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
|
||||
--cov-report= \
|
||||
--junitxml=test-results/controllers.xml \
|
||||
api/tests/unit_tests/controllers
|
||||
|
||||
- name: Upload controller test reports
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-test-results-controllers-3.12
|
||||
path: test-results/*.xml
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload controller coverage data
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-coverage-controllers-3.12
|
||||
path: ${{ env.COVERAGE_FILE }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
api-integration:
|
||||
name: API Integration Tests
|
||||
runs-on: depot-ubuntu-24.04
|
||||
name: API Integration Tests (${{ matrix.python-version }})
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
env:
|
||||
COVERAGE_FILE: coverage-integration
|
||||
COVERAGE_FILE: coverage-integration-${{ matrix.python-version }}
|
||||
STORAGE_TYPE: opendal
|
||||
OPENDAL_SCHEME: fs
|
||||
OPENDAL_FS_ROOT: /tmp/dify-storage
|
||||
@@ -82,6 +215,7 @@ jobs:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- '3.12'
|
||||
@@ -90,11 +224,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -111,7 +244,10 @@ jobs:
|
||||
uv run --project api pytest \
|
||||
-p no:benchmark \
|
||||
--start-middleware \
|
||||
--middleware-stop-timeout 2 \
|
||||
-n auto \
|
||||
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
|
||||
--cov-report= \
|
||||
--timeout "${PYTEST_TIMEOUT:-180}" \
|
||||
api/tests/integration_tests/workflow \
|
||||
api/tests/integration_tests/tools \
|
||||
@@ -120,8 +256,9 @@ jobs:
|
||||
- name: Upload integration coverage data
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-coverage-integration
|
||||
path: coverage-integration
|
||||
name: api-coverage-integration-${{ matrix.python-version }}
|
||||
path: ${{ env.COVERAGE_FILE }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
api-coverage:
|
||||
@@ -129,6 +266,7 @@ jobs:
|
||||
runs-on: depot-ubuntu-24.04
|
||||
needs:
|
||||
- api-unit
|
||||
- api-controllers
|
||||
- api-integration
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -141,18 +279,12 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Download coverage data
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -164,35 +296,48 @@ jobs:
|
||||
- name: Combine coverage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
coverage_version=$(python3 -c 'import tomllib; print(next(p["version"] for p in tomllib.load(open("api/uv.lock", "rb"))["package"] if p["name"] == "coverage"))')
|
||||
|
||||
echo "### API Coverage" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Merged backend coverage report generated for Codecov project status." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
mapfile -t unit_coverage < <(find coverage-data -type f -name 'coverage-unit-*' -print | sort)
|
||||
mapfile -t controller_coverage < <(find coverage-data -type f -name 'coverage-controllers-*' -print | sort)
|
||||
mapfile -t integration_coverage < <(find coverage-data -type f -name 'coverage-integration-*' -print | sort)
|
||||
if [[ "${#unit_coverage[@]}" -ne 2 ]]; then
|
||||
echo "expected 2 unit coverage artifacts, found ${#unit_coverage[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${#controller_coverage[@]}" -ne 1 ]]; then
|
||||
echo "expected 1 controller coverage artifact, found ${#controller_coverage[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${#integration_coverage[@]}" -ne 1 ]]; then
|
||||
echo "expected 1 integration coverage artifact, found ${#integration_coverage[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unit_coverage="$(find coverage-data -type f -name coverage-unit -print -quit)"
|
||||
integration_coverage="$(find coverage-data -type f -name coverage-integration -print -quit)"
|
||||
: "${unit_coverage:?coverage-unit artifact not found}"
|
||||
: "${integration_coverage:?coverage-integration artifact not found}"
|
||||
uvx --from "coverage==$coverage_version" coverage combine "${unit_coverage[@]}" "${controller_coverage[@]}" "${integration_coverage[@]}"
|
||||
uvx --from "coverage==$coverage_version" coverage xml -o coverage.xml
|
||||
|
||||
report_file="$(mktemp)"
|
||||
uv run --project api coverage combine "$unit_coverage" "$integration_coverage"
|
||||
uv run --project api coverage report --show-missing | tee "$report_file"
|
||||
echo "Summary: \`$(tail -n 1 "$report_file")\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
{
|
||||
echo ""
|
||||
echo "<details><summary>Coverage report</summary>"
|
||||
echo ""
|
||||
echo '```'
|
||||
cat "$report_file"
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
uv run --project api coverage xml -o coverage.xml
|
||||
# Reuse the XML totals instead of analyzing every source file again for a text report.
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
coverage = ET.parse("coverage.xml").getroot()
|
||||
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
|
||||
summary.write("### API Coverage\n\n")
|
||||
summary.write("Merged backend coverage report generated for Codecov project status.\n\n")
|
||||
summary.write("| Metric | Covered | Total | Coverage |\n")
|
||||
summary.write("| --- | ---: | ---: | ---: |\n")
|
||||
for label, metric in (("Lines", "lines"), ("Branches", "branches")):
|
||||
covered = int(coverage.attrib[f"{metric}-covered"])
|
||||
total = int(coverage.attrib[f"{metric}-valid"])
|
||||
percentage = f"{covered / total:.2%}" if total else "N/A"
|
||||
summary.write(f"| {label} | {covered} | {total} | {percentage} |\n")
|
||||
summary.write("\nSee Codecov for per-file coverage details.\n")
|
||||
PY
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
disable_search: true
|
||||
|
||||
2
.github/workflows/autofix.yml
vendored
2
.github/workflows/autofix.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
python-version: '3.11'
|
||||
|
||||
- if: github.event_name != 'merge_group' && (steps.api-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true' || steps.dify-agent-changes.outputs.any_changed == 'true')
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
||||
12
.github/workflows/build-push.yml
vendored
12
.github/workflows/build-push.yml
vendored
@@ -160,10 +160,10 @@ jobs:
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Validate Docker image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.build_context }}
|
||||
@@ -245,8 +245,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: ''
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
46
.github/workflows/cli-e2e.yml
vendored
46
.github/workflows/cli-e2e.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -87,10 +87,9 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
install: false
|
||||
|
||||
- name: Install CLI dependencies
|
||||
working-directory: cli
|
||||
@@ -122,7 +121,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -131,11 +130,6 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run framework + output + error-handling
|
||||
@@ -172,7 +166,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -181,11 +175,6 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run discovery suite
|
||||
@@ -238,7 +227,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -247,11 +236,6 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: 'Run run/${{ matrix.name }}'
|
||||
@@ -282,7 +266,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
@@ -303,7 +287,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -312,11 +296,6 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run auth/login + status + whoami
|
||||
@@ -362,7 +341,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -371,11 +350,6 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run use / devices / logout / agent (serial)
|
||||
@@ -422,7 +396,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-last-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
|
||||
1
.github/workflows/cli-edge.yml
vendored
1
.github/workflows/cli-edge.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/generated/api/openapi/**'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
5
.github/workflows/cli-release.yml
vendored
5
.github/workflows/cli-release.yml
vendored
@@ -77,11 +77,6 @@ jobs:
|
||||
echo "dify_tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::target Dify release ${tag}"
|
||||
|
||||
- name: Compatibility check
|
||||
env:
|
||||
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
|
||||
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
|
||||
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Run smoke against live dify
|
||||
working-directory: ./cli
|
||||
run: pnpm exec tsx scripts/run-smoke.ts --base-url http://localhost:5001
|
||||
run: DIFY_SERVER=http://localhost:5001 bun scripts/run-smoke.ts
|
||||
|
||||
- name: Dump dify logs on failure
|
||||
if: failure()
|
||||
|
||||
2
.github/workflows/cli-tests.yml
vendored
2
.github/workflows/cli-tests.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
directory: cli/coverage
|
||||
flags: cli
|
||||
|
||||
8
.github/workflows/db-migration-test.yml
vendored
8
.github/workflows/db-migration-test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
cp envs/middleware.env.example middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
|
||||
uses: hoverkraft-tech/compose-action@b659c744ee9bed25aea2307c017ad849bd104433 # v3.2.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
|
||||
uses: hoverkraft-tech/compose-action@b659c744ee9bed25aea2307c017ad849bd104433 # v3.2.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
|
||||
2
.github/workflows/deploy-knowledge.yml
vendored
2
.github/workflows/deploy-knowledge.yml
vendored
@@ -67,5 +67,7 @@ jobs:
|
||||
host: ${{ secrets.SSH_NEW_RAG_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
# Allow the shared deployment lock and Celery warm shutdown to finish.
|
||||
command_timeout: 300m
|
||||
script: |
|
||||
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
|
||||
|
||||
2
.github/workflows/dify-agent-tests.yml
vendored
2
.github/workflows/dify-agent-tests.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/docker-build.yml
vendored
4
.github/workflows/docker-build.yml
vendored
@@ -96,10 +96,10 @@ jobs:
|
||||
file: 'docker/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.context }}
|
||||
|
||||
39
.github/workflows/image-resources.yml
vendored
Normal file
39
.github/workflows/image-resources.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Image optimization
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
image-resources:
|
||||
name: Image optimization
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up workspace toolchain
|
||||
uses: ./.github/actions/setup-web
|
||||
with:
|
||||
run-install: 'false'
|
||||
|
||||
- name: Install image checker dependencies
|
||||
run: pnpm --filter @dify/image-resources... install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Type-check image checker
|
||||
run: pnpm --filter @dify/image-resources type-check
|
||||
|
||||
- name: Test image checker
|
||||
run: pnpm --filter @dify/image-resources test
|
||||
|
||||
- name: Check image optimization
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
pnpm --filter @dify/image-resources check:images --base "$BASE_SHA"
|
||||
301
.github/workflows/main-ci.yml
vendored
301
.github/workflows/main-ci.yml
vendored
@@ -21,9 +21,10 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'merge_group' }}
|
||||
|
||||
jobs:
|
||||
# Keep orchestration independent of the Depot pool used for test execution.
|
||||
pre_job:
|
||||
name: Skip Duplicate Checks
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
|
||||
steps:
|
||||
@@ -37,9 +38,7 @@ jobs:
|
||||
# Check which paths were changed to determine which tests to run
|
||||
check-changes:
|
||||
name: Check Changed Files
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
api-changed: ${{ steps.changes.outputs.api }}
|
||||
cli-changed: ${{ steps.changes.outputs.cli }}
|
||||
@@ -49,6 +48,11 @@ jobs:
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
sandbox-runtime-changed: ${{ steps.changes.outputs.sandbox-runtime }}
|
||||
dify-agent-changed: ${{ steps.changes.outputs.dify-agent }}
|
||||
python-style-changed: ${{ steps.changes.outputs.python-style }}
|
||||
dify-agent-style-changed: ${{ steps.changes.outputs.dify-agent-style }}
|
||||
web-style-changed: ${{ steps.changes.outputs.web-style }}
|
||||
ts-common-style-changed: ${{ steps.changes.outputs.ts-common-style }}
|
||||
superlinter-changed: ${{ steps.changes.outputs.superlinter }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
@@ -63,6 +67,7 @@ jobs:
|
||||
- 'scripts/lint_controller_sqlalchemy.py'
|
||||
- 'scripts/ast_grep_rules/no_new_getattr.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
|
||||
- '.github/workflows/style.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
@@ -75,6 +80,7 @@ jobs:
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
cli:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/**'
|
||||
- 'packages/tsconfig/**'
|
||||
- 'package.json'
|
||||
@@ -95,8 +101,6 @@ jobs:
|
||||
- '.github/actions/setup-web/**'
|
||||
e2e:
|
||||
- 'api/**'
|
||||
- 'api/pyproject.toml'
|
||||
- 'api/uv.lock'
|
||||
- 'e2e/**'
|
||||
- 'web/**'
|
||||
- 'packages/**'
|
||||
@@ -105,9 +109,13 @@ jobs:
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- 'docker/ssrf_proxy/**'
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
- '.github/actions/setup-core-e2e/**'
|
||||
- '.github/actions/wait-e2e-web-build/**'
|
||||
vdb:
|
||||
- 'api/core/rag/datasource/**'
|
||||
- 'api/tests/integration_tests/vdb/**'
|
||||
@@ -150,8 +158,68 @@ jobs:
|
||||
- 'docker/generate_docker_compose'
|
||||
- 'docker/ssrf_proxy/**'
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
python-style:
|
||||
- 'api/**'
|
||||
- 'scripts/ast_grep_guard.py'
|
||||
- 'scripts/check_no_new_getattr.py'
|
||||
- 'scripts/check_no_new_controller_sqlalchemy.py'
|
||||
- 'scripts/check_no_new_session_mock.py'
|
||||
- 'scripts/lint_controller_sqlalchemy.py'
|
||||
- 'scripts/ast_grep_rules/no_new_getattr.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
|
||||
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
|
||||
- '.github/workflows/style.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
dify-agent-style:
|
||||
- 'dify-agent/**'
|
||||
- '.github/workflows/style.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
web-style:
|
||||
- 'web/**'
|
||||
- 'e2e/**'
|
||||
- 'sdks/nodejs-client/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'knip.config.ts'
|
||||
- 'scripts/check-web-production-unused-after-knip-fix.mjs'
|
||||
- '.npmrc'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/workflows/web-bundle-size.yml'
|
||||
- '.github/workflows/style.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
ts-common-style:
|
||||
- 'web/**'
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'e2e/**'
|
||||
- 'sdks/nodejs-client/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'tsconfig.json'
|
||||
- 'vite.config.ts'
|
||||
- 'lint.config.ts'
|
||||
- 'eslint.config.mjs'
|
||||
- 'knip.config.ts'
|
||||
- 'scripts/check-web-production-unused-after-knip-fix.mjs'
|
||||
- 'oxlint-suppressions.json'
|
||||
- 'eslint-suppressions.json'
|
||||
- '.vscode/**'
|
||||
- '.github/**'
|
||||
superlinter:
|
||||
- '**.sh'
|
||||
- '**.yaml'
|
||||
- '**.yml'
|
||||
- '**Dockerfile'
|
||||
- 'dev/**'
|
||||
- '.editorconfig'
|
||||
- '.vite-hooks/**'
|
||||
|
||||
# Run tests in parallel while always emitting stable required checks.
|
||||
# Finalizers report unchanged paths directly, avoiding separate skip-only jobs.
|
||||
# Keep each required check name stable and fail closed if classification fails.
|
||||
api-tests-run:
|
||||
name: Run API Tests
|
||||
needs:
|
||||
@@ -161,17 +229,6 @@ jobs:
|
||||
uses: ./.github/workflows/api-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
api-tests-skip:
|
||||
name: Skip API Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped API tests
|
||||
run: echo "No API-related changes detected; skipping API tests."
|
||||
|
||||
api-tests:
|
||||
name: API Tests
|
||||
if: ${{ always() }}
|
||||
@@ -179,21 +236,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- api-tests-run
|
||||
- api-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize API Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.api-changed }}
|
||||
RUN_RESULT: ${{ needs.api-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.api-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "API tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "API tests ran successfully."
|
||||
@@ -204,12 +266,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "API tests were skipped because no API-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
cli-tests-run:
|
||||
@@ -221,17 +283,6 @@ jobs:
|
||||
uses: ./.github/workflows/cli-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
cli-tests-skip:
|
||||
name: Skip CLI Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped CLI tests
|
||||
run: echo "No CLI-related changes detected; skipping CLI tests."
|
||||
|
||||
cli-tests:
|
||||
name: CLI Tests
|
||||
if: ${{ always() }}
|
||||
@@ -239,21 +290,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- cli-tests-run
|
||||
- cli-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize CLI Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.cli-changed }}
|
||||
RUN_RESULT: ${{ needs.cli-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.cli-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "CLI tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "CLI tests ran successfully."
|
||||
@@ -264,12 +320,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "CLI tests were skipped because no CLI-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
web-tests-run:
|
||||
@@ -281,17 +337,6 @@ jobs:
|
||||
uses: ./.github/workflows/web-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
web-tests-skip:
|
||||
name: Skip Web Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped web tests
|
||||
run: echo "No web-related changes detected; skipping web tests."
|
||||
|
||||
web-tests:
|
||||
name: Web Tests
|
||||
if: ${{ always() }}
|
||||
@@ -299,21 +344,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- web-tests-run
|
||||
- web-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Web Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.web-changed }}
|
||||
RUN_RESULT: ${{ needs.web-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.web-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Web tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Web tests ran successfully."
|
||||
@@ -324,12 +374,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Web tests were skipped because no web-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
web-e2e-run:
|
||||
@@ -343,17 +393,6 @@ jobs:
|
||||
run-external-runtime: false
|
||||
secrets: inherit
|
||||
|
||||
web-e2e-skip:
|
||||
name: Skip Web Full-Stack E2E
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped web full-stack e2e
|
||||
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
||||
|
||||
web-e2e:
|
||||
name: Web Full-Stack E2E
|
||||
if: ${{ always() }}
|
||||
@@ -361,21 +400,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- web-e2e-run
|
||||
- web-e2e-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Web Full-Stack E2E status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
|
||||
RUN_RESULT: ${{ needs.web-e2e-run.result }}
|
||||
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Web full-stack E2E ran successfully."
|
||||
@@ -386,20 +430,29 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Web full-stack E2E was skipped because no E2E-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
style-check:
|
||||
name: Style Check
|
||||
needs: pre_job
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
uses: ./.github/workflows/style.yml
|
||||
with:
|
||||
base-rev: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
classification-succeeded: ${{ needs.check-changes.result == 'success' }}
|
||||
run-python-style: ${{ needs.check-changes.outputs.python-style-changed == 'true' }}
|
||||
run-dify-agent-style: ${{ needs.check-changes.outputs.dify-agent-style-changed == 'true' }}
|
||||
run-web-style: ${{ needs.check-changes.outputs.web-style-changed == 'true' }}
|
||||
run-ts-common-style: ${{ needs.check-changes.outputs.ts-common-style-changed == 'true' }}
|
||||
run-superlinter: ${{ needs.check-changes.outputs.superlinter-changed == 'true' }}
|
||||
skip-checks: ${{ needs.pre_job.outputs.should_skip == 'true' }}
|
||||
|
||||
vdb-tests-run:
|
||||
@@ -410,17 +463,6 @@ jobs:
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed == 'true'
|
||||
uses: ./.github/workflows/vdb-tests.yml
|
||||
|
||||
vdb-tests-skip:
|
||||
name: Skip VDB Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped VDB tests
|
||||
run: echo "No VDB-related changes detected; skipping VDB tests."
|
||||
|
||||
vdb-tests:
|
||||
name: VDB Tests
|
||||
if: ${{ always() }}
|
||||
@@ -428,21 +470,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- vdb-tests-run
|
||||
- vdb-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize VDB Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.vdb-changed }}
|
||||
RUN_RESULT: ${{ needs.vdb-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.vdb-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "VDB tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "VDB tests ran successfully."
|
||||
@@ -453,12 +500,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "VDB tests were skipped because no VDB-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "VDB tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
db-migration-test-run:
|
||||
@@ -469,17 +516,6 @@ jobs:
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed == 'true'
|
||||
uses: ./.github/workflows/db-migration-test.yml
|
||||
|
||||
db-migration-test-skip:
|
||||
name: Skip DB Migration Test
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped DB migration tests
|
||||
run: echo "No migration-related changes detected; skipping DB migration tests."
|
||||
|
||||
db-migration-test:
|
||||
name: DB Migration Test
|
||||
if: ${{ always() }}
|
||||
@@ -487,21 +523,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- db-migration-test-run
|
||||
- db-migration-test-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize DB Migration Test status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.migration-changed }}
|
||||
RUN_RESULT: ${{ needs.db-migration-test-run.result }}
|
||||
SKIP_RESULT: ${{ needs.db-migration-test-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "DB migration tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "DB migration tests ran successfully."
|
||||
@@ -512,12 +553,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "DB migration tests were skipped because no migration-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "DB migration tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
sandbox-runtime-tests-run:
|
||||
@@ -529,17 +570,6 @@ jobs:
|
||||
uses: ./.github/workflows/sandbox-runtime-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
sandbox-runtime-tests-skip:
|
||||
name: Skip Sandbox Runtime Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.sandbox-runtime-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped sandbox runtime tests
|
||||
run: echo "No sandbox-runtime-related changes detected; skipping sandbox runtime tests."
|
||||
|
||||
sandbox-runtime-tests:
|
||||
name: Sandbox Runtime Tests
|
||||
if: ${{ always() }}
|
||||
@@ -547,21 +577,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- sandbox-runtime-tests-run
|
||||
- sandbox-runtime-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Sandbox Runtime Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.sandbox-runtime-changed }}
|
||||
RUN_RESULT: ${{ needs.sandbox-runtime-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.sandbox-runtime-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Sandbox runtime tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Sandbox runtime tests ran successfully."
|
||||
@@ -572,12 +607,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Sandbox runtime tests were skipped because no sandbox-runtime-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Sandbox runtime tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
dify-agent-tests-run:
|
||||
@@ -589,17 +624,6 @@ jobs:
|
||||
uses: ./.github/workflows/dify-agent-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
dify-agent-tests-skip:
|
||||
name: Skip Dify Agent Tests
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.dify-agent-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped dify agent tests
|
||||
run: echo "No dify-agent-related changes detected; skipping dify agent tests."
|
||||
|
||||
dify-agent-tests:
|
||||
name: Dify Agent Tests
|
||||
if: ${{ always() }}
|
||||
@@ -607,21 +631,26 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- dify-agent-tests-run
|
||||
- dify-agent-tests-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Dify Agent Tests status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.dify-agent-changed }}
|
||||
RUN_RESULT: ${{ needs.dify-agent-tests-run.result }}
|
||||
SKIP_RESULT: ${{ needs.dify-agent-tests-skip.result }}
|
||||
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
|
||||
CHANGES_RESULT: ${{ needs.check-changes.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Dify agent tests were skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
|
||||
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Dify agent tests ran successfully."
|
||||
@@ -632,10 +661,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
|
||||
echo "Dify agent tests were skipped because no dify-agent-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Dify agent tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
49
.github/workflows/post-merge.yml
vendored
49
.github/workflows/post-merge.yml
vendored
@@ -36,7 +36,6 @@ jobs:
|
||||
- 'e2e/package.json'
|
||||
- 'e2e/test-env.ts'
|
||||
- 'e2e/tsconfig.json'
|
||||
- 'e2e/tsx-register.js'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/post-merge.yml'
|
||||
@@ -44,6 +43,8 @@ jobs:
|
||||
- '.github/actions/setup-web/**'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- 'docker/ssrf_proxy/**'
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
- 'dify-agent/**'
|
||||
- 'dify-agent-runtime/**'
|
||||
- 'api/pyproject.toml'
|
||||
@@ -53,12 +54,30 @@ jobs:
|
||||
- 'api/core/app/apps/agent_app/**'
|
||||
- 'api/core/workflow/nodes/agent_v2/**'
|
||||
- 'api/controllers/console/agent/**'
|
||||
- 'api/controllers/console/app/agent*.py'
|
||||
- 'api/controllers/console/app/audio.py'
|
||||
- 'api/controllers/common/agent_access.py'
|
||||
- 'api/controllers/inner_api/agent/**'
|
||||
- 'api/controllers/inner_api/wraps.py'
|
||||
- 'api/controllers/service_api/app/completion.py'
|
||||
- 'api/controllers/service_api/wraps.py'
|
||||
- 'api/controllers/web/completion.py'
|
||||
- 'api/controllers/web/wraps.py'
|
||||
- 'api/services/agent_llm_inner_service.py'
|
||||
- 'api/services/agent_tool_inner_service.py'
|
||||
- 'api/services/audio_service.py'
|
||||
- 'api/services/entities/agent_llm_inner.py'
|
||||
- 'api/services/entities/agent_tool_inner.py'
|
||||
- 'api/services/errors/agent_tool_inner.py'
|
||||
- 'api/services/agent/**'
|
||||
- 'api/core/plugin/**'
|
||||
- 'api/services/plugin/**'
|
||||
- 'api/core/tools/**'
|
||||
- 'api/services/tools/**'
|
||||
- 'packages/contracts/package.json'
|
||||
- 'packages/contracts/console.ts'
|
||||
- 'packages/contracts/generated/api/console/router.gen.ts'
|
||||
- 'packages/contracts/generated/api/web/**'
|
||||
- 'packages/contracts/generated/api/console/agent/**'
|
||||
- 'packages/contracts/generated/api/console/apps/**'
|
||||
- 'packages/contracts/generated/api/console/datasets/**'
|
||||
@@ -66,20 +85,27 @@ jobs:
|
||||
- 'packages/contracts/generated/api/console/workspaces/**'
|
||||
- 'packages/contracts/generated/api/service/**'
|
||||
- 'web/features/agent-v2/**'
|
||||
- 'web/app/(commonLayout)/agents/**'
|
||||
- 'web/app/(commonLayout)/@detailSidebar/agents/**'
|
||||
- 'web/app/(commonLayout)/roster/**'
|
||||
- 'web/app/\(commonLayout\)/agents/**'
|
||||
- 'web/app/\(commonLayout\)/@detailSidebar/agents/**'
|
||||
- 'web/app/\(shareLayout\)/chat/**'
|
||||
- 'web/app/\(shareLayout\)/components/**'
|
||||
- 'web/app/components/base/chat/chat-with-history/**'
|
||||
- 'web/app/components/base/chat/chat/**'
|
||||
- 'web/app/components/base/voice-input/**'
|
||||
- 'web/app/components/workflow/index.tsx'
|
||||
- 'web/app/components/workflow/block-selector/**'
|
||||
- 'web/app/components/workflow/hooks/use-node-keyboard-interactions.ts'
|
||||
- 'web/app/components/workflow/hooks/use-nodes-interactions.ts'
|
||||
- 'web/app/components/workflow/hooks/use-nodes-sync-draft.ts'
|
||||
- 'web/app/components/workflow/nodes/_base/**'
|
||||
- 'web/app/components/workflow/nodes/agent-v2/**'
|
||||
- 'web/i18n/en-US/agent-v-2.json'
|
||||
- 'web/i18n/en-US/common.json'
|
||||
- 'web/i18n/locales/en-US/agent-v-2.json'
|
||||
- 'web/i18n/locales/en-US/common.json'
|
||||
- 'web/service/base.ts'
|
||||
- 'web/service/client.ts'
|
||||
- 'web/service/console-link.ts'
|
||||
- 'web/service/console-openapi-url.ts'
|
||||
- 'web/service/console-router-loader.ts'
|
||||
- 'web/service/console/**'
|
||||
- 'web/service/share.ts'
|
||||
- 'web/service/use-share.ts'
|
||||
- 'web/service/webapp-address.ts'
|
||||
- 'web/package.json'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
@@ -87,6 +113,9 @@ jobs:
|
||||
name: External Runtime E2E
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.external-e2e-changed == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
uses: ./.github/workflows/web-e2e.yml
|
||||
with:
|
||||
run-external-runtime: true
|
||||
|
||||
2
.github/workflows/pyrefly-diff.yml
vendored
2
.github/workflows/pyrefly-diff.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/pyrefly-type-coverage.yml
vendored
2
.github/workflows/pyrefly-type-coverage.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
55
.github/workflows/size-labeler.yml
vendored
Normal file
55
.github/workflows/size-labeler.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Pull Request Size Labeler
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review, edited]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: size-labeler-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
# Only read PR metadata. Never check out or execute the PR's code here.
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo
|
||||
const issue_number = context.payload.pull_request.number
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number })
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: issue_number, per_page: 100,
|
||||
})
|
||||
// GitHub caps this endpoint at 3,000 files; avoid labeling a partial count.
|
||||
if (files.length !== pr.changed_files) {
|
||||
core.warning('Skipping size label because the changed-file list is incomplete')
|
||||
return
|
||||
}
|
||||
const ignoredNames = new Set(['uv.lock', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'])
|
||||
const changes = files
|
||||
.filter(file => !ignoredNames.has(file.filename.split('/').pop()))
|
||||
.reduce((total, file) => total + file.additions + file.deletions, 0)
|
||||
// Match the descriptions of the repository's existing size labels.
|
||||
const buckets = [
|
||||
[10, 'size:XS'], [30, 'size:S'], [100, 'size:M'],
|
||||
[500, 'size:L'], [1000, 'size:XL'], [Infinity, 'size:XXL'],
|
||||
]
|
||||
const label = buckets.find(([limit]) => changes < limit)[1]
|
||||
const sizeLabels = new Set(buckets.map(([, name]) => name))
|
||||
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner, repo, issue_number, per_page: 100,
|
||||
})
|
||||
// Add first so an API error cannot leave the PR without a size label.
|
||||
if (!labels.some(item => item.name === label)) {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] })
|
||||
}
|
||||
for (const item of labels) {
|
||||
if (sizeLabels.has(item.name) && item.name !== label) {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: item.name })
|
||||
}
|
||||
}
|
||||
219
.github/workflows/style.yml
vendored
219
.github/workflows/style.yml
vendored
@@ -6,6 +6,30 @@ on:
|
||||
base-rev:
|
||||
required: true
|
||||
type: string
|
||||
classification-succeeded:
|
||||
description: Whether the caller successfully classified the changed files.
|
||||
required: true
|
||||
type: boolean
|
||||
run-python-style:
|
||||
description: Run Python style checks for the changed files.
|
||||
required: true
|
||||
type: boolean
|
||||
run-dify-agent-style:
|
||||
description: Run Dify Agent style checks for the changed files.
|
||||
required: true
|
||||
type: boolean
|
||||
run-web-style:
|
||||
description: Run Web style checks for the changed files.
|
||||
required: true
|
||||
type: boolean
|
||||
run-ts-common-style:
|
||||
description: Run shared TypeScript style checks for the changed files.
|
||||
required: true
|
||||
type: boolean
|
||||
run-superlinter:
|
||||
description: Run SuperLinter for the changed files.
|
||||
required: true
|
||||
type: boolean
|
||||
skip-checks:
|
||||
description: Create the required check runs without repeating previously successful work.
|
||||
required: false
|
||||
@@ -20,72 +44,86 @@ permissions:
|
||||
jobs:
|
||||
python-style:
|
||||
name: Python Style
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-python-style || inputs.run-dify-agent-style) }}
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Verify changed-file classification
|
||||
if: ${{ !inputs.classification-succeeded }}
|
||||
run: |
|
||||
echo "Changed-file classification failed; refusing to skip Python style checks." >&2
|
||||
exit 1
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
api/**
|
||||
scripts/ast_grep_guard.py
|
||||
scripts/check_no_new_getattr.py
|
||||
scripts/check_no_new_controller_sqlalchemy.py
|
||||
scripts/lint_controller_sqlalchemy.py
|
||||
scripts/ast_grep_rules/no_new_getattr.yml
|
||||
scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml
|
||||
.github/workflows/style.yml
|
||||
.github/workflows/main-ci.yml
|
||||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
cache-dependency-glob: |
|
||||
api/uv.lock
|
||||
dify-agent/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
if: inputs.run-python-style
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Run Import Linter
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
if: inputs.run-python-style
|
||||
run: uv run --directory api --dev lint-imports
|
||||
|
||||
- name: Run Response Contract Linter
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api --dev python api/dev/lint_response_contracts.py --fail-on-mismatch
|
||||
|
||||
- name: Run No New Getattr Guard
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api python scripts/check_no_new_getattr.py --base-rev "${{ inputs.base-rev }}"
|
||||
|
||||
- name: Run No New Controller SQLAlchemy Guard
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api python scripts/check_no_new_controller_sqlalchemy.py --base-rev "${{ inputs.base-rev }}"
|
||||
|
||||
- name: Run No New Session Mock Guard
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api python scripts/check_no_new_session_mock.py --base-rev "${{ inputs.base-rev }}"
|
||||
|
||||
- name: Run Type Checks
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
if: inputs.run-python-style
|
||||
env:
|
||||
PYREFLY_OUTPUT_FORMAT: github
|
||||
run: make type-check-core
|
||||
|
||||
- name: Run Dify Agent Ruff
|
||||
if: inputs.run-dify-agent-style
|
||||
run: make -C dify-agent check
|
||||
|
||||
- name: Run Dify Agent Type Checks
|
||||
if: inputs.run-dify-agent-style
|
||||
run: make -C dify-agent typecheck
|
||||
|
||||
- name: Dotenv check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
if: inputs.run-python-style
|
||||
run: uv run --project api dotenv-linter ./api/.env.example ./web/.env.example
|
||||
|
||||
web-style:
|
||||
name: Web Style
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
web-build:
|
||||
name: Web Build and Bundle Analysis
|
||||
if: ${{ !inputs.skip-checks && inputs.classification-succeeded && inputs.run-web-style }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/web-bundle-size.yml
|
||||
with:
|
||||
base-rev: ${{ inputs.base-rev }}
|
||||
|
||||
web-style-checks:
|
||||
name: Web Static Checks
|
||||
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-web-style) }}
|
||||
runs-on: depot-ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
@@ -95,126 +133,121 @@ jobs:
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Verify changed-file classification
|
||||
if: ${{ !inputs.classification-succeeded }}
|
||||
working-directory: .
|
||||
run: |
|
||||
echo "Changed-file classification failed; refusing to skip Web style checks." >&2
|
||||
exit 1
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
web/**
|
||||
e2e/**
|
||||
sdks/nodejs-client/**
|
||||
packages/**
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
knip.config.ts
|
||||
scripts/check-web-production-unused-after-knip-fix.mjs
|
||||
.github/workflows/style.yml
|
||||
.github/actions/setup-web/**
|
||||
|
||||
- name: Setup web environment
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Check generated i18n types
|
||||
run: vp run i18n:check-types
|
||||
|
||||
- name: Web tsslint
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: vp run lint:tss
|
||||
|
||||
- name: Web dead code check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: .
|
||||
run: vp run knip
|
||||
|
||||
- name: Web dead code check production
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: .
|
||||
run: vp run knip:production
|
||||
|
||||
- name: Web production unused declarations check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: .
|
||||
run: vp run knip:production-unused-check
|
||||
|
||||
# Preserve the existing required check while build and static checks run in parallel.
|
||||
web-style:
|
||||
name: Web Style
|
||||
if: ${{ always() && !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-web-style) }}
|
||||
needs: [web-build, web-style-checks]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify web checks
|
||||
env:
|
||||
BUILD_RESULT: ${{ needs.web-build.result }}
|
||||
STYLE_RESULT: ${{ needs.web-style-checks.result }}
|
||||
run: |
|
||||
if [[ "$BUILD_RESULT" != 'success' || "$STYLE_RESULT" != 'success' ]]; then
|
||||
echo "Web checks failed: build=$BUILD_RESULT, static checks=$STYLE_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ts-common-style:
|
||||
name: TS Common
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-ts-common-style) }}
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Verify changed-file classification
|
||||
if: ${{ !inputs.classification-succeeded }}
|
||||
run: |
|
||||
echo "Changed-file classification failed; refusing to skip TypeScript style checks." >&2
|
||||
exit 1
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
web/**
|
||||
cli/**
|
||||
e2e/**
|
||||
sdks/nodejs-client/**
|
||||
packages/**
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
vite.config.ts
|
||||
lint.config.ts
|
||||
eslint.config.mjs
|
||||
knip.config.ts
|
||||
scripts/check-web-production-unused-after-knip-fix.mjs
|
||||
oxlint-suppressions.json
|
||||
eslint-suppressions.json
|
||||
.vscode/**
|
||||
.github/**
|
||||
|
||||
- name: Setup web environment
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Restore static check task cache
|
||||
id: static-check-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: node_modules/.vite/task-cache
|
||||
key: vite-task-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
vite-task-static-v1-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Static check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: pnpm -w check
|
||||
run: vp run -w check
|
||||
|
||||
- name: Save static check task cache
|
||||
if: success() && github.event_name != 'merge_group'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: node_modules/.vite/task-cache
|
||||
key: ${{ steps.static-check-cache.outputs.cache-primary-key }}
|
||||
|
||||
superlinter:
|
||||
name: SuperLinter
|
||||
if: ${{ !inputs.skip-checks }}
|
||||
if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-superlinter) }}
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Verify changed-file classification
|
||||
if: ${{ !inputs.classification-succeeded }}
|
||||
run: |
|
||||
echo "Changed-file classification failed; refusing to skip SuperLinter." >&2
|
||||
exit 1
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
**.sh
|
||||
**.yaml
|
||||
**.yml
|
||||
**Dockerfile
|
||||
dev/**
|
||||
.editorconfig
|
||||
.vite-hooks/**
|
||||
|
||||
- name: Super-linter
|
||||
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
env:
|
||||
BASH_SEVERITY: warning
|
||||
DEFAULT_BRANCH: origin/main
|
||||
|
||||
18
.github/workflows/tool-test-sdks.yaml
vendored
18
.github/workflows/tool-test-sdks.yaml
vendored
@@ -9,6 +9,8 @@ on:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .github/actions/setup-web/**
|
||||
- .github/workflows/tool-test-sdks.yaml
|
||||
|
||||
concurrency:
|
||||
group: sdk-tests-${{ github.head_ref || github.run_id }}
|
||||
@@ -28,15 +30,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: ''
|
||||
cache-dependency-path: 'pnpm-lock.yaml'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build and verify package entry points
|
||||
run: |
|
||||
pnpm build
|
||||
node --input-type=module -e "import { access } from 'node:fs/promises'; import 'dify-client'; await access('dist/index.d.ts')"
|
||||
|
||||
45
.github/workflows/translate-i18n-claude.yml
vendored
45
.github/workflows/translate-i18n-claude.yml
vendored
@@ -47,6 +47,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.client_payload.head_sha || github.sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure Git
|
||||
@@ -69,7 +70,7 @@ jobs:
|
||||
/supported: true/ && value != \"en-US\" {
|
||||
printf \"%s \", value
|
||||
}
|
||||
" web/i18n-config/languages.ts | sed 's/[[:space:]]*$//')
|
||||
" web/i18n/languages.ts | sed 's/[[:space:]]*$//')
|
||||
|
||||
generate_changes_json() {
|
||||
node .github/scripts/generate-i18n-changes.mjs
|
||||
@@ -110,9 +111,9 @@ jobs:
|
||||
elif [ "$SYNC_MODE" = "incremental" ]; then
|
||||
BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || true)
|
||||
if [ -n "$BASE_SHA" ]; then
|
||||
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
CHANGED_FILES=$(git diff --name-only --find-renames=100% --diff-filter=AMDT "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/' 2>/dev/null | sed -n '\@^web/i18n/locales/en-US/[^/]*\.json$@p' | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
else
|
||||
CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
CHANGED_FILES=$(find web/i18n/locales/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
fi
|
||||
elif [ "$SYNC_MODE" = "full" ]; then
|
||||
echo "workflow_dispatch full mode requires the files input to stay within CI limits." >&2
|
||||
@@ -162,7 +163,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1.0.211
|
||||
uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1.0.235
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -170,12 +171,12 @@ jobs:
|
||||
show_full_output: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
prompt: |
|
||||
You are the i18n sync agent for the Dify repository.
|
||||
Your job is to keep translations synchronized with the English source files under `${{ github.workspace }}/web/i18n/en-US/`.
|
||||
Your job is to keep translations synchronized with the English source files under `${{ github.workspace }}/web/i18n/locales/en-US/`.
|
||||
|
||||
Use absolute paths at all times:
|
||||
- Repo root: `${{ github.workspace }}`
|
||||
- Web directory: `${{ github.workspace }}/web`
|
||||
- Language config: `${{ github.workspace }}/web/i18n-config/languages.ts`
|
||||
- Language config: `${{ github.workspace }}/web/i18n/languages.ts`
|
||||
|
||||
Inputs:
|
||||
- Files in scope: `${{ steps.context.outputs.CHANGED_FILES }}`
|
||||
@@ -198,12 +199,12 @@ jobs:
|
||||
Required execution plan:
|
||||
1. Resolve target languages.
|
||||
- Use the provided `Target languages` value as the source of truth.
|
||||
- If it is unexpectedly empty, read `${{ github.workspace }}/web/i18n-config/languages.ts` and use every language with `supported: true` except `en-US`.
|
||||
- If it is unexpectedly empty, read `${{ github.workspace }}/web/i18n/languages.ts` and use every language with `supported: true` except `en-US`.
|
||||
2. Stay strictly in scope.
|
||||
- Only process the files listed in `Files in scope`.
|
||||
- Only process the resolved target languages, never `en-US`.
|
||||
- Do not touch unrelated i18n files.
|
||||
- Do not modify `${{ github.workspace }}/web/i18n/en-US/`.
|
||||
- Do not modify `${{ github.workspace }}/web/i18n/locales/en-US/`.
|
||||
3. Resolve source changes.
|
||||
- If `Structured change set available` is `true`, read `/tmp/i18n-changes.json` and use it as the source of truth for file-level and key-level changes.
|
||||
- For each file entry:
|
||||
@@ -227,7 +228,7 @@ jobs:
|
||||
- Match the existing terminology and register used by each locale.
|
||||
- Prefer one Edit per file when stable, but prioritize correctness over batching.
|
||||
6. Verify only the edited files.
|
||||
- Run `vp fmt <repo-relative edited paths such as web/i18n/...>`
|
||||
- Run `vp fmt <repo-relative edited paths such as web/i18n/locales/...>`
|
||||
- Run `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
|
||||
- If verification fails, fix the remaining problems before continuing.
|
||||
7. Stop after the scoped locale files are updated and verification passes.
|
||||
@@ -241,7 +242,7 @@ jobs:
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "$(git -C "${{ github.workspace }}" status --porcelain -- web/i18n/)" ]; then
|
||||
if [ -z "$(git -C "${{ github.workspace }}" status --porcelain -- web/i18n/locales/)" ]; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -260,7 +261,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
git -C "${{ github.workspace }}" checkout -B "${{ steps.pr_meta.outputs.branch_name }}"
|
||||
git -C "${{ github.workspace }}" add web/i18n/
|
||||
git -C "${{ github.workspace }}" add web/i18n/locales/
|
||||
git -C "${{ github.workspace }}" commit -m "chore(i18n): sync translations with en-US"
|
||||
|
||||
- name: Push translation branch
|
||||
@@ -285,6 +286,7 @@ jobs:
|
||||
BASE_SHA: ${{ steps.context.outputs.BASE_SHA }}
|
||||
HEAD_SHA: ${{ steps.context.outputs.HEAD_SHA }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
PR_BODY_FILE=/tmp/i18n-pr-body.md
|
||||
@@ -295,6 +297,7 @@ jobs:
|
||||
export LANG_COUNT
|
||||
|
||||
node <<'NODE' > "$PR_BODY_FILE"
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
|
||||
const changesPath = '/tmp/i18n-changes.json'
|
||||
@@ -303,7 +306,25 @@ jobs:
|
||||
: { changes: {} }
|
||||
|
||||
const filesInScope = (process.env.FILES_IN_SCOPE || '').split(/\s+/).filter(Boolean)
|
||||
const sourceCommit = process.env.HEAD_SHA
|
||||
let sourcePr
|
||||
if (process.env.EVENT_NAME === 'repository_dispatch') {
|
||||
try {
|
||||
const pulls = JSON.parse(execFileSync('gh', [
|
||||
'api', `repos/${process.env.REPO_NAME}/commits/${sourceCommit}/pulls`,
|
||||
], { encoding: 'utf8' }))
|
||||
sourcePr = pulls.find(pr => pr.merged_at && pr.merge_commit_sha === sourceCommit)
|
||||
} catch (error) {
|
||||
console.error(`Could not resolve source PR: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'## Source',
|
||||
'',
|
||||
...(sourcePr ? [`- **PR**: [#${sourcePr.number}](${sourcePr.html_url})`] : []),
|
||||
`- **Commit**: [\`${sourceCommit.slice(0, 12)}\`](https://github.com/${process.env.REPO_NAME}/commit/${sourceCommit})`,
|
||||
'',
|
||||
'## Summary',
|
||||
'',
|
||||
`- **Files synced**: \`${process.env.FILES_IN_SCOPE || '<none>'}\``,
|
||||
@@ -326,7 +347,7 @@ jobs:
|
||||
'## Verification',
|
||||
'',
|
||||
`- \`vp run dify-web#i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``,
|
||||
`- \`vp fmt <repo-relative edited paths such as web/i18n/...>\``,
|
||||
`- \`vp fmt <repo-relative edited paths such as web/i18n/locales/...>\``,
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
|
||||
6
.github/workflows/trigger-i18n-sync.yml
vendored
6
.github/workflows/trigger-i18n-sync.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'web/i18n/en-US/*.json'
|
||||
- 'web/i18n/locales/en-US/*.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -36,9 +36,9 @@ jobs:
|
||||
HEAD_SHA="${{ github.sha }}"
|
||||
|
||||
if [ -n "$BASE_SHA" ]; then
|
||||
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
CHANGED_FILES=$(git diff --name-only --find-renames=100% --diff-filter=AMDT "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/' 2>/dev/null | sed -n '\@^web/i18n/locales/en-US/[^/]*\.json$@p' | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
else
|
||||
CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
CHANGED_FILES=$(find web/i18n/locales/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//')
|
||||
fi
|
||||
|
||||
export BASE_SHA HEAD_SHA CHANGED_FILES
|
||||
|
||||
2
.github/workflows/vdb-tests-full.yml
vendored
2
.github/workflows/vdb-tests-full.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
2
.github/workflows/vdb-tests.yml
vendored
2
.github/workflows/vdb-tests.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
162
.github/workflows/web-bundle-size.yml
vendored
Normal file
162
.github/workflows/web-bundle-size.yml
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
name: Web Bundle Analysis
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
base-rev:
|
||||
description: Exact base revision selected by the main CI event.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
measure:
|
||||
name: Measure ${{ matrix.revision }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- revision: base
|
||||
sha: ${{ inputs.base-rev }}
|
||||
- revision: merge
|
||||
sha: ${{ github.sha }}
|
||||
env:
|
||||
NEXT_TELEMETRY_DISABLED: '1'
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
steps:
|
||||
# Keep one collector for both revisions, including when it does not exist on base yet.
|
||||
- name: Checkout reporting tools
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Preserve collector
|
||||
run: cp web/scripts/bundle-analysis.ts "$RUNNER_TEMP/bundle-analysis.ts"
|
||||
|
||||
- name: Checkout measured revision
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ matrix.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Instrument selected revision
|
||||
run: node "$RUNNER_TEMP/bundle-analysis.ts" instrument web
|
||||
|
||||
- name: Full Vinext build with official Rolldown analysis
|
||||
working-directory: web
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
started=$SECONDS
|
||||
pnpm run build:vinext 2>&1 | tee "$RUNNER_TEMP/vinext-build.log"
|
||||
printf 'Full Vinext build: **%s seconds**.\n' "$((SECONDS - started))" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Collect sizes
|
||||
env:
|
||||
COMMIT_SHA: ${{ matrix.sha }}
|
||||
run: node "$RUNNER_TEMP/bundle-analysis.ts" collect web "$COMMIT_SHA" "$RUNNER_TEMP/bundle-size/summary.json"
|
||||
|
||||
- name: Upload size snapshot
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: web-bundle-summary-${{ matrix.revision }}
|
||||
path: ${{ runner.temp }}/bundle-size/summary.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload official Rolldown analysis
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: web-bundle-analyzer-${{ matrix.revision }}
|
||||
path: |
|
||||
web/dist/client/bundle-analysis.json
|
||||
web/dist/client/bundle-analysis.md
|
||||
${{ runner.temp }}/vinext-build.log
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
report:
|
||||
name: Compare bundle sizes
|
||||
needs: measure
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout reporting tools
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
|
||||
with:
|
||||
install: false
|
||||
|
||||
- name: Download size snapshots
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: web-bundle-summary-*
|
||||
path: bundle-results
|
||||
|
||||
- name: Compare and summarize
|
||||
id: compare
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
node web/scripts/bundle-analysis.ts compare \
|
||||
bundle-results/web-bundle-summary-base/summary.json \
|
||||
bundle-results/web-bundle-summary-merge/summary.json \
|
||||
bundle-report.md
|
||||
printf '\n[Download base/merge snapshots and official Rolldown analysis reports](%s#artifacts).\n' "$RUN_URL" >> bundle-report.md
|
||||
printf '\nComparison: event base SHA vs the tested merge commit at GITHUB_SHA (PR or merge queue).\n' >> bundle-report.md
|
||||
cat bundle-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Forks still receive the job summary and artifacts, without a privileged PR build.
|
||||
- name: Update PR report
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
SHOULD_COMMENT: ${{ steps.compare.outputs.should-comment }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.payload.pull_request.number;
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
if (pr.state !== 'open' || pr.head.sha !== context.payload.pull_request.head.sha ||
|
||||
pr.base.sha !== context.payload.pull_request.base.sha) {
|
||||
core.info('Skipping a superseded bundle report.');
|
||||
return;
|
||||
}
|
||||
const body = fs.readFileSync('bundle-report.md', 'utf8');
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pull_number, per_page: 100,
|
||||
});
|
||||
const existing = comments.find(comment =>
|
||||
comment.user?.login === 'github-actions[bot]' &&
|
||||
comment.body?.startsWith('<!-- dify-vinext-bundle-size -->'));
|
||||
if (process.env.SHOULD_COMMENT !== 'true') {
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||
}
|
||||
core.info('Bundle changes are below the PR comment thresholds. See the job summary.');
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body });
|
||||
}
|
||||
116
.github/workflows/web-e2e.yml
vendored
116
.github/workflows/web-e2e.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
@@ -24,8 +25,31 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test Web build synchronization
|
||||
run: node --test .github/actions/wait-e2e-web-build/*.test.cjs
|
||||
|
||||
- name: Compute build source hash
|
||||
id: build-source
|
||||
shell: bash
|
||||
run: |
|
||||
# Hash tracked build inputs, excluding generated files and backend-only changes.
|
||||
source_hash=$(git ls-tree -r HEAD -- web packages e2e/scripts e2e/package.json e2e/test-env.ts package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc .github/actions/setup-web .github/workflows/web-e2e.yml | sha256sum | cut -d ' ' -f 1)
|
||||
echo "hash=$source_hash" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
with:
|
||||
# Browser jobs already save this dependency cache; avoid duplicate uploads.
|
||||
cache-save: 'false'
|
||||
|
||||
- name: Restore Next.js build cache
|
||||
id: next-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: web/.next/cache
|
||||
key: e2e-next-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }}-${{ steps.build-source.outputs.hash }}
|
||||
restore-keys: |
|
||||
e2e-next-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }}-
|
||||
|
||||
- name: Run E2E support unit tests
|
||||
working-directory: ./e2e
|
||||
@@ -35,7 +59,15 @@ jobs:
|
||||
working-directory: ./e2e
|
||||
env:
|
||||
E2E_FORCE_WEB_BUILD: '1'
|
||||
run: vp run e2e:web:build
|
||||
CACHE_HIT: ${{ steps.next-cache.outputs.cache-hit || 'false' }}
|
||||
run: |
|
||||
started=$SECONDS
|
||||
vp run e2e:web:build
|
||||
{
|
||||
echo "### E2E Web build"
|
||||
echo "Build: $((SECONDS - started))s"
|
||||
echo "Exact cache hit: $CACHE_HIT (false can include a compatible fallback)"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Package Web build
|
||||
run: >-
|
||||
@@ -48,16 +80,21 @@ jobs:
|
||||
- name: Upload Web build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: core-e2e-web-build
|
||||
name: core-e2e-web-build-${{ github.run_attempt }}
|
||||
path: e2e-web-build.tar
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
- name: Save Next.js build cache
|
||||
if: github.event_name != 'merge_group' && steps.next-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: web/.next/cache
|
||||
key: ${{ steps.next-cache.outputs.cache-primary-key }}
|
||||
|
||||
chromium-full:
|
||||
name: Chromium Full (${{ matrix.shard }}/3)
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
needs: core-build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
@@ -76,35 +113,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install API dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Download Web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: core-e2e-web-build
|
||||
|
||||
- name: Extract Web build
|
||||
run: tar -xf e2e-web-build.tar -C web
|
||||
|
||||
- name: Install Chromium
|
||||
- name: Prepare Chromium E2E dependencies
|
||||
timeout-minutes: 15
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install:ci:chromium
|
||||
uses: ./.github/actions/setup-core-e2e
|
||||
with:
|
||||
browser: chromium
|
||||
|
||||
# The runner prepares the backend while waiting for and downloading the build.
|
||||
- name: Run Chromium full shard
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: vp run e2e:full --download-web-build -- --shard ${{ matrix.shard }}/3
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -130,9 +150,8 @@ jobs:
|
||||
webkit-smoke:
|
||||
name: WebKit Browser Smoke
|
||||
if: ${{ !inputs.run-external-runtime }}
|
||||
needs: core-build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
@@ -146,35 +165,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install API dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Download Web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: core-e2e-web-build
|
||||
|
||||
- name: Extract Web build
|
||||
run: tar -xf e2e-web-build.tar -C web
|
||||
|
||||
- name: Install WebKit
|
||||
- name: Prepare WebKit E2E dependencies
|
||||
timeout-minutes: 15
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install:ci:webkit
|
||||
uses: ./.github/actions/setup-core-e2e
|
||||
with:
|
||||
browser: webkit
|
||||
|
||||
# Keep the same overlap and teardown ownership for the WebKit smoke lane.
|
||||
- name: Run WebKit browser smoke
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:full -- --tags '@browser-smoke'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: vp run e2e:full --download-web-build -- --tags '@browser-smoke'
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -213,7 +215,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
|
||||
10
.github/workflows/web-tests.yml
vendored
10
.github/workflows/web-tests.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: web/.vitest-reports/*
|
||||
path: web/.vitest/blob/*
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: web/.vitest-reports
|
||||
path: web/.vitest/blob
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
@@ -111,8 +111,7 @@ jobs:
|
||||
run: vp test --merge-reports --coverage --silent=passed-only
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
directory: web/coverage
|
||||
flags: web
|
||||
@@ -157,8 +156,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
directory: packages/dify-ui/coverage
|
||||
flags: dify-ui
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -185,6 +185,7 @@ docker/volumes/minio/*
|
||||
docker/volumes/milvus/*
|
||||
docker/volumes/chroma/*
|
||||
docker/volumes/opensearch/data/*
|
||||
docker/volumes/openshell-mtls/*
|
||||
docker/volumes/myscale/data/*
|
||||
docker/volumes/myscale/log/*
|
||||
docker/volumes/unstructured/*
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
#!/bin/sh
|
||||
# get the list of modified files
|
||||
files=$(git diff --cached --name-only)
|
||||
|
||||
# check if api or web directory is modified
|
||||
|
||||
api_modified=false
|
||||
skip_web_checks=false
|
||||
set -eu
|
||||
|
||||
git_path() {
|
||||
git rev-parse --git-path "$1"
|
||||
@@ -17,42 +11,7 @@ if [ -f "$(git_path MERGE_HEAD)" ] || \
|
||||
[ -f "$(git_path SQUASH_MSG)" ] || \
|
||||
[ -d "$(git_path rebase-merge)" ] || \
|
||||
[ -d "$(git_path rebase-apply)" ]; then
|
||||
skip_web_checks=true
|
||||
fi
|
||||
|
||||
for file in $files
|
||||
do
|
||||
# Use POSIX compliant pattern matching
|
||||
case "$file" in
|
||||
api/*.py)
|
||||
# set api_modified flag to true
|
||||
api_modified=true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# run linters based on the modified modules
|
||||
|
||||
if $api_modified; then
|
||||
echo "Running Ruff linter on api module"
|
||||
|
||||
# run Ruff linter auto-fixing
|
||||
uv run --project api --dev ruff check --fix ./api
|
||||
|
||||
# run Ruff linter checks
|
||||
uv run --project api --dev ruff check ./api || status=$?
|
||||
|
||||
status=${status:-0}
|
||||
|
||||
if [ $status -ne 0 ]; then
|
||||
echo "Ruff linter on api module error, exit code: $status"
|
||||
echo "Please run 'dev/reformat' to fix the fixable linting errors."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if $skip_web_checks; then
|
||||
echo "Git operation in progress, skipping web checks"
|
||||
echo "Git operation in progress, skipping staged checks; CI validates the result."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
9
.vscode/README.md
vendored
9
.vscode/README.md
vendored
@@ -1,14 +1,11 @@
|
||||
# Debugging with VS Code
|
||||
|
||||
This `launch.json.template` file provides various debug configurations for the Dify project within VS Code / Cursor. To use these configurations, you should copy the contents of this file into a new file named `launch.json` in the same `.vscode` directory.
|
||||
[`launch.json.template`](launch.json.template) provides API and Celery worker debug configurations for VS Code / Cursor. Both use `api/.venv/bin/python`; complete the [backend setup](../api/README.md) first.
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Create `launch.json`**: If you don't have one, create a file named `launch.json` inside the `.vscode` directory.
|
||||
1. **Copy Content**: Copy the entire content from `launch.json.template` into your newly created `launch.json` file.
|
||||
1. Copy `.vscode/launch.json.template` to `.vscode/launch.json`, or merge the configurations into an existing file.
|
||||
1. **Select Debug Configuration**: Go to the Run and Debug view in VS Code / Cursor (Ctrl+Shift+D or Cmd+Shift+D).
|
||||
1. **Start Debugging**: Select the desired configuration from the dropdown menu and click the green play button.
|
||||
|
||||
## Tips
|
||||
|
||||
- If you need to debug with Edge browser instead of Chrome, modify the `serverReadyAction` configuration in the "Next.js: debug full stack" section, change `"debugWithChrome"` to `"debugWithEdge"` to use Microsoft Edge for debugging.
|
||||
Start the frontend separately using the [frontend guide](../web/README.md).
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
# AGENTS.md
|
||||
|
||||
Dify is an open-source platform for building LLM applications, agentic workflows, and RAG pipelines. This monorepo contains the backend API (`api/`), frontend application (`web/`), deployment assets (`docker/`), standalone agent backend (`dify-agent/`), CLI (`cli/`), and end-to-end suite (`e2e/`). Follow the nearest scoped `AGENTS.md` for the files being changed.
|
||||
Dify is an open-source platform for building LLM applications, agentic workflows, and RAG pipelines. This monorepo contains the backend API (`api/`), frontend application (`web/`), deployment assets (`docker/`), standalone agent backend (`dify-agent/`), CLI (`cli/`), and end-to-end suite (`e2e/`). Follow the nearest scoped `AGENTS.md` for the files being changed. Apply its guidance within the user's requested scope; explicit user instructions take precedence over workflow defaults.
|
||||
|
||||
## Repository Gotchas
|
||||
|
||||
- Run backend commands through `uv run --project api <command>`.
|
||||
- Backend integration tests are CI-only and are not expected to run locally.
|
||||
- Keep `docker/.env.example` limited to variables required for a default Docker Compose deployment to start. Put optional and provider-specific settings in the matching `docker/envs/*.env.example` file; `docker/.env` overrides those service-specific env files.
|
||||
|
||||
## Frontend Workflow
|
||||
|
||||
- For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure].
|
||||
|
||||
[Truncated Text Disclosure]: web/docs/truncated-text-disclosure.md
|
||||
|
||||
@@ -22,6 +22,8 @@ Don't forget to link an existing issue or open a new issue in the PR's descripti
|
||||
|
||||
### Bug reports
|
||||
|
||||
For security vulnerabilities, follow our [security policy](./SECURITY.md) and report privately instead of opening a public issue.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Please make sure to include the following information when submitting a bug report:
|
||||
|
||||
@@ -36,7 +38,7 @@ How we prioritize:
|
||||
|
||||
| Issue Type | Priority |
|
||||
| ------------------------------------------------------------ | --------------- |
|
||||
| Bugs in core functions (cloud service, cannot login, applications not working, security loopholes) | Critical |
|
||||
| Bugs in core functions (cloud service, cannot login, applications not working) | Critical |
|
||||
| Non-critical bugs, performance boosts | Medium Priority |
|
||||
| Minor fixes (typos, confusing but working UI) | Low Priority |
|
||||
|
||||
@@ -71,28 +73,21 @@ How we prioritize:
|
||||
1. Please link the issue in the PR description, `fixes #<issue_number>`
|
||||
1. Get merged!
|
||||
|
||||
### AI-assisted contributions
|
||||
|
||||
AI tools are welcome, but you are responsible for understanding and verifying every change you submit. Describe the problem, your solution, and actual test results in your own words. Unreviewed or repetitive low-quality submissions may be closed.
|
||||
|
||||
### Setup the project
|
||||
|
||||
#### Frontend
|
||||
|
||||
For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
|
||||
For setting up the frontend service, please refer to our comprehensive [guide](web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
|
||||
|
||||
**Testing**: Add focused tests when a change affects observable behavior or carries meaningful regression risk. See [web/docs/test.md](https://github.com/langgenius/dify/blob/main/web/docs/test.md) for the canonical frontend testing guidelines.
|
||||
**Testing**: Add focused tests when a change affects observable behavior or carries meaningful regression risk. See [web/docs/test.md](web/docs/test.md) for the canonical frontend testing guidelines.
|
||||
|
||||
#### Backend
|
||||
|
||||
For setting up the backend service, kindly refer to our detailed [instructions](https://github.com/langgenius/dify/blob/main/api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.
|
||||
|
||||
#### Other things to note
|
||||
|
||||
We recommend reviewing this document carefully before proceeding with the setup, as it contains essential information about:
|
||||
|
||||
- Prerequisites and dependencies
|
||||
- Installation steps
|
||||
- Configuration details
|
||||
- Common troubleshooting tips
|
||||
|
||||
Feel free to reach out if you encounter any issues during the setup process.
|
||||
For setting up the backend service, kindly refer to our detailed [instructions](api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.
|
||||
|
||||
## Getting Help
|
||||
|
||||
|
||||
10
Makefile
10
Makefile
@@ -90,15 +90,13 @@ api-contract-lint:
|
||||
@echo "✅ Response contract lint complete"
|
||||
|
||||
type-check:
|
||||
@echo "📝 Running type checks (pyrefly + mypy)..."
|
||||
@echo "📝 Running type checks (pyrefly)..."
|
||||
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "✅ Type checks complete"
|
||||
|
||||
type-check-core:
|
||||
@echo "📝 Running core type checks (pyrefly + mypy)..."
|
||||
@echo "📝 Running core type checks (pyrefly)..."
|
||||
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "✅ Core type checks complete"
|
||||
|
||||
test:
|
||||
@@ -214,8 +212,8 @@ help:
|
||||
@echo " make check - Check code with ruff"
|
||||
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
||||
@echo " make api-contract-lint - Check Flask response docs against returned schemas"
|
||||
@echo " make type-check - Run type checks (pyrefly, mypy)"
|
||||
@echo " make type-check-core - Run core type checks (pyrefly, mypy)"
|
||||
@echo " make type-check - Run type checks (pyrefly)"
|
||||
@echo " make type-check-core - Run core type checks (pyrefly)"
|
||||
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
|
||||
@echo " make test-all - Run full backend tests, including Docker-backed suites"
|
||||
@echo ""
|
||||
|
||||
16
README.md
16
README.md
@@ -58,6 +58,7 @@
|
||||
<a href="./docs/sl-SI/README.md"><img alt="README Slovenščina" src="https://img.shields.io/badge/Sloven%C5%A1%C4%8Dina-d9d9d9"></a>
|
||||
<a href="./docs/bn-BD/README.md"><img alt="README in বাংলা" src="https://img.shields.io/badge/বাংলা-d9d9d9"></a>
|
||||
<a href="./docs/hi-IN/README.md"><img alt="README in हिन्दी" src="https://img.shields.io/badge/Hindi-d9d9d9"></a>
|
||||
<a href="./docs/id-ID/README.md"><img alt="README dalam Bahasa Indonesia" src="https://img.shields.io/badge/Bahasa%20Indonesia-d9d9d9"></a>
|
||||
</p>
|
||||
|
||||
Dify is an open-source LLM app development platform. Its intuitive interface combines AI workflow, RAG pipeline, agent capabilities, model management, observability features (including [Opik](https://www.comet.com/docs/opik/integrations/dify), [Langfuse](https://docs.langfuse.com), and [Arize Phoenix](https://docs.arize.com/phoenix)) and more, letting you quickly go from prototype to production. Here's a list of the core features:
|
||||
@@ -82,7 +83,7 @@ docker compose up -d
|
||||
|
||||
After running, you can access the Dify dashboard in your browser at [http://localhost/install](http://localhost/install) and start the initialization process.
|
||||
|
||||
#### Seeking help
|
||||
### Seeking help
|
||||
|
||||
Please refer to our [FAQ](https://docs.dify.ai/getting-started/install-self-hosted/faqs) if you encounter problems setting up Dify. Reach out to [the community and us](#community--contact) if you are still having issues.
|
||||
|
||||
@@ -104,8 +105,7 @@ Intuitive interface for crafting prompts, comparing model performance, and addin
|
||||
**4. RAG Pipeline**:
|
||||
Extensive RAG capabilities that cover everything from document ingestion to retrieval, with out-of-box support for text extraction from PDFs, PPTs, and other common document formats.
|
||||
|
||||
**5. Agent capabilities**:
|
||||
You can define agents based on LLM Function Calling or ReAct, and add pre-built or custom tools for the agent. Dify provides 50+ built-in tools for AI agents, such as Google Search, DALL·E, Stable Diffusion and WolframAlpha.
|
||||
**5. Agent**: Autonomous agents with a sandbox of their own: they run commands, install software, and handle files to get open-ended tasks done. Describe the agent you want and it builds itself. Give it skills, connect tools from [Dify Marketplace](https://marketplace.dify.ai/?category=tool), MCP servers, or your own APIs, and put it to work as a chat app or as a step in any workflow.
|
||||
|
||||
**6. LLMOps**:
|
||||
Monitor and analyze application logs and performance over time. You could continuously improve prompts, datasets, and models based on production data and annotations.
|
||||
@@ -116,14 +116,14 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
|
||||
## Using Dify
|
||||
|
||||
- **Cloud <br/>**
|
||||
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan. If you run into issues with Dify Cloud, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
|
||||
We host [Dify Cloud](https://cloud.dify.ai) so you can try Dify without managing a server. See the [pricing page](https://dify.ai/pricing) for current plans and usage allowances. If you run into issues, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
|
||||
|
||||
- **Self-hosting Dify Community Edition<br/>**
|
||||
Quickly get Dify running in your environment with this [starter guide](#quick-start).
|
||||
Use our [documentation](https://docs.dify.ai) for further references and more in-depth instructions.
|
||||
|
||||
- **Dify for enterprise / organizations<br/>**
|
||||
We provide additional enterprise-centric features. [Send us an email](mailto:business@dify.ai?subject=%5BGitHub%5DBusiness%20License%20Inquiry) to discuss your enterprise needs. <br/>
|
||||
- **[Dify Enterprise](https://dify.ai/dify-enterprise)<br/>**
|
||||
For organizations requiring self-hosting, SSO, RBAC Security and Enterprise Support SLAs, please fill out [this form](https://share-na2.hsforms.com/14-09ff5HS92Sh4m3f4yrcw40s9fk) to speak to a solution representative. <br/>
|
||||
|
||||
## Staying ahead
|
||||
|
||||
@@ -141,7 +141,7 @@ Dify welcomes contributions of all kinds:
|
||||
|
||||
- **Code**: Read the [Contribution Guide](CONTRIBUTING.md), then browse [good first issues](https://github.com/langgenius/dify/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22).
|
||||
- **Ideas and feedback**: Start or join a [GitHub Discussion](https://github.com/langgenius/dify/discussions).
|
||||
- **Translations**: Follow the [internationalization guide](web/i18n-config/README.md) to add or update a locale.
|
||||
- **Translations**: Follow the [internationalization guide](web/i18n/README.md) to add or update a locale.
|
||||
- **Community**: Share the apps you build, help other users, and spread the word about Dify.
|
||||
|
||||
### Contributors
|
||||
@@ -171,7 +171,7 @@ Choose the channel that best fits your question:
|
||||
|
||||
## Security disclosure
|
||||
|
||||
To protect your privacy, please avoid posting security issues on GitHub. Instead, report issues to security@dify.ai, and our team will respond with detailed answer.
|
||||
Report vulnerabilities privately through GitHub Security Advisories, following our [security policy](SECURITY.md). Do not disclose them in public issues, discussions, or pull requests.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ ARCHIVE_STORAGE_EXPORT_BUCKET=
|
||||
ARCHIVE_STORAGE_ACCESS_KEY=
|
||||
ARCHIVE_STORAGE_SECRET_KEY=
|
||||
ARCHIVE_STORAGE_REGION=auto
|
||||
ARCHIVE_STORAGE_ADDRESS_STYLE=path
|
||||
|
||||
# Azure Blob Storage configuration
|
||||
AZURE_BLOB_ACCOUNT_NAME=your-account-name
|
||||
@@ -234,6 +235,7 @@ VECTOR_INDEX_NAME_PREFIX=Vector_index
|
||||
|
||||
# Weaviate configuration
|
||||
WEAVIATE_ENDPOINT=http://localhost:8080
|
||||
WEAVIATE_GRPC_ENDPOINT=grpc://localhost:50051
|
||||
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
|
||||
WEAVIATE_BATCH_SIZE=100
|
||||
WEAVIATE_TOKENIZATION=word
|
||||
@@ -459,6 +461,12 @@ UPLOAD_IMAGE_FILE_SIZE_LIMIT=10
|
||||
UPLOAD_VIDEO_FILE_SIZE_LIMIT=100
|
||||
UPLOAD_AUDIO_FILE_SIZE_LIMIT=50
|
||||
UPLOAD_SKILL_FILE_SIZE_LIMIT=50
|
||||
SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES=209715200
|
||||
SKILL_PACKAGE_MAX_ENTRIES=5000
|
||||
SKILL_PACKAGE_MAX_SKILL_MD_BYTES=1048576
|
||||
AGENT_PACKAGE_MAX_BYTES=536870912
|
||||
AGENT_PACKAGE_MAX_MANIFEST_BYTES=5242880
|
||||
AGENT_PACKAGE_MAX_ENTRIES=5000
|
||||
|
||||
# Comma-separated list of file extensions blocked from upload for security reasons.
|
||||
# Extensions should be lowercase without dots (e.g., exe,bat,sh,dll).
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
[importlinter]
|
||||
root_packages =
|
||||
core
|
||||
core.rag.extractor.entity
|
||||
constants
|
||||
context
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
factories
|
||||
fields
|
||||
libs
|
||||
machinery
|
||||
models
|
||||
@@ -16,7 +18,7 @@ root_packages =
|
||||
include_external_packages = True
|
||||
|
||||
[importlinter:contract:backend-layers]
|
||||
name = Backend modules follow the transport, application, domain, and shared-library layers
|
||||
name = Backend modules follow the transport, application, execution-kernel, and shared-library layers
|
||||
type = layers
|
||||
layers =
|
||||
controllers
|
||||
@@ -27,7 +29,7 @@ layers =
|
||||
# error makes this list shrink whenever a legacy dependency is removed.
|
||||
unmatched_ignore_imports_alerting = error
|
||||
ignore_imports =
|
||||
core.app.app_config.easy_ui_based_app.dataset.manager -> services.dataset_service
|
||||
core.app.app_config.easy_ui_based_app.dataset.manager -> services.knowledge.dataset_service
|
||||
core.app.apps.advanced_chat.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.advanced_chat.app_generator -> services.conversation_service
|
||||
core.app.apps.advanced_chat.app_generator -> services.errors.conversation
|
||||
@@ -48,45 +50,44 @@ ignore_imports =
|
||||
core.app.apps.message_based_app_generator -> services.errors.conversation
|
||||
core.app.apps.message_based_app_generator -> services.errors.message
|
||||
core.app.apps.pipeline.pipeline_generator -> services
|
||||
core.app.apps.pipeline.pipeline_generator -> services.dataset_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.datasource_provider_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.knowledge.dataset_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.data_source.provider_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.feature_service
|
||||
core.app.apps.pipeline.pipeline_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.pipeline.pipeline_runner -> services.dataset_ref_service
|
||||
core.app.apps.workflow.app_generator -> controllers.console.app.workflow
|
||||
core.app.apps.workflow.app_generator -> services.snippet_generate_service
|
||||
core.app.apps.workflow.app_generator -> services.workflow_draft_variable_service
|
||||
core.app.apps.workflow_app_runner -> tasks.mail_human_input_delivery_task
|
||||
core.app.features.annotation_reply.annotation_reply -> services.annotation_service
|
||||
core.app.features.annotation_reply.annotation_reply -> services.dataset_service
|
||||
core.app.features.annotation_reply.annotation_reply -> services.knowledge.dataset_service
|
||||
core.app.llm.quota -> services.credit_pool_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.account_service
|
||||
core.app.task_pipeline.message_cycle_manager -> services.annotation_service
|
||||
core.app.workflow.layers.persistence -> services.workflow.inspector_events
|
||||
core.datasource.datasource_manager -> services.datasource_provider_service
|
||||
core.helper.credential_utils -> services.enterprise.plugin_manager_service
|
||||
core.helper.credential_utils -> services.system_feature_service
|
||||
core.indexing_runner -> services.vector_space_admission_service
|
||||
core.mcp.auth_client -> services
|
||||
core.provider_manager -> services.credential_permission_service
|
||||
core.provider_manager -> services.credit_pool_service
|
||||
core.provider_manager -> services.feature_service
|
||||
core.rag.datasource.retrieval_service -> services.external_knowledge_service
|
||||
core.rag.datasource.retrieval_service -> services.knowledge.external.service
|
||||
core.rag.index_processor.index_processor -> services.vector_space_admission_service
|
||||
core.rag.index_processor.index_processor_base -> services.file_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.paragraph_index_processor -> services.knowledge.summaries.adapters
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.account_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.summary_index_service
|
||||
core.rag.index_processor.processor.qa_index_processor -> services.summary_index_service
|
||||
core.rag.retrieval.dataset_retrieval -> services.external_knowledge_service
|
||||
core.rag.index_processor.processor.parent_child_index_processor -> services.knowledge.summaries.adapters
|
||||
core.rag.index_processor.processor.qa_index_processor -> services.knowledge.summaries.adapters
|
||||
core.rag.retrieval.dataset_retrieval -> services.knowledge.external.service
|
||||
core.rag.retrieval.dataset_retrieval -> services.knowledge.retrieval.attachments
|
||||
core.rag.retrieval.dataset_retrieval -> services.feature_service
|
||||
core.rag.summary_index.summary_index -> services.summary_index_service
|
||||
core.rag.summary_index.summary_index -> services.knowledge.summaries.adapters
|
||||
core.rag.summary_index.summary_index -> tasks.generate_summary_index_task
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.file_service
|
||||
core.repositories.sqlalchemy_workflow_node_execution_repository -> services.variable_truncator
|
||||
core.tools.tool_manager -> services
|
||||
core.workflow.node_runtime -> services
|
||||
# Node construction delegates datasource dependency assembly to the composition root.
|
||||
core.workflow.node_factory -> extensions.application_services.data_sources
|
||||
core.workflow.nodes.agent.message_transformer -> services
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.prompt_mentions
|
||||
core.workflow.nodes.agent_v2.agent_node -> services.agent.workspace_service
|
||||
@@ -97,12 +98,9 @@ ignore_imports =
|
||||
core.workflow.nodes.agent_v2.session_store -> services.agent.workspace_service
|
||||
core.workflow.nodes.agent_v2.validators -> services.agent.knowledge_datasets
|
||||
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
|
||||
libs.device_flow_security -> controllers.openapi._models
|
||||
libs.device_flow_security -> services.entities.feature_entities
|
||||
libs.device_flow_security -> services.system_feature_service
|
||||
libs.email_i18n -> services.entities.feature_entities
|
||||
libs.email_i18n -> services.system_feature_service
|
||||
libs.external_api -> core
|
||||
libs.external_api -> core.plugin
|
||||
libs.external_api -> core.errors.error
|
||||
libs.external_api -> extensions.ext_logging
|
||||
libs.flask_utils -> models
|
||||
@@ -171,6 +169,141 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:dataset-application-boundary]
|
||||
name = Dataset application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
core.rag.extractor.entity.extract_setting
|
||||
services.knowledge.entities.segments
|
||||
services.knowledge.entities.datasets
|
||||
services.knowledge.entities.document_creation
|
||||
services.data_source.binding_application_service
|
||||
services.data_source.credential_gateway
|
||||
services.data_source.notion_import_application_service
|
||||
services.data_source.entities.credential
|
||||
services.data_source.entities.notion_import
|
||||
services.knowledge.entities.indexing_estimate
|
||||
services.knowledge.dataset_access
|
||||
services.knowledge.datasets.application
|
||||
services.knowledge.datasets.retrieval
|
||||
services.knowledge.document_sync
|
||||
services.knowledge.documents.application
|
||||
services.knowledge.external.application
|
||||
services.knowledge.indexing.errors
|
||||
services.knowledge.indexing.estimate
|
||||
services.knowledge.indexing.execution
|
||||
services.knowledge.resource_scope
|
||||
services.knowledge.segments.application
|
||||
services.knowledge.metadata.application
|
||||
services.knowledge.summaries.application
|
||||
forbidden_modules =
|
||||
fields
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
tasks
|
||||
werkzeug
|
||||
services.knowledge.dataset_service
|
||||
services.data_source.provider_service
|
||||
services.knowledge.indexing.adapters
|
||||
services.knowledge.segments.adapters
|
||||
services.knowledge.segments.indexing
|
||||
core.datasource.datasource_manager
|
||||
core.rag.extractor.notion_extractor
|
||||
|
||||
[importlinter:contract:knowledge-repository-boundary]
|
||||
name = Knowledge persistence adapters only depend on their consumer-owned application contracts
|
||||
type = forbidden
|
||||
source_modules =
|
||||
repositories.knowledge
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
services
|
||||
tasks
|
||||
werkzeug
|
||||
allow_indirect_imports = True
|
||||
ignore_imports =
|
||||
repositories.knowledge.dataset_api_key_repository -> services.auth.api_key_contracts
|
||||
repositories.knowledge.dataset_api_key_repository -> services.knowledge.api_key_service
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.segments.application
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.dataset_access
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.indexing.estimate
|
||||
repositories.knowledge.dataset_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.document_repository -> services.knowledge.document_sync
|
||||
repositories.knowledge.document_repository -> services.knowledge.indexing.estimate
|
||||
repositories.knowledge.document_repository -> services.knowledge.indexing.errors
|
||||
repositories.knowledge.document_repository -> services.knowledge.indexing.execution
|
||||
repositories.knowledge.document_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.segment_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.segment_repository -> services.knowledge.indexing.execution
|
||||
repositories.knowledge.segment_repository -> services.knowledge.segments.application
|
||||
repositories.knowledge.segment_repository -> services.knowledge.entities.segments
|
||||
repositories.knowledge.metadata_repository -> services.errors.metadata
|
||||
repositories.knowledge.metadata_repository -> services.knowledge.metadata.application
|
||||
repositories.knowledge.metadata_repository -> services.knowledge.resource_scope
|
||||
repositories.knowledge.summary_repository -> services.knowledge.summaries.application
|
||||
|
||||
[importlinter:contract:datasource-credential-repository-boundary]
|
||||
name = Data source credential persistence does not depend on SQLAlchemy service helpers
|
||||
type = forbidden
|
||||
source_modules =
|
||||
repositories.credentials.query_repository
|
||||
repositories.data_source.credential_repository
|
||||
forbidden_modules =
|
||||
services.data_source.provider_service
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:datasource-provider-persistence-boundary]
|
||||
name = Datasource provider service consumes injected persistence contracts
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.data_source.provider_service
|
||||
forbidden_modules =
|
||||
repositories
|
||||
extensions.application_services
|
||||
extensions.ext_application_services
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:credential-permission-query-policy-boundary]
|
||||
name = Credential query contracts do not expose persistence implementations
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.credentials.query
|
||||
forbidden_modules =
|
||||
repositories
|
||||
sqlalchemy
|
||||
models
|
||||
extensions
|
||||
flask
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:credential-query-implementation-boundary]
|
||||
name = Credential query implementations are wired only by composition or other repositories
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers
|
||||
services
|
||||
core
|
||||
forbidden_modules =
|
||||
repositories.credentials.query_repository
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:pipeline-document-lookup-boundary]
|
||||
name = Pipeline core depends on its document store port
|
||||
type = forbidden
|
||||
source_modules =
|
||||
core.app.apps.pipeline.pipeline_generator
|
||||
core.app.apps.pipeline.pipeline_runner
|
||||
forbidden_modules =
|
||||
repositories
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:init-validation-service-boundary]
|
||||
name = Initialization validation application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -199,10 +332,102 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:installed-app-access-boundary]
|
||||
name = Installed app access application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.installed_app_access_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:installed-app-management-boundary]
|
||||
name = Installed app management service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.installed_app_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:installed-app-conversation-boundary]
|
||||
name = Installed app conversation service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.installed_app_conversation_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:installed-app-message-boundary]
|
||||
name = Installed app message service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.installed_app_message_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:installed-app-message-persistence-boundary]
|
||||
name = Installed app message persistence does not call the legacy message service
|
||||
type = forbidden
|
||||
source_modules =
|
||||
repositories.installed_app_message_repository
|
||||
forbidden_modules =
|
||||
services.message_service
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:conversation-persistence-boundary]
|
||||
name = Conversation persistence does not call legacy conversation or workspace services
|
||||
type = forbidden
|
||||
source_modules =
|
||||
repositories.installed_app_conversation_repository
|
||||
repositories.conversation_lifecycle
|
||||
repositories.agent_workspace_repository
|
||||
forbidden_modules =
|
||||
services.conversation_service
|
||||
services.agent.workspace_service
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:installed-app-generation-boundary]
|
||||
name = Installed app generation service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.installed_app_generation_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:account-application-boundary]
|
||||
name = Account application services and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.account_access_service
|
||||
services.account_avatar_service
|
||||
services.account_change_email_ports
|
||||
services.account_change_email_service
|
||||
@@ -214,10 +439,16 @@ source_modules =
|
||||
services.account_forgot_password_service
|
||||
services.account_initialization_service
|
||||
services.account_integration_service
|
||||
services.account_login_service
|
||||
services.account_oauth_service
|
||||
services.account_password_service
|
||||
services.account_ports
|
||||
services.account_profile_service
|
||||
services.entities.account_access_entities
|
||||
services.entities.account_entities
|
||||
services.entities.account_login_entities
|
||||
services.entities.account_oauth_entities
|
||||
services.entities.auth_audit_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
@@ -257,6 +488,80 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:end-user-query-service-boundary]
|
||||
name = App-scoped end user query service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app_scoped_end_user_query_service
|
||||
services.entities.app_scoped_end_user_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:end-user-command-service-boundary]
|
||||
name = App-scoped end user command service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app_scoped_end_user_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models.model
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:app-tracing-config-service-boundary]
|
||||
name = App tracing configuration service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app_tracing_config_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:message-file-preview-service-boundary]
|
||||
name = Message file preview application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.message_file_preview_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:upload-file-delivery-service-boundary]
|
||||
name = Upload file delivery application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.upload_file_delivery_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:webapp-access-query-service-boundary]
|
||||
name = Web app access query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -310,7 +615,7 @@ forbidden_modules =
|
||||
name = Data source OAuth application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.data_source_oauth_service
|
||||
services.data_source.oauth_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
@@ -340,6 +645,24 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:oauth-device-application-service-boundary]
|
||||
name = OAuth device application service and contracts are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.oauth_device_application_service
|
||||
services.oauth_device_contracts
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
httpx
|
||||
libs
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:workspace-member-query-service-boundary]
|
||||
name = Workspace member query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -418,6 +741,36 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:file-grant-service-boundary]
|
||||
name = File grant application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.file_grant_service
|
||||
services.entities.file_grant_entities
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:plugin-file-upload-service-boundary]
|
||||
name = Plugin file upload application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.plugin_file_upload_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:account-activation-service-boundary]
|
||||
name = Account activation application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -449,6 +802,20 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:tool-file-download-service-boundary]
|
||||
name = Tool file download application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.tool_file_download_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-query-service-boundary]
|
||||
name = Recommended app query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
@@ -466,6 +833,20 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-package-service-boundary]
|
||||
name = Recommended package application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.recommended_app_package_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:recommended-app-catalog-gateway-boundary]
|
||||
name = Recommended app catalog gateway does not depend on Flask
|
||||
type = forbidden
|
||||
@@ -474,6 +855,52 @@ source_modules =
|
||||
forbidden_modules =
|
||||
flask
|
||||
|
||||
[importlinter:contract:trial-app-application-boundary]
|
||||
name = Trial app access and generation services are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.trial_app_access_service
|
||||
services.trial_app_generation_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.app_generate_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:app-preview-query-boundary]
|
||||
name = App preview queries are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app_preview_query_service
|
||||
services.app_preview_details_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:message-suggested-questions-boundary]
|
||||
name = Message suggested questions port is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.message_suggested_questions_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.message_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:billing-application-boundary]
|
||||
name = Billing application services and ports are framework and infrastructure neutral
|
||||
type = forbidden
|
||||
@@ -508,3 +935,99 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
tasks
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:knowledge-segment-presentation-boundary]
|
||||
name = Knowledge operations do not serialize HTTP responses
|
||||
type = forbidden
|
||||
allow_indirect_imports = True
|
||||
source_modules =
|
||||
services.knowledge.segments
|
||||
services.knowledge.documents
|
||||
services.knowledge.datasets
|
||||
services.knowledge.external
|
||||
forbidden_modules =
|
||||
controllers
|
||||
fields
|
||||
|
||||
[importlinter:contract:knowledge-response-persistence-boundary]
|
||||
name = Knowledge response schemas do not load database state
|
||||
type = forbidden
|
||||
allow_indirect_imports = True
|
||||
source_modules =
|
||||
fields.dataset_fields
|
||||
fields.document_fields
|
||||
fields.segment_fields
|
||||
forbidden_modules =
|
||||
sqlalchemy
|
||||
models
|
||||
repositories
|
||||
services.knowledge
|
||||
|
||||
[importlinter:contract:dataset-model-infrastructure-boundary]
|
||||
name = Dataset models do not execute infrastructure or application operations
|
||||
type = forbidden
|
||||
allow_indirect_imports = True
|
||||
source_modules =
|
||||
models.dataset
|
||||
forbidden_modules =
|
||||
repositories
|
||||
services
|
||||
extensions
|
||||
core.tools
|
||||
models.engine
|
||||
|
||||
[importlinter:contract:app-mcp-server-service-boundary]
|
||||
name = App MCP server application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app.mcp_server_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:console-app-mcp-server-controller-boundary]
|
||||
name = Console App MCP server controller reaches persistence only through the composition root
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers.console.app.mcp_server
|
||||
forbidden_modules =
|
||||
extensions.ext_database
|
||||
models.model
|
||||
repositories
|
||||
sqlalchemy
|
||||
# The composition root (extensions.ext_application_services) wires repositories,
|
||||
# so only direct imports are checked here.
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:console-app-wraps-boundary]
|
||||
name = Console app decorators load App rows without reaching into repositories
|
||||
type = forbidden
|
||||
source_modules =
|
||||
controllers.console.app.wraps
|
||||
forbidden_modules =
|
||||
repositories
|
||||
# models.model / models.workflow lazily import repositories.factory (legacy debt),
|
||||
# so only direct imports are checked here.
|
||||
allow_indirect_imports = True
|
||||
|
||||
[importlinter:contract:app-audio-boundary]
|
||||
name = App audio operations and values are framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.app_audio_service
|
||||
services.audio_types
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.audio_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -15,7 +15,8 @@ Run direct Python commands through `uv run --project api`. Docker-backed integra
|
||||
|
||||
## Architecture And Boundaries
|
||||
|
||||
- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in `core/` or its domain owner. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions.
|
||||
- Treat `api/core/` as a migration-only area: do not add files or move implementations into it. Move responsibilities out; limit changes to existing callers to the adaptations needed for that migration.
|
||||
- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in its domain owner. New or extracted implementations must stay outside `core/`. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions.
|
||||
- Before changing controller schemas, generated API contracts, or `SystemFeatureModel`, read `controllers/API_SCHEMA_GUIDE.md`. Treat `/system-features` as a minimal unauthenticated bootstrap allowlist, not a general configuration registry.
|
||||
- Scope tenant-owned reads and writes by the complete owner chain, and propagate `tenant_id` across every affected layer. Reconstruct trusted internal references from validated database state after payload or async boundaries.
|
||||
- Keep write transactions explicit and bounded. Do not perform external I/O inside an open transaction unless a documented consistency contract requires it.
|
||||
@@ -23,3 +24,8 @@ Run direct Python commands through `uv run --project api`. Docker-backed integra
|
||||
- Use Pydantic v2 for request and response models. Reuse domain-specific exceptions and translate them at the controller boundary.
|
||||
- Use existing Celery task and queue owners for asynchronous work; do not route unrelated jobs through workflow-specific services.
|
||||
- Celery tasks that may be retried or redelivered must keep side effects idempotent and log affected resource identifiers.
|
||||
|
||||
## Typing Best Practises
|
||||
|
||||
- Treat nullable types (`T | None`) strictly as union types; document the semantic meaning for each branch.
|
||||
- Treat default parameter values as introducing distinct function overloads; verify that every added default has demonstrable practical utility and avoids masking unsafe runtime states.
|
||||
|
||||
@@ -2,19 +2,11 @@
|
||||
|
||||
## Setup and Run
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> In the v1.3.0 release, `poetry` has been replaced with
|
||||
> [`uv`](https://docs.astral.sh/uv/) as the package manager
|
||||
> for Dify API backend service.
|
||||
|
||||
`uv` and `pnpm` are required to run the setup and development commands below.
|
||||
[`uv`](https://docs.astral.sh/uv/), Docker Compose, and the Node.js and pnpm versions pinned in the root `package.json` are required. Run the commands below from the repository root, with each long-running service in its own terminal.
|
||||
|
||||
### Using scripts (recommended)
|
||||
|
||||
The scripts resolve paths relative to their location, so you can run them from anywhere.
|
||||
|
||||
1. Run setup (copies env files and installs dependencies).
|
||||
1. Run setup for a new checkout (copies env files and installs dependencies). This overwrites `api/.env`, `web/.env.local`, and `docker/middleware.env`; preserve existing configuration before rerunning it.
|
||||
|
||||
```bash
|
||||
./dev/setup
|
||||
@@ -44,7 +36,7 @@ The scripts resolve paths relative to their location, so you can run them from a
|
||||
|
||||
1. Set up your application by visiting `http://localhost:3000`.
|
||||
|
||||
1. Start the worker service (async and scheduler tasks, runs from `api`).
|
||||
1. Start the worker service (executes queued async tasks).
|
||||
|
||||
```bash
|
||||
./dev/start-worker
|
||||
@@ -62,50 +54,35 @@ The scripts resolve paths relative to their location, so you can run them from a
|
||||
>
|
||||
> When the frontend and backend run on different subdomains, set COOKIE_DOMAIN to the site’s top-level domain (e.g., `example.com`). The frontend and backend must be under the same top-level domain in order to share authentication cookies.
|
||||
|
||||
- Generate a `SECRET_KEY` in the `.env` file.
|
||||
|
||||
bash for Linux
|
||||
|
||||
```bash
|
||||
sed -i "/^SECRET_KEY=/c\\SECRET_KEY=$(openssl rand -base64 42)" .env
|
||||
```
|
||||
|
||||
bash for Mac
|
||||
|
||||
```bash
|
||||
secret_key=$(openssl rand -base64 42)
|
||||
sed -i '' "/^SECRET_KEY=/c\\
|
||||
SECRET_KEY=${secret_key}" .env
|
||||
```
|
||||
Leave `SECRET_KEY` empty to let Dify generate a persistent key in the storage directory. To manage it explicitly, generate a value with `openssl rand -base64 42` and set `SECRET_KEY` in `api/.env`.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Install dependencies for both the backend and the test environment
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
1. Run the tests locally with mocked system environment variables in `tool.pytest_env` section in `pyproject.toml`, more can check [Claude.md](../CLAUDE.md)
|
||||
|
||||
```bash
|
||||
cd api
|
||||
uv run pytest # Run all tests
|
||||
uv run pytest tests/unit_tests/ # Unit tests only
|
||||
uv run pytest tests/integration_tests/ # Integration tests
|
||||
|
||||
# Code quality
|
||||
./dev/reformat # Run all formatters and linters
|
||||
uv run ruff check --fix ./ # Fix linting issues
|
||||
uv run ruff format ./ # Format code
|
||||
uv run pyrefly check # Type checking
|
||||
```
|
||||
|
||||
## Generate TS stub
|
||||
|
||||
```
|
||||
uv run dev/generate_swagger_specs.py --output-dir openapi
|
||||
```bash
|
||||
uv sync --project api --group dev
|
||||
make test
|
||||
make test TARGET_TESTS=./api/tests/unit_tests/<path>
|
||||
make lint
|
||||
make type-check
|
||||
```
|
||||
|
||||
use https://jsontotable.org/openapi-to-typescript to convert to typescript
|
||||
`make test` includes provider unit tests and runs controller tests separately. Integration suites are CI-only and are not expected to run locally. Test environment defaults live in `api/pyproject.toml` under `tool.pytest_env`; see the backend [agent guide](AGENTS.md) for package conventions.
|
||||
|
||||
## API contracts
|
||||
|
||||
The [API schema guide](controllers/API_SCHEMA_GUIDE.md) owns schema changes and verification. Generate the OpenAPI specifications and TypeScript/Zod contracts through the workspace script:
|
||||
|
||||
```bash
|
||||
pnpm -C packages/contracts gen-api-contract
|
||||
```
|
||||
|
||||
Refresh the checked-in Markdown reference with:
|
||||
|
||||
```bash
|
||||
uv run --project api python api/dev/generate_swagger_markdown_docs.py \
|
||||
--swagger-dir packages/contracts/openapi --markdown-dir api/openapi/markdown --keep-swagger-json
|
||||
```
|
||||
|
||||
Update the backend schema owner and regenerate contracts instead of converting types manually or editing generated files.
|
||||
|
||||
@@ -105,6 +105,10 @@ def create_flask_app_with_configs() -> DifyApp:
|
||||
dify_app = DifyApp(__name__)
|
||||
dify_app.config.from_mapping(dify_config.model_dump())
|
||||
dify_app.config["RESTX_INCLUDE_ALL_MODELS"] = True
|
||||
# flask-restx appends url-map suggestions to 404 bodies: they enumerate routes to
|
||||
# anonymous callers, and a policy 404 on an existing route (edition admission)
|
||||
# ends up suggesting the very path that was just requested.
|
||||
dify_app.config["RESTX_ERROR_404_HELP"] = False
|
||||
|
||||
# add before request hook
|
||||
@dify_app.before_request
|
||||
|
||||
@@ -36,6 +36,11 @@ from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyK
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||
from dify_agent.layers.runtime import DIFY_RUNTIME_LAYER_TYPE_ID, DifyRuntimeLayerConfig
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.layers.user_prompt import (
|
||||
DIFY_USER_PROMPT_LAYER_TYPE_ID,
|
||||
DifyUserPromptFileConfig,
|
||||
DifyUserPromptLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol import (
|
||||
DIFY_AGENT_HISTORY_LAYER_ID,
|
||||
DIFY_AGENT_MODEL_LAYER_ID,
|
||||
@@ -246,6 +251,7 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
backend_binding_ref: str = Field(min_length=1)
|
||||
user_prompt: str
|
||||
user_files: list[DifyUserPromptFileConfig] = Field(default_factory=list)
|
||||
agent_soul_prompt: str | None = None
|
||||
agent_config_version_kind: AgentConfigVersionKind = "snapshot"
|
||||
idempotency_key: str | None = None
|
||||
@@ -310,9 +316,9 @@ class AgentBackendRunRequestBuilder:
|
||||
[
|
||||
RunLayerSpec(
|
||||
name=AGENT_APP_USER_PROMPT_LAYER_ID,
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
type=DIFY_USER_PROMPT_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "agent_app_user_prompt"},
|
||||
config=PromptLayerConfig(user=run_input.user_prompt),
|
||||
config=DifyUserPromptLayerConfig(text=run_input.user_prompt, files=run_input.user_files),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
@@ -632,7 +638,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
|
||||
|
||||
_SENSITIVE_KEY_PARTS = ("secret", "credential", "token", "password", "api_key")
|
||||
_SENSITIVE_KEY_PARTS = ("secret", "credential", "token", "password", "api_key", "base64_data")
|
||||
|
||||
|
||||
def redact_for_agent_backend_log(value: object) -> object:
|
||||
@@ -641,9 +647,10 @@ def redact_for_agent_backend_log(value: object) -> object:
|
||||
return redact_for_agent_backend_log(value.model_dump(mode="json", warnings=False))
|
||||
if isinstance(value, dict):
|
||||
redacted: dict[object, object] = {}
|
||||
is_multimodal_file = value.get("type") == "image" and "filename" in value and "mime_type" in value
|
||||
for key, item in value.items():
|
||||
key_text = str(key).lower()
|
||||
if any(part in key_text for part in _SENSITIVE_KEY_PARTS):
|
||||
if any(part in key_text for part in _SENSITIVE_KEY_PARTS) or (is_multimodal_file and key_text == "url"):
|
||||
redacted[key] = "[REDACTED]"
|
||||
else:
|
||||
redacted[key] = redact_for_agent_backend_log(item)
|
||||
|
||||
@@ -23,6 +23,7 @@ from .plugin import (
|
||||
transform_datasource_credentials,
|
||||
)
|
||||
from .rbac import (
|
||||
migrate_agent_permissions_to_rbac,
|
||||
migrate_dataset_permissions_to_rbac,
|
||||
migrate_member_roles_to_rbac,
|
||||
migrate_only_me_resource_whitelist_scopes_to_automatic_include,
|
||||
@@ -81,6 +82,7 @@ __all__ = [
|
||||
"install_plugins",
|
||||
"install_rag_pipeline_plugins",
|
||||
"legacy_model_types",
|
||||
"migrate_agent_permissions_to_rbac",
|
||||
"migrate_annotation_vector_database",
|
||||
"migrate_data_for_plugin",
|
||||
"migrate_dataset_permissions_to_rbac",
|
||||
|
||||
@@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
|
||||
@click.command("setup-system-tool-oauth-client", help="Setup system tool oauth client.")
|
||||
@click.option("--provider", prompt=True, help="Provider name")
|
||||
@click.option("--client-params", prompt=True, help="Client Params")
|
||||
def setup_system_tool_oauth_client(provider, client_params):
|
||||
def setup_system_tool_oauth_client(provider: str, client_params: str):
|
||||
"""
|
||||
Setup system tool oauth client
|
||||
"""
|
||||
@@ -79,7 +79,7 @@ def setup_system_tool_oauth_client(provider, client_params):
|
||||
@click.command("setup-system-trigger-oauth-client", help="Setup system trigger oauth client.")
|
||||
@click.option("--provider", prompt=True, help="Provider name")
|
||||
@click.option("--client-params", prompt=True, help="Client Params")
|
||||
def setup_system_trigger_oauth_client(provider, client_params):
|
||||
def setup_system_trigger_oauth_client(provider: str, client_params: str):
|
||||
"""
|
||||
Setup system trigger oauth client
|
||||
"""
|
||||
@@ -129,7 +129,7 @@ def setup_system_trigger_oauth_client(provider, client_params):
|
||||
@click.command("setup-datasource-oauth-client", help="Setup datasource oauth client.")
|
||||
@click.option("--provider", prompt=True, help="Provider name")
|
||||
@click.option("--client-params", prompt=True, help="Client Params")
|
||||
def setup_datasource_oauth_client(provider, client_params):
|
||||
def setup_datasource_oauth_client(provider: str, client_params: str):
|
||||
"""
|
||||
Setup datasource oauth client
|
||||
"""
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
import click
|
||||
from sqlalchemy import select
|
||||
@@ -11,12 +13,34 @@ from sqlalchemy.orm import Session
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from core.rbac import RBACResourceWhitelistScope
|
||||
from models import App, Dataset, DatasetPermission, DatasetPermissionEnum, TenantAccountJoin, TenantAccountRole
|
||||
from services.enterprise.rbac_service import ListOption, RBACService, ReplaceMemberBindings, ReplaceUserAccessPolicies
|
||||
from models import (
|
||||
Agent,
|
||||
AgentStatus,
|
||||
App,
|
||||
Dataset,
|
||||
DatasetPermission,
|
||||
DatasetPermissionEnum,
|
||||
Tenant,
|
||||
TenantAccountJoin,
|
||||
TenantAccountRole,
|
||||
)
|
||||
from services.enterprise.rbac_service import (
|
||||
LegacyAgentRoleMigration,
|
||||
ListOption,
|
||||
RBACResourceType,
|
||||
RBACService,
|
||||
ReplaceMemberBindings,
|
||||
ReplaceUserAccessPolicies,
|
||||
)
|
||||
|
||||
_RBAC_DEFAULT_ACCESS_POLICY_ID = "default"
|
||||
_RBAC_RESOURCE_ACCESS_POLICY_BATCH_SIZE = 500
|
||||
|
||||
_AGENT_MIGRATION_STATE_LABEL = {
|
||||
False: "would change",
|
||||
True: "changed",
|
||||
}
|
||||
|
||||
_LEGACY_ROLE_TO_BUILTIN_TAG = {
|
||||
TenantAccountRole.OWNER.value: "owner",
|
||||
TenantAccountRole.ADMIN.value: "admin",
|
||||
@@ -67,6 +91,88 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
|
||||
return _resolve_builtin_role_ids(tenant_id, operator_account_id)[legacy_role]
|
||||
|
||||
|
||||
def _iter_tenant_ids(tenant_id: str | None, *, batch_size: int) -> Iterator[str]:
|
||||
if tenant_id:
|
||||
yield tenant_id
|
||||
return
|
||||
last_id: str | None = None
|
||||
while True:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(Tenant.id).order_by(Tenant.id.asc()).limit(batch_size)
|
||||
if last_id is not None:
|
||||
stmt = stmt.where(Tenant.id > last_id)
|
||||
rows = session.execute(stmt).scalars().all()
|
||||
if not rows:
|
||||
return
|
||||
for row in rows:
|
||||
yield str(row)
|
||||
last_id = str(rows[-1])
|
||||
|
||||
|
||||
def _emit_agent_migration_event(payload: dict[str, object]) -> None:
|
||||
click.echo(json.dumps(payload, sort_keys=True))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AgentMigrationEventKind:
|
||||
event_stem: str
|
||||
include_tenant_id: bool = True
|
||||
|
||||
@property
|
||||
def skipped(self) -> str:
|
||||
return f"{self.event_stem}_skipped"
|
||||
|
||||
@property
|
||||
def failed(self) -> str:
|
||||
return f"{self.event_stem}_failed"
|
||||
|
||||
def outcome(self, *, apply: bool) -> str:
|
||||
return f"{self.event_stem}_applied" if apply else f"{self.event_stem}_proposed_change"
|
||||
|
||||
|
||||
_AGENT_ROLE_MIGRATION_EVENT_KIND = _AgentMigrationEventKind(event_stem="agent_manage_role_migration")
|
||||
_AGENT_ROLE_TEMPLATE_MIGRATION_EVENT_KIND = _AgentMigrationEventKind(
|
||||
event_stem="agent_manage_role_template_migration", include_tenant_id=False
|
||||
)
|
||||
_AGENT_ACCESS_BOOTSTRAP_EVENT_KIND = _AgentMigrationEventKind(event_stem="agent_access_bootstrap")
|
||||
|
||||
_AGENT_BACKING_APP_SPECIFIC_WHITELIST_EVENT = "agent_backing_app_has_specific_whitelist"
|
||||
_AGENT_ACCESS_BOOTSTRAP_MEMBER_SOURCE = "workspace_members"
|
||||
|
||||
|
||||
class _AgentAccessBootstrapReason(StrEnum):
|
||||
ALREADY_INITIALIZED = "already_initialized"
|
||||
MISSING_WHITELIST_SCOPE = "missing_whitelist_scope"
|
||||
NO_CREATOR = "no_creator"
|
||||
|
||||
|
||||
def _agent_manage_role_event(
|
||||
tenant_id: str | None,
|
||||
entry: LegacyAgentRoleMigration,
|
||||
*,
|
||||
apply: bool,
|
||||
kind: _AgentMigrationEventKind,
|
||||
) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"dry_run": not apply,
|
||||
"role_id": entry.role_id,
|
||||
"role_name": entry.role_name,
|
||||
}
|
||||
if kind.include_tenant_id:
|
||||
base["tenant_id"] = tenant_id
|
||||
if entry.skipped:
|
||||
return {**base, "event": kind.skipped, "reason": entry.skipped}
|
||||
return {
|
||||
**base,
|
||||
"event": kind.outcome(apply=apply),
|
||||
"after": {
|
||||
"added_keys": entry.added_keys,
|
||||
"removed_keys": entry.removed_keys,
|
||||
"bound_policies": entry.bound_policies,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _iter_tenant_member_batches(
|
||||
tenant_id: str | None,
|
||||
*,
|
||||
@@ -1005,3 +1111,302 @@ def migrate_dataset_permissions_to_rbac(
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
_AgentRow = tuple[str, str | None, str | None]
|
||||
|
||||
|
||||
def _iter_agent_row_batches(tenant_id: str, batch_size: int) -> Iterator[list[_AgentRow]]:
|
||||
last_agent_id: str | None = None
|
||||
while True:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(Agent.id, Agent.created_by, Agent.backing_app_id)
|
||||
.where(Agent.tenant_id == tenant_id, Agent.status != AgentStatus.ARCHIVED)
|
||||
.order_by(Agent.id.asc())
|
||||
.limit(batch_size)
|
||||
)
|
||||
if last_agent_id:
|
||||
stmt = stmt.where(Agent.id > last_agent_id)
|
||||
|
||||
rows = list(session.execute(stmt).all())
|
||||
if not rows:
|
||||
return
|
||||
|
||||
yield [
|
||||
(
|
||||
str(row.id),
|
||||
str(row.created_by) if row.created_by else None,
|
||||
str(row.backing_app_id) if row.backing_app_id else None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
last_agent_id = str(rows[-1].id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AgentAccessBootstrapCounts:
|
||||
changed: int = 0
|
||||
already_initialized: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AgentAccessBootstrapOptions:
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
creator_account_id: str | None
|
||||
backing_app_id: str | None
|
||||
operator_account_id: str
|
||||
member_batch_size: int
|
||||
apply: bool
|
||||
initialized: bool
|
||||
|
||||
|
||||
def _report_backing_app_specific_whitelist(options: _AgentAccessBootstrapOptions) -> None:
|
||||
if not options.backing_app_id:
|
||||
return
|
||||
app_config = _resource_legacy_whitelist_config(
|
||||
"app",
|
||||
tenant_id=options.tenant_id,
|
||||
operator_account_id=options.operator_account_id,
|
||||
resource_id=options.backing_app_id,
|
||||
)
|
||||
if _normalize_rbac_whitelist_scope(app_config.rbac_whitelist_scope) is not RBACResourceWhitelistScope.SPECIFIC:
|
||||
return
|
||||
_emit_agent_migration_event(
|
||||
{
|
||||
"event": _AGENT_BACKING_APP_SPECIFIC_WHITELIST_EVENT,
|
||||
"dry_run": not options.apply,
|
||||
"tenant_id": options.tenant_id,
|
||||
"agent_id": options.agent_id,
|
||||
"app_id": options.backing_app_id,
|
||||
"backing_app_account_ids": sorted(set(app_config.account_ids)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _write_agent_access_rows(options: _AgentAccessBootstrapOptions) -> None:
|
||||
for batch in _workspace_member_account_id_batches(options.tenant_id, options.member_batch_size):
|
||||
RBACService.AgentAccess.replace_user_access_policies(
|
||||
tenant_id=options.tenant_id,
|
||||
account_id=options.operator_account_id,
|
||||
agent_id=options.agent_id,
|
||||
target_account_id=None,
|
||||
payload=ReplaceUserAccessPolicies(
|
||||
access_policy_ids=[_RBAC_DEFAULT_ACCESS_POLICY_ID],
|
||||
account_ids=batch,
|
||||
),
|
||||
)
|
||||
if options.creator_account_id:
|
||||
RBACService.AccessPolicies.sync_creator_access_policy_member_bindings(
|
||||
tenant_id=options.tenant_id,
|
||||
account_id=options.creator_account_id,
|
||||
resource_type=RBACResourceType.AGENT,
|
||||
resource_id=options.agent_id,
|
||||
)
|
||||
RBACService.AgentAccess.replace_whitelist(
|
||||
tenant_id=options.tenant_id,
|
||||
account_id=options.operator_account_id,
|
||||
agent_id=options.agent_id,
|
||||
payload=ReplaceMemberBindings(automatic_include_workspace_members=True),
|
||||
)
|
||||
|
||||
|
||||
def _emit_agent_access_bootstrap_skipped(
|
||||
options: _AgentAccessBootstrapOptions,
|
||||
reason: _AgentAccessBootstrapReason,
|
||||
) -> None:
|
||||
_emit_agent_migration_event(
|
||||
{
|
||||
"event": _AGENT_ACCESS_BOOTSTRAP_EVENT_KIND.skipped,
|
||||
"reason": reason.value,
|
||||
"dry_run": not options.apply,
|
||||
"tenant_id": options.tenant_id,
|
||||
"agent_id": options.agent_id,
|
||||
"operator_account_id": options.operator_account_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _agent_access_bootstrap_failure(
|
||||
options: _AgentAccessBootstrapOptions,
|
||||
exc: Exception,
|
||||
) -> click.ClickException:
|
||||
_emit_agent_migration_event(
|
||||
{
|
||||
"event": _AGENT_ACCESS_BOOTSTRAP_EVENT_KIND.failed,
|
||||
"tenant_id": options.tenant_id,
|
||||
"agent_id": options.agent_id,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
return click.ClickException(f"tenant {options.tenant_id} agent {options.agent_id}: {exc}")
|
||||
|
||||
|
||||
def _bootstrap_agent_access(options: _AgentAccessBootstrapOptions, counts: _AgentAccessBootstrapCounts) -> None:
|
||||
if options.initialized:
|
||||
counts.already_initialized += 1
|
||||
_emit_agent_access_bootstrap_skipped(options, _AgentAccessBootstrapReason.ALREADY_INITIALIZED)
|
||||
return
|
||||
|
||||
config = RBACService.AgentAccess.legacy_whitelist_config(
|
||||
tenant_id=options.tenant_id,
|
||||
account_id=options.operator_account_id,
|
||||
agent_id=options.agent_id,
|
||||
)
|
||||
try:
|
||||
scope = _normalize_rbac_whitelist_scope(config.rbac_whitelist_scope)
|
||||
except ValueError as exc:
|
||||
raise _agent_access_bootstrap_failure(options, exc) from exc
|
||||
if scope is None:
|
||||
_emit_agent_access_bootstrap_skipped(options, _AgentAccessBootstrapReason.MISSING_WHITELIST_SCOPE)
|
||||
return
|
||||
|
||||
account_ids = sorted(set(config.account_ids))
|
||||
_report_backing_app_specific_whitelist(options)
|
||||
|
||||
if options.apply:
|
||||
try:
|
||||
_write_agent_access_rows(options)
|
||||
except Exception as exc:
|
||||
raise _agent_access_bootstrap_failure(options, exc) from exc
|
||||
counts.changed += 1
|
||||
|
||||
event: dict[str, object] = {
|
||||
"event": _AGENT_ACCESS_BOOTSTRAP_EVENT_KIND.outcome(apply=options.apply),
|
||||
"dry_run": not options.apply,
|
||||
"tenant_id": options.tenant_id,
|
||||
"agent_id": options.agent_id,
|
||||
"operator_account_id": options.operator_account_id,
|
||||
"before": {"rbac_whitelist_scope": scope.value, "whitelist_account_ids": account_ids},
|
||||
"after": {
|
||||
"automatic_include_workspace_members": True,
|
||||
"default_policy_member_source": _AGENT_ACCESS_BOOTSTRAP_MEMBER_SOURCE,
|
||||
"creator_access_policy_synced": bool(options.creator_account_id),
|
||||
},
|
||||
}
|
||||
if not options.creator_account_id:
|
||||
event["reason"] = _AgentAccessBootstrapReason.NO_CREATOR.value
|
||||
_emit_agent_migration_event(event)
|
||||
|
||||
|
||||
def _bootstrap_tenant_agent_access(
|
||||
tenant_id: str,
|
||||
*,
|
||||
agent_batch_size: int,
|
||||
member_batch_size: int,
|
||||
apply: bool,
|
||||
counts: _AgentAccessBootstrapCounts,
|
||||
) -> None:
|
||||
owner_account_id: str | None = None
|
||||
for rows in _iter_agent_row_batches(tenant_id, agent_batch_size):
|
||||
initialized_agent_ids = set(
|
||||
RBACService.Migrations.list_configured_agent_ids(tenant_id, [agent_id for agent_id, _, _ in rows])
|
||||
)
|
||||
for agent_id, creator_account_id, backing_app_id in rows:
|
||||
if creator_account_id:
|
||||
operator_account_id = creator_account_id
|
||||
else:
|
||||
if owner_account_id is None:
|
||||
with session_factory.create_session() as session:
|
||||
owner_account_id = _owner_account_id(tenant_id, session=session)
|
||||
operator_account_id = owner_account_id
|
||||
_bootstrap_agent_access(
|
||||
_AgentAccessBootstrapOptions(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
creator_account_id=creator_account_id,
|
||||
backing_app_id=backing_app_id,
|
||||
operator_account_id=operator_account_id,
|
||||
member_batch_size=member_batch_size,
|
||||
apply=apply,
|
||||
initialized=agent_id in initialized_agent_ids,
|
||||
),
|
||||
counts,
|
||||
)
|
||||
|
||||
|
||||
@click.command(
|
||||
"rbac-migrate-agent-permissions",
|
||||
help=(
|
||||
"Upgrade step for agent RBAC. Phase 1 asks the RBAC service to replace agent.manage on every "
|
||||
"custom role with agent.create plus the agent.full_access binding. Phase 2 bootstraps the access "
|
||||
"rows pre-existing agents never got, so they stay visible to workspace members. Agents that "
|
||||
"already have a whitelist scope row are skipped. Dry run by default."
|
||||
),
|
||||
)
|
||||
@click.option("--tenant-id", help="Only migrate a single workspace.")
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
default=500,
|
||||
show_default=True,
|
||||
type=click.IntRange(min=1),
|
||||
help="Tenants fetched per database batch.",
|
||||
)
|
||||
@click.option(
|
||||
"--agent-batch-size",
|
||||
default=500,
|
||||
show_default=True,
|
||||
type=click.IntRange(min=1),
|
||||
help="Agents fetched per database batch.",
|
||||
)
|
||||
@click.option(
|
||||
"--member-batch-size",
|
||||
default=_RBAC_RESOURCE_ACCESS_POLICY_BATCH_SIZE,
|
||||
show_default=True,
|
||||
type=click.IntRange(min=1),
|
||||
help="Workspace members written per default-policy call when bootstrapping an agent.",
|
||||
)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Write changes. Without it nothing is written.")
|
||||
def migrate_agent_permissions_to_rbac(
|
||||
tenant_id: str | None,
|
||||
batch_size: int,
|
||||
agent_batch_size: int,
|
||||
member_batch_size: int,
|
||||
apply: bool,
|
||||
) -> None:
|
||||
click.echo(click.style("Starting agent RBAC migration: custom roles holding agent.manage.", fg="green"))
|
||||
tenant_count = 0
|
||||
role_count = 0
|
||||
skipped_count = 0
|
||||
seen_template_ids: set[str] = set()
|
||||
template_count = 0
|
||||
agent_counts = _AgentAccessBootstrapCounts()
|
||||
for workspace_id in _iter_tenant_ids(tenant_id, batch_size=batch_size):
|
||||
tenant_count += 1
|
||||
try:
|
||||
report = RBACService.Migrations.migrate_agent_manage_roles(workspace_id, apply=apply)
|
||||
except Exception as exc:
|
||||
raise click.ClickException(f"tenant {workspace_id}: {exc}") from exc
|
||||
for entry in report.roles:
|
||||
role_count += 1
|
||||
if entry.skipped:
|
||||
skipped_count += 1
|
||||
_emit_agent_migration_event(
|
||||
_agent_manage_role_event(workspace_id, entry, apply=apply, kind=_AGENT_ROLE_MIGRATION_EVENT_KIND)
|
||||
)
|
||||
for template in report.role_templates:
|
||||
if template.role_id in seen_template_ids:
|
||||
continue
|
||||
seen_template_ids.add(template.role_id)
|
||||
template_count += 1
|
||||
_emit_agent_migration_event(
|
||||
_agent_manage_role_event(None, template, apply=apply, kind=_AGENT_ROLE_TEMPLATE_MIGRATION_EVENT_KIND)
|
||||
)
|
||||
_bootstrap_tenant_agent_access(
|
||||
workspace_id,
|
||||
agent_batch_size=agent_batch_size,
|
||||
member_batch_size=member_batch_size,
|
||||
apply=apply,
|
||||
counts=agent_counts,
|
||||
)
|
||||
role_state = _AGENT_MIGRATION_STATE_LABEL[apply]
|
||||
click.echo(
|
||||
f"{tenant_count} tenant(s), {role_count} role(s) {role_state}, {skipped_count} skipped, "
|
||||
f"{template_count} template(s) {role_state}, "
|
||||
f"{agent_counts.changed} agent(s) {role_state}, "
|
||||
f"{agent_counts.already_initialized} already initialised"
|
||||
)
|
||||
if not apply:
|
||||
click.echo(click.style("Dry run: no changes written. Re-run with --apply.", fg="yellow"))
|
||||
|
||||
@@ -751,7 +751,7 @@ def migrate_oss(
|
||||
UploadFile.storage_type == source_storage_type,
|
||||
UploadFile.key.in_(copied_upload_file_keys),
|
||||
)
|
||||
.values(storage_type=dify_config.STORAGE_TYPE)
|
||||
.values(storage_type=StorageType(dify_config.STORAGE_TYPE))
|
||||
),
|
||||
).rowcount
|
||||
db.session.commit()
|
||||
|
||||
@@ -19,6 +19,7 @@ from models.dataset import Dataset, DatasetCollectionBinding, DatasetMetadata, D
|
||||
from models.dataset import Document as DatasetDocument
|
||||
from models.enums import DatasetMetadataType, IndexingStatus, SegmentStatus
|
||||
from models.model import App, AppAnnotationSetting, MessageAnnotation
|
||||
from repositories.knowledge.dataset_read_repository import get_segment_child_chunks
|
||||
|
||||
|
||||
@click.command("vdb-migrate", help="Migrate vector db.")
|
||||
@@ -278,7 +279,7 @@ def migrate_knowledge_vector_database():
|
||||
},
|
||||
)
|
||||
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
|
||||
child_chunks = segment.get_child_chunks(session=db_session)
|
||||
child_chunks = get_segment_child_chunks(segment, session=db_session)
|
||||
if child_chunks:
|
||||
child_documents = []
|
||||
for child_chunk in child_chunks:
|
||||
|
||||
@@ -7,6 +7,18 @@ class AgentBackendConfig(BaseSettings):
|
||||
Configuration settings for the Agent backend runtime integration.
|
||||
"""
|
||||
|
||||
AGENT_SANDBOX_METERING_ENABLED: bool = Field(default=False, description="Persist independent E2B runtime usage.")
|
||||
AGENT_SANDBOX_METERING_PROJECT_ID: str = Field(
|
||||
default="", description="Allowed E2B project/team for this database."
|
||||
)
|
||||
AGENT_SANDBOX_METERING_START_AT: str = Field(
|
||||
default="", description="Immutable UTC activation instant, aligned to a whole second; required when enabled."
|
||||
)
|
||||
AGENT_SANDBOX_METERING_INTERVAL_SECONDS: int | str = Field(
|
||||
default=60,
|
||||
description="Celery Beat interval in seconds; validated only when registering optional usage collection.",
|
||||
)
|
||||
|
||||
AGENT_BACKEND_BASE_URL: str | None = Field(
|
||||
description="Base URL for the Dify Agent backend service.",
|
||||
default=None,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -41,3 +43,8 @@ class ArchiveStorageConfig(BaseSettings):
|
||||
description="Region for storage (use 'auto' if the provider supports it)",
|
||||
default="auto",
|
||||
)
|
||||
|
||||
ARCHIVE_STORAGE_ADDRESS_STYLE: Literal["path", "virtual", "auto"] = Field(
|
||||
description="S3 addressing style for archive storage: 'path' (default), 'virtual' (virtual-hosted), or 'auto'",
|
||||
default="path",
|
||||
)
|
||||
|
||||
@@ -481,6 +481,36 @@ class FileUploadConfig(BaseSettings):
|
||||
default=50,
|
||||
)
|
||||
|
||||
SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES: PositiveInt = Field(
|
||||
description="Maximum total uncompressed size of a Skill package in bytes",
|
||||
default=200 * 1024 * 1024,
|
||||
)
|
||||
|
||||
SKILL_PACKAGE_MAX_ENTRIES: PositiveInt = Field(
|
||||
description="Maximum number of entries in a Skill package",
|
||||
default=5000,
|
||||
)
|
||||
|
||||
SKILL_PACKAGE_MAX_SKILL_MD_BYTES: PositiveInt = Field(
|
||||
description="Maximum allowed SKILL.md size in bytes",
|
||||
default=1 * 1024 * 1024,
|
||||
)
|
||||
|
||||
AGENT_PACKAGE_MAX_BYTES: PositiveInt = Field(
|
||||
description="Maximum allowed Agent package size in bytes",
|
||||
default=512 * 1024 * 1024,
|
||||
)
|
||||
|
||||
AGENT_PACKAGE_MAX_MANIFEST_BYTES: PositiveInt = Field(
|
||||
description="Maximum allowed size of each Agent package YAML document in bytes",
|
||||
default=5 * 1024 * 1024,
|
||||
)
|
||||
|
||||
AGENT_PACKAGE_MAX_ENTRIES: PositiveInt = Field(
|
||||
description="Maximum number of entries in an Agent package",
|
||||
default=5000,
|
||||
)
|
||||
|
||||
BATCH_UPLOAD_LIMIT: NonNegativeInt = Field(
|
||||
description="Maximum number of files allowed in a batch upload operation",
|
||||
default=20,
|
||||
@@ -1614,7 +1644,7 @@ class AccountConfig(BaseSettings):
|
||||
)
|
||||
|
||||
EDUCATION_ENABLED: bool = Field(
|
||||
description="whether to enable education identity",
|
||||
description="whether to enable education identity (CLOUD deployments only)",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,17 +6,6 @@ class PyProjectConfig(BaseModel):
|
||||
version: str = Field(description="Dify version", default="")
|
||||
|
||||
|
||||
class DifyToolConfig(BaseModel):
|
||||
min_difyctl_version: str = Field(
|
||||
description="Oldest difyctl version served on /openapi/v1",
|
||||
default="0.0.0",
|
||||
)
|
||||
|
||||
|
||||
class ToolConfig(BaseModel):
|
||||
dify: DifyToolConfig = Field(default=DifyToolConfig())
|
||||
|
||||
|
||||
class PyProjectTomlConfig(BaseSettings):
|
||||
"""
|
||||
configs in api/pyproject.toml
|
||||
@@ -26,8 +15,3 @@ class PyProjectTomlConfig(BaseSettings):
|
||||
description="configs in the project section of pyproject.toml",
|
||||
default=PyProjectConfig(),
|
||||
)
|
||||
|
||||
tool: ToolConfig = Field(
|
||||
description="configs in the [tool.*] section of pyproject.toml",
|
||||
default=ToolConfig(),
|
||||
)
|
||||
|
||||
@@ -52,5 +52,4 @@ class ApolloSettingsSource(RemoteSettingsSource):
|
||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||
if not isinstance(self.remote_configs, dict):
|
||||
raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
|
||||
field_value = self.remote_configs.get(field_name)
|
||||
return field_value, field_name, False
|
||||
return self.resolve_field_value(self.remote_configs, field, field_name)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from pydantic import AliasChoices
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
|
||||
@@ -11,5 +12,28 @@ class RemoteSettingsSource:
|
||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def resolve_field_value(
|
||||
remote_configs: Mapping[str, Any], field: FieldInfo, field_name: str
|
||||
) -> tuple[Any, str, bool]:
|
||||
field_value = remote_configs.get(field_name)
|
||||
if field_value is not None:
|
||||
return field_value, field_name, False
|
||||
|
||||
validation_alias = field.validation_alias
|
||||
if isinstance(validation_alias, str):
|
||||
aliases = [validation_alias]
|
||||
elif isinstance(validation_alias, AliasChoices):
|
||||
aliases = [alias for alias in validation_alias.choices if isinstance(alias, str)]
|
||||
else:
|
||||
aliases = []
|
||||
|
||||
for alias in aliases:
|
||||
field_value = remote_configs.get(alias)
|
||||
if field_value is not None:
|
||||
return field_value, alias, False
|
||||
|
||||
return None, field_name, False
|
||||
|
||||
def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool):
|
||||
return value
|
||||
|
||||
@@ -43,8 +43,4 @@ class NacosSettingsSource(RemoteSettingsSource):
|
||||
|
||||
@override
|
||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||
field_value = self.remote_configs.get(field_name)
|
||||
if field_value is None:
|
||||
return None, field_name, False
|
||||
|
||||
return field_value, field_name, False
|
||||
return self.resolve_field_value(self.remote_configs, field, field_name)
|
||||
|
||||
@@ -8,6 +8,7 @@ tests below their directory, and this setup is shared by api/tests and api/provi
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -33,6 +34,26 @@ ensure_backend_test_environment(_REPO_ROOT)
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
group = parser.getgroup("dify")
|
||||
group.addoption("--file-shard-plan", type=Path, help="Shared pre-collection file/partition assignment.")
|
||||
group.addoption("--write-test-durations", type=Path, help="Write successful-run file durations for CI history.")
|
||||
group.addoption(
|
||||
"--middleware-stop-timeout",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Override the shutdown grace period in seconds for pytest-started middleware only.",
|
||||
)
|
||||
group.addoption(
|
||||
"--shard-index",
|
||||
type=int,
|
||||
default=1,
|
||||
help="One-based index of the test shard to run.",
|
||||
)
|
||||
group.addoption(
|
||||
"--shard-total",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Total number of test shards.",
|
||||
)
|
||||
group.addoption(
|
||||
"--start-middleware",
|
||||
action="store_true",
|
||||
@@ -58,9 +79,60 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
shard_index = config.getoption("shard_index")
|
||||
shard_total = config.getoption("shard_total")
|
||||
if shard_total < 1:
|
||||
raise pytest.UsageError("--shard-total must be at least 1")
|
||||
if not 1 <= shard_index <= shard_total:
|
||||
raise pytest.UsageError("--shard-index must be between 1 and --shard-total")
|
||||
stop_timeout = config.getoption("middleware_stop_timeout")
|
||||
if stop_timeout is not None and stop_timeout < 0:
|
||||
raise pytest.UsageError("--middleware-stop-timeout must be nonnegative")
|
||||
|
||||
durations_path = config.getoption("write_test_durations")
|
||||
if durations_path is not None and not hasattr(config, "workerinput"):
|
||||
from tests.pytest_timing import TestDurationsPlugin
|
||||
|
||||
config.pluginmanager.register(TestDurationsPlugin(durations_path), "dify-test-durations")
|
||||
|
||||
config.stash[_DIFY_COMPOSE_STACKS_KEY] = []
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||
"""Apply the shared file plan, or the legacy round-robin testcase shard."""
|
||||
shard_index = config.getoption("shard_index")
|
||||
shard_total = config.getoption("shard_total")
|
||||
if plan_path := config.getoption("file_shard_plan"):
|
||||
import json
|
||||
|
||||
from dev.pytest_sharding import case_shard
|
||||
|
||||
plan = json.loads(plan_path.read_text())
|
||||
selected_items: list[pytest.Item] = []
|
||||
deselected_items: list[pytest.Item] = []
|
||||
for item in items:
|
||||
filename = item.path.relative_to(_REPO_ROOT).as_posix()
|
||||
# Missing files are a broken plan, not an excuse to silently drop tests.
|
||||
targets = plan[filename]
|
||||
case = item.nodeid.split("::", 1)[1]
|
||||
destination = selected_items if case_shard(case, targets) == shard_index else deselected_items
|
||||
destination.append(item)
|
||||
config.hook.pytest_deselected(items=deselected_items)
|
||||
items[:] = selected_items
|
||||
return
|
||||
if shard_total == 1:
|
||||
return
|
||||
|
||||
selected: list[pytest.Item] = []
|
||||
deselected: list[pytest.Item] = []
|
||||
for item_index, item in enumerate(items):
|
||||
target = selected if item_index % shard_total == shard_index - 1 else deselected
|
||||
target.append(item)
|
||||
|
||||
config.hook.pytest_deselected(items=deselected)
|
||||
items[:] = selected
|
||||
|
||||
|
||||
def pytest_sessionstart(session: pytest.Session) -> None:
|
||||
config = session.config
|
||||
if hasattr(config, "workerinput"):
|
||||
@@ -70,6 +142,10 @@ def pytest_sessionstart(session: pytest.Session) -> None:
|
||||
if config.getoption("start_middleware"):
|
||||
ensure_compose_env_files(_REPO_ROOT)
|
||||
stack = build_middleware_stack(_REPO_ROOT, parse_services(config.getoption("middleware_services")))
|
||||
stack = replace(
|
||||
stack,
|
||||
shutdown_timeout_seconds=config.getoption("middleware_stop_timeout"),
|
||||
)
|
||||
stack.up()
|
||||
stacks.append(stack)
|
||||
|
||||
|
||||
@@ -79,3 +79,4 @@ COOKIE_NAME_PASSPORT = "passport"
|
||||
HEADER_NAME_CSRF_TOKEN = "X-CSRF-Token"
|
||||
HEADER_NAME_APP_CODE = "X-App-Code"
|
||||
HEADER_NAME_PASSPORT = "X-App-Passport"
|
||||
HEADER_NAME_CATALOG = "X-Dify-Catalog"
|
||||
|
||||
@@ -8,6 +8,7 @@ language_timezone_mapping = {
|
||||
"de-DE": "Europe/Berlin",
|
||||
"ja-JP": "Asia/Tokyo",
|
||||
"ko-KR": "Asia/Seoul",
|
||||
"lo-LA": "Asia/Vientiane",
|
||||
"ru-RU": "Europe/Moscow",
|
||||
"it-IT": "Europe/Rome",
|
||||
"uk-UA": "Europe/Kyiv",
|
||||
@@ -22,6 +23,7 @@ language_timezone_mapping = {
|
||||
"id-ID": "Asia/Jakarta",
|
||||
"ar-TN": "Africa/Tunis",
|
||||
"nl-NL": "Europe/Amsterdam",
|
||||
"az-AZ": "Asia/Baku",
|
||||
}
|
||||
|
||||
languages = list(language_timezone_mapping.keys())
|
||||
|
||||
65
api/constants/oauth_bearer.py
Normal file
65
api/constants/oauth_bearer.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Stable OAuth bearer vocabulary shared across application layers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class Scope(StrEnum):
|
||||
"""Catalog of bearer scopes recognised by the openapi surface.
|
||||
|
||||
`FULL` is the catch-all carried by `dfoa_` account tokens — it satisfies
|
||||
any per-route scope requirement. `dfoe_` tokens carry the per-feature
|
||||
scopes (`APPS_RUN`, `APPS_READ_PERMITTED_EXTERNAL`).
|
||||
"""
|
||||
|
||||
FULL = "full"
|
||||
APPS_READ = "apps:read"
|
||||
APPS_READ_PERMITTED_EXTERNAL = "apps:read:permitted-external"
|
||||
APPS_RUN = "apps:run"
|
||||
WORKSPACE_READ = "workspace:read"
|
||||
WORKSPACE_WRITE = "workspace:write"
|
||||
|
||||
|
||||
class SubjectType(StrEnum):
|
||||
# Annotation-only names are not members; they declare what `__new__` attaches.
|
||||
scopes: frozenset[Scope]
|
||||
# Whether the subject's token rows carry an `account_id`. A subject that does
|
||||
# not is keyed by its external issuer instead.
|
||||
bound_to_account: bool
|
||||
|
||||
ACCOUNT = ("account", frozenset({Scope.FULL}), True)
|
||||
EXTERNAL_SSO = ("external_sso", frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), False)
|
||||
|
||||
def __new__(cls, value: str, scopes: frozenset[Scope], bound_to_account: bool) -> SubjectType:
|
||||
obj = str.__new__(cls, value)
|
||||
obj._value_ = value
|
||||
obj.scopes = scopes
|
||||
obj.bound_to_account = bound_to_account
|
||||
return obj
|
||||
|
||||
|
||||
class TokenType(StrEnum):
|
||||
"""Many token types may serve one subject; the prefix is the only thing a
|
||||
bearer string reveals, so it lives here and nowhere else.
|
||||
"""
|
||||
|
||||
prefix: str
|
||||
subject: SubjectType
|
||||
|
||||
OAUTH_ACCOUNT = ("oauth_account", "dfoa_", SubjectType.ACCOUNT)
|
||||
OAUTH_EXTERNAL_SSO = ("oauth_external_sso", "dfoe_", SubjectType.EXTERNAL_SSO)
|
||||
|
||||
def __new__(cls, value: str, prefix: str, subject: SubjectType) -> TokenType:
|
||||
obj = str.__new__(cls, value)
|
||||
obj._value_ = value
|
||||
obj.prefix = prefix
|
||||
obj.subject = subject
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def for_token(cls, token: str) -> TokenType | None:
|
||||
return next((token_type for token_type in cls if token.startswith(token_type.prefix)), None)
|
||||
|
||||
|
||||
TOKEN_CACHE_KEY_FMT = "auth:token:{hash}"
|
||||
@@ -239,12 +239,12 @@ to the legacy area and avoid importing RESTX model objects from controllers.
|
||||
|
||||
## Verifying Swagger
|
||||
|
||||
For schema and documentation changes, run focused tests and generate Swagger JSON:
|
||||
For schema and documentation changes, run focused tests and generate Swagger JSON from the repository root:
|
||||
|
||||
```bash
|
||||
uv run --project . pytest tests/unit_tests/controllers/common/test_schema.py
|
||||
uv run --project . pytest tests/unit_tests/commands/test_generate_swagger_specs.py tests/unit_tests/controllers/test_swagger.py
|
||||
uv run --project . dev/generate_swagger_specs.py --output-dir /tmp/dify-openapi-check
|
||||
uv run --project api pytest api/tests/unit_tests/controllers/common/test_schema.py
|
||||
uv run --project api pytest api/tests/unit_tests/commands/test_generate_swagger_specs.py api/tests/unit_tests/controllers/test_swagger.py
|
||||
uv run --project api python api/dev/generate_swagger_specs.py --output-dir /tmp/dify-openapi-check
|
||||
```
|
||||
|
||||
Inspect affected endpoints with `jq`. Check that:
|
||||
@@ -253,3 +253,31 @@ Inspect affected endpoints with `jq`. Check that:
|
||||
- Request bodies appear only where the endpoint has a body.
|
||||
- Responses reference the expected `*Response` schema.
|
||||
- Response schemas use public serialized names, not internal validation aliases like `inputs_dict`.
|
||||
|
||||
## Service API Documentation Handoff
|
||||
|
||||
The `dify-docs` repository imports `service-openapi.json` for the `/v1` API. It keeps a pinned export and adds
|
||||
descriptions, examples, translations, and existing page URLs through documentation annotations. Fields, constraints,
|
||||
references, response statuses, and security come from this repository's export.
|
||||
|
||||
Verify the public schema against request validation and response serialization, including intentional schema overrides
|
||||
and excluded fields. Fix an inaccurate contract here and regenerate it before updating the documentation snapshot.
|
||||
Record the full source commit SHA when handing off the export. For coordinated changes that are not committed yet,
|
||||
also provide the source patch used to produce it; replace that patch with a clean committed export after merging.
|
||||
|
||||
The manual import, annotation review, build, and validation commands live in `tools/api-pipeline/README.md` in
|
||||
`dify-docs`. Regenerate this repository's OpenAPI Markdown and TypeScript/Zod contracts whenever their inputs change.
|
||||
|
||||
## Catalog metadata on /openapi/v1
|
||||
|
||||
Every `@endpoint` on `/openapi/v1` declares `op` (dotted lowercase id), `kind`
|
||||
(`object|list|sse|text|file`) and a one-line `summary`; they live on the route's
|
||||
`EndpointSpec`. `controllers/openapi/_catalog.py` derives the rest (input schema, bind,
|
||||
tags) from the route and its Pydantic models. Files are declared in the body model with
|
||||
`UploadPart`; never add a separate files decorator or parse `request.files` in a handler.
|
||||
Run ops are per app mode (`console_app.<mode>.run`), each a self-contained route with the exact
|
||||
body that mode takes.
|
||||
Next steps for the caller go in a `hints` field (see `_hints.py`); `hints` is reserved.
|
||||
Frozen after release: kind names, bind values, the pagination envelope, and any field the
|
||||
CLI's fold table reads from an SSE event. Breaking input changes get a new op id; the old
|
||||
one stays and is marked `deprecated=True`.
|
||||
|
||||
85
api/controllers/common/agent_access.py
Normal file
85
api/controllers/common/agent_access.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from services.app_service import AppListBaseParams
|
||||
from services.enterprise.rbac_service import MyPermissionsResponse
|
||||
|
||||
|
||||
AGENT_LIST_PERMISSION_KEYS: frozenset[str] = frozenset({"agent.acl.preview"})
|
||||
|
||||
|
||||
def has_agent_list_permission(permission_keys: Sequence[str]) -> bool:
|
||||
"""Return whether the permission set makes an Agent visible in resource lists."""
|
||||
return any(permission_key in AGENT_LIST_PERMISSION_KEYS for permission_key in permission_keys)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentAccessFilter:
|
||||
"""Resolved resource-level visibility for Agent list endpoints.
|
||||
|
||||
``accessible_agent_ids`` of ``None`` means every Agent is visible. An
|
||||
explicit set limits list queries to exactly those Agent resources.
|
||||
"""
|
||||
|
||||
accessible_agent_ids: set[str] | None
|
||||
|
||||
@classmethod
|
||||
def unrestricted(cls) -> AgentAccessFilter:
|
||||
return cls(accessible_agent_ids=None)
|
||||
|
||||
def apply_to_app_params(self, params: AppListBaseParams, *, tenant_id: str, session: Session) -> None:
|
||||
"""Translate visible Agent ids to their Agent App ids before pagination."""
|
||||
if self.accessible_agent_ids is None:
|
||||
return
|
||||
if not self.accessible_agent_ids:
|
||||
params.accessible_app_ids = []
|
||||
return
|
||||
|
||||
params.accessible_app_ids = AgentRosterService(session).load_app_ids_for_agents(
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=sorted(self.accessible_agent_ids),
|
||||
)
|
||||
|
||||
|
||||
def resolve_agent_access_filter(
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session,
|
||||
permissions: MyPermissionsResponse | None = None,
|
||||
) -> AgentAccessFilter:
|
||||
"""Compute Agent resources visible to an account in a workspace."""
|
||||
if permissions is None:
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
tenant_id,
|
||||
account_id,
|
||||
session=session,
|
||||
)
|
||||
whitelist_scope = enterprise_rbac_service.RBACService.AgentAccess.whitelist_resources(tenant_id, account_id)
|
||||
has_default_preview = has_agent_list_permission(
|
||||
permissions.agent.default_permission_keys
|
||||
) or has_agent_list_permission(permissions.workspace.permission_keys)
|
||||
|
||||
permission_agent_ids: set[str] | None = None
|
||||
if not has_default_preview:
|
||||
permission_agent_ids = {
|
||||
override.resource_id
|
||||
for override in permissions.agent.overrides
|
||||
if has_agent_list_permission(override.permission_keys)
|
||||
}
|
||||
|
||||
if whitelist_scope.unrestricted:
|
||||
accessible_agent_ids = permission_agent_ids
|
||||
else:
|
||||
accessible_agent_ids = set(whitelist_scope.resource_ids)
|
||||
|
||||
return AgentAccessFilter(accessible_agent_ids=accessible_agent_ids)
|
||||
@@ -1,109 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from services.app_service import AppListBaseParams
|
||||
from services.enterprise.rbac_service import MyPermissionsResponse
|
||||
|
||||
# Permission keys (dot-notation, from MyPermissionsResponse) that grant
|
||||
# list/preview access to an app. Keep this the single source of truth for both
|
||||
# the console and OpenAPI app-list endpoints.
|
||||
APP_LIST_PERMISSION_KEYS: frozenset[str] = frozenset({"app.preview", "app.acl.preview", "app.full_access"})
|
||||
|
||||
# Workspace permission key that lets a caller see apps they maintain when the
|
||||
# app whitelist is unrestricted.
|
||||
_MANAGE_OWN_APPS_PERMISSION_KEY = "app.create_and_management"
|
||||
|
||||
|
||||
def has_app_list_permission(permission_keys: Sequence[str]) -> bool:
|
||||
"""Return True if any of ``permission_keys`` grants app list/preview access."""
|
||||
return any(permission_key in APP_LIST_PERMISSION_KEYS for permission_key in permission_keys)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppAccessFilter:
|
||||
"""Resolved RBAC visibility for app list/read endpoints.
|
||||
|
||||
``accessible_app_ids`` of ``None`` means the caller can see every app in the
|
||||
workspace (unrestricted). Otherwise it is the exact set of app ids the
|
||||
caller may preview; combined with ``can_manage_own_apps`` it can also cover
|
||||
apps the caller maintains.
|
||||
"""
|
||||
|
||||
accessible_app_ids: set[str] | None
|
||||
can_manage_own_apps: bool
|
||||
|
||||
@classmethod
|
||||
def unrestricted(cls) -> AppAccessFilter:
|
||||
"""Filter that imposes no restriction (RBAC disabled / not applicable)."""
|
||||
return cls(accessible_app_ids=None, can_manage_own_apps=False)
|
||||
|
||||
def is_app_accessible(self, app_id: str, maintainer: str | None, account_id: str) -> bool:
|
||||
"""Whether a single app is visible to the caller under this filter.
|
||||
|
||||
Mirrors the service-layer query gate: an app is visible when the filter
|
||||
is unrestricted, the app id is whitelisted, or the caller maintains it
|
||||
and holds ``app.create_and_management``.
|
||||
"""
|
||||
if self.accessible_app_ids is None:
|
||||
return True
|
||||
if app_id in self.accessible_app_ids:
|
||||
return True
|
||||
return self.can_manage_own_apps and maintainer is not None and maintainer == account_id
|
||||
|
||||
def apply_to_params(self, params: AppListBaseParams) -> None:
|
||||
if self.accessible_app_ids is None:
|
||||
return
|
||||
params.accessible_app_ids = sorted(self.accessible_app_ids)
|
||||
params.include_own_apps = self.can_manage_own_apps
|
||||
|
||||
|
||||
def resolve_app_access_filter(
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session,
|
||||
permissions: MyPermissionsResponse | None = None,
|
||||
) -> AppAccessFilter:
|
||||
"""Compute the RBAC app-access filter for ``account_id`` in ``tenant_id``.
|
||||
|
||||
Pass ``permissions`` when the caller has already fetched the snapshot (the
|
||||
console controller reuses it for per-app permission keys) to avoid a second
|
||||
inner-API round trip; otherwise it is fetched here.
|
||||
"""
|
||||
if permissions is None:
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(tenant_id, account_id, session=session)
|
||||
whitelist_scope = enterprise_rbac_service.RBACService.AppAccess.whitelist_resources(tenant_id, account_id)
|
||||
|
||||
can_manage_own_apps = _MANAGE_OWN_APPS_PERMISSION_KEY in permissions.workspace.permission_keys
|
||||
has_default_preview = has_app_list_permission(permissions.app.default_permission_keys) or has_app_list_permission(
|
||||
permissions.workspace.permission_keys
|
||||
)
|
||||
|
||||
permission_app_ids: set[str] | None = None
|
||||
if not has_default_preview:
|
||||
# Collect apps the caller can preview via per-app permission overrides.
|
||||
permission_app_ids = {
|
||||
override.resource_id
|
||||
for override in permissions.app.overrides
|
||||
if has_app_list_permission(override.permission_keys)
|
||||
}
|
||||
|
||||
accessible_app_ids: set[str] | None
|
||||
if getattr(whitelist_scope, "unrestricted", False):
|
||||
accessible_app_ids = permission_app_ids
|
||||
else:
|
||||
# A restricted app whitelist is the highest-priority visibility gate:
|
||||
# default preview, per-app permission overrides, and own-app management
|
||||
# must not expose apps outside this set.
|
||||
accessible_app_ids = set(whitelist_scope.resource_ids)
|
||||
can_manage_own_apps = False
|
||||
|
||||
return AppAccessFilter(accessible_app_ids=accessible_app_ids, can_manage_own_apps=can_manage_own_apps)
|
||||
27
api/controllers/common/audio_response.py
Normal file
27
api/controllers/common/audio_response.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Create binary audio responses and own their stream cleanup."""
|
||||
|
||||
from flask import Response, stream_with_context
|
||||
|
||||
from core.base.tts.audio_mime import inspect_audio_stream, resolve_audio_mime_type
|
||||
from services.audio_types import AudioOutput
|
||||
|
||||
|
||||
def audio_binary_response(output: AudioOutput | None) -> Response | None:
|
||||
"""Preserve audio MIME and request context while releasing provider streams."""
|
||||
if output is None:
|
||||
return None
|
||||
if isinstance(output.data, (bytes, bytearray, memoryview)):
|
||||
data = bytes(output.data)
|
||||
return Response(data, content_type=resolve_audio_mime_type(data, output.mime_type))
|
||||
|
||||
audio_stream, mime_type = inspect_audio_stream(output.data, output.mime_type)
|
||||
try:
|
||||
response = Response(
|
||||
stream_with_context(audio_stream), # pyrefly: ignore[no-matching-overload]
|
||||
content_type=mime_type,
|
||||
)
|
||||
response.call_on_close(audio_stream.close)
|
||||
return response
|
||||
except BaseException:
|
||||
audio_stream.close()
|
||||
raise
|
||||
@@ -1,18 +1,54 @@
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
class FilenameNotExistsError(HTTPException):
|
||||
class FilenameNotExistsError(BaseHTTPException):
|
||||
error_code = "filename_not_exists_error"
|
||||
code = 400
|
||||
description = "The specified filename does not exist."
|
||||
|
||||
|
||||
class RemoteFileUploadError(HTTPException):
|
||||
class RemoteFileUploadError(BaseHTTPException):
|
||||
error_code = "remote_file_upload_error"
|
||||
code = 400
|
||||
description = "Error uploading remote file."
|
||||
|
||||
|
||||
class RemoteFileInvalidUrlError(BaseHTTPException):
|
||||
error_code = "remote_file_invalid_url"
|
||||
description = "The remote file URL is invalid."
|
||||
code = 400
|
||||
|
||||
|
||||
class RemoteFileUrlBlockedError(BaseHTTPException):
|
||||
error_code = "remote_file_url_blocked"
|
||||
description = "The remote file URL is not allowed."
|
||||
code = 400
|
||||
|
||||
|
||||
class RemoteFileNotFoundError(BaseHTTPException):
|
||||
error_code = "remote_file_not_found"
|
||||
description = "The remote file could not be found."
|
||||
code = 404
|
||||
|
||||
|
||||
class RemoteFileAccessDeniedError(BaseHTTPException):
|
||||
error_code = "remote_file_access_denied"
|
||||
description = "The remote file cannot be accessed without authorization."
|
||||
code = 400
|
||||
|
||||
|
||||
class RemoteFileUnavailableError(BaseHTTPException):
|
||||
error_code = "remote_file_unavailable"
|
||||
description = "The remote file is temporarily unavailable."
|
||||
code = 502
|
||||
|
||||
|
||||
class RemoteFileInvalidResponseError(BaseHTTPException):
|
||||
error_code = "remote_file_invalid_response"
|
||||
description = "The remote file server returned an invalid response."
|
||||
code = 502
|
||||
|
||||
|
||||
class FileTooLargeError(BaseHTTPException):
|
||||
error_code = "file_too_large"
|
||||
description = "File size exceeded. {message}"
|
||||
|
||||
@@ -1,39 +1,4 @@
|
||||
import contextlib
|
||||
import mimetypes
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import urllib.parse
|
||||
import warnings
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
import magic
|
||||
except ImportError:
|
||||
if platform.system() == "Windows":
|
||||
warnings.warn(
|
||||
"To use python-magic guess MIMETYPE, you need to run `pip install python-magic-bin`", stacklevel=2
|
||||
)
|
||||
elif platform.system() == "Darwin":
|
||||
warnings.warn("To use python-magic guess MIMETYPE, you need to run `brew install libmagic`", stacklevel=2)
|
||||
elif platform.system() == "Linux":
|
||||
warnings.warn(
|
||||
"To use python-magic guess MIMETYPE, you need to run `sudo apt-get install libmagic1`", stacklevel=2
|
||||
)
|
||||
else:
|
||||
warnings.warn("To use python-magic guess MIMETYPE, you need to install `libmagic`", stacklevel=2)
|
||||
magic = None # type: ignore[assignment]
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class FileInfo(BaseModel):
|
||||
filename: str
|
||||
extension: str
|
||||
mimetype: str
|
||||
size: int
|
||||
|
||||
|
||||
def decode_remote_url(url: str, query_string: bytes | str = b"") -> str:
|
||||
@@ -45,59 +10,15 @@ def decode_remote_url(url: str, query_string: bytes | str = b"") -> str:
|
||||
if not raw_query:
|
||||
return decoded_url
|
||||
|
||||
try:
|
||||
has_query = bool(urllib.parse.urlsplit(decoded_url).query)
|
||||
except ValueError:
|
||||
has_query = False
|
||||
|
||||
if decoded_url.endswith(("?", "&")):
|
||||
separator = ""
|
||||
elif urllib.parse.urlsplit(decoded_url).query:
|
||||
elif has_query:
|
||||
separator = "&"
|
||||
else:
|
||||
separator = "?"
|
||||
return f"{decoded_url}{separator}{raw_query}"
|
||||
|
||||
|
||||
def guess_file_info_from_response(response: httpx.Response):
|
||||
url = str(response.url)
|
||||
# Try to extract filename from URL
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
url_path = parsed_url.path
|
||||
# Decode percent-encoded characters in the path segment
|
||||
filename = urllib.parse.unquote(os.path.basename(url_path))
|
||||
|
||||
# If filename couldn't be extracted, use Content-Disposition header
|
||||
if not filename:
|
||||
content_disposition = response.headers.get("Content-Disposition")
|
||||
if content_disposition:
|
||||
filename_match = re.search(r'filename="?(.+)"?', content_disposition)
|
||||
if filename_match:
|
||||
filename = filename_match.group(1)
|
||||
|
||||
# If still no filename, generate a unique one
|
||||
if not filename:
|
||||
unique_name = str(uuid4())
|
||||
filename = f"{unique_name}"
|
||||
|
||||
# Guess MIME type from filename first, then URL
|
||||
mimetype, _ = mimetypes.guess_type(filename)
|
||||
if mimetype is None:
|
||||
mimetype, _ = mimetypes.guess_type(url)
|
||||
if mimetype is None:
|
||||
# If guessing fails, use Content-Type from response headers
|
||||
mimetype = response.headers.get("Content-Type", "application/octet-stream")
|
||||
|
||||
# Use python-magic to guess MIME type if still unknown or generic
|
||||
if mimetype == "application/octet-stream" and magic is not None:
|
||||
with contextlib.suppress(magic.MagicException):
|
||||
mimetype = magic.from_buffer(response.content[:1024], mime=True)
|
||||
|
||||
extension = os.path.splitext(filename)[1]
|
||||
|
||||
# Ensure filename has an extension
|
||||
if not extension:
|
||||
extension = mimetypes.guess_extension(mimetype) or ".bin"
|
||||
filename = f"{filename}{extension}"
|
||||
|
||||
return FileInfo(
|
||||
filename=filename,
|
||||
extension=extension,
|
||||
mimetype=mimetype,
|
||||
size=int(response.headers.get("Content-Length", -1)),
|
||||
)
|
||||
|
||||
31
api/controllers/common/rbac/__init__.py
Normal file
31
api/controllers/common/rbac/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
|
||||
from .checks import RBAC_CHECKS_ATTR, RBACCheck, enforce_rbac_checks
|
||||
from .locators import (
|
||||
AgentBehindApp,
|
||||
AgentId,
|
||||
DatasetByDocument,
|
||||
DatasetByPipeline,
|
||||
DatasetId,
|
||||
PlainApp,
|
||||
ResourceIdentity,
|
||||
ResourceLocator,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RBAC_CHECKS_ATTR",
|
||||
"AgentBehindApp",
|
||||
"AgentId",
|
||||
"DatasetByDocument",
|
||||
"DatasetByPipeline",
|
||||
"DatasetId",
|
||||
"PlainApp",
|
||||
"RBACCheck",
|
||||
"RBACPermission",
|
||||
"RBACResourceScope",
|
||||
"ResourceIdentity",
|
||||
"ResourceLocator",
|
||||
"Workspace",
|
||||
"enforce_rbac_checks",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user