mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-09 10:09:47 -10:00
Compare commits
687
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ef01446a | ||
|
|
4c3b906179 | ||
|
|
6c5678feae | ||
|
|
dd27aeb809 | ||
|
|
7b5b1c6638 | ||
|
|
feb20d7bc7 | ||
|
|
838996a49d | ||
|
|
3ed49c449f | ||
|
|
60e562d14d | ||
|
|
ebaa192dee | ||
|
|
2e1d4a02fc | ||
|
|
2fdd27f677 | ||
|
|
ea04298ad9 | ||
|
|
4bcb0cb108 | ||
|
|
20b55ab83a | ||
|
|
ac152963be | ||
|
|
a4ad7f2bcf | ||
|
|
89fe3be501 | ||
|
|
7314445da4 | ||
|
|
42b76f3272 | ||
|
|
73f609bc3e | ||
|
|
624b4a66c9 | ||
|
|
515d901e36 | ||
|
|
534aeaa907 | ||
|
|
0bc316115c | ||
|
|
a2a5a265c7 | ||
|
|
e64690aaf7 | ||
|
|
f84138b2a5 | ||
|
|
57a11e51ea | ||
|
|
81b489aad8 | ||
|
|
102a567977 | ||
|
|
b7d1e3a3e4 | ||
|
|
537f587b17 | ||
|
|
5c3504e2e6 | ||
|
|
bfc3bc614b | ||
|
|
5fdb3ef97f | ||
|
|
8bb8eaca5b | ||
|
|
9384da3d6f | ||
|
|
61884f2326 | ||
|
|
d4afb2176f | ||
|
|
b043906f06 | ||
|
|
793050d04a | ||
|
|
c7d1a75edb | ||
|
|
33c1a7ad6e | ||
|
|
af4c9bc632 | ||
|
|
b9a7e1943c | ||
|
|
34e975f5b5 | ||
|
|
bd1a47114b | ||
|
|
cd2e0739d3 | ||
|
|
52238fc09c | ||
|
|
f996d9e9c2 | ||
|
|
cc34fe665a | ||
|
|
83d2c68bf1 | ||
|
|
7c2ea3ca14 | ||
|
|
2377ba6ced | ||
|
|
6f659d9cc2 | ||
|
|
532ff00e37 | ||
|
|
0b78d0b56e | ||
|
|
c267bf01f4 | ||
|
|
082c6bcf1f | ||
|
|
84682f4dad | ||
|
|
22166b4673 | ||
|
|
b2282b8c85 | ||
|
|
79e1f29890 | ||
|
|
a5b479f003 | ||
|
|
6af9e383ff | ||
|
|
705dab6ce1 | ||
|
|
1c3ed3fa8d | ||
|
|
b099fc22f1 | ||
|
|
6f68216ea4 | ||
|
|
bfdf452235 | ||
|
|
7d6aae520f | ||
|
|
fc96f5ca35 | ||
|
|
97d5fa03f2 | ||
|
|
bc051c015f | ||
|
|
db7540718e | ||
|
|
bc9f649176 | ||
|
|
a3d5912298 | ||
|
|
a3fd17f8c5 | ||
|
|
c49b5f7744 | ||
|
|
9758790810 | ||
|
|
e443ec7909 | ||
|
|
e1187a9b14 | ||
|
|
cd1fe36e3e | ||
|
|
a89ac0fabf | ||
|
|
c1be2030bc | ||
|
|
3b40c6f679 | ||
|
|
7d0de54f29 | ||
|
|
4bed0e9cc3 | ||
|
|
faa90a4df6 | ||
|
|
6e19114bd9 | ||
|
|
d8bcd3d0fc | ||
|
|
85f86f7dd9 | ||
|
|
9807ddecea | ||
|
|
802db6db28 | ||
|
|
265ad9cf3c | ||
|
|
991cd9849a | ||
|
|
a3ddd3ef55 | ||
|
|
b8117814e4 | ||
|
|
b07cdec87e | ||
|
|
bc805f9a2a | ||
|
|
bf00490c1d | ||
|
|
16ef685b7e | ||
|
|
cde57b3748 | ||
|
|
8f3ec1de33 | ||
|
|
50e6cc2a0e | ||
|
|
a73d800832 | ||
|
|
f80c9cd374 | ||
|
|
5a8cae7891 | ||
|
|
5a5bad9d22 | ||
|
|
1caa7e9e3a | ||
|
|
ac441a2694 | ||
|
|
0ab22e6ebb | ||
|
|
8347f153c1 | ||
|
|
4a4e2f710f | ||
|
|
06f1333bfc | ||
|
|
3df41f6a7d | ||
|
|
929b746b12 | ||
|
|
11bcb647c9 | ||
|
|
cb811824cd | ||
|
|
9bc390b061 | ||
|
|
155d5c8112 | ||
|
|
6c64ef6d1f | ||
|
|
d21c8f3f8f | ||
|
|
210eba65c9 | ||
|
|
fa1300a6cc | ||
|
|
e1a587862c | ||
|
|
3131b72ffb | ||
|
|
a4b963f0d3 | ||
|
|
5ff9f643a9 | ||
|
|
f38fefb210 | ||
|
|
4969f37bb8 | ||
|
|
ce81c05081 | ||
|
|
e5c652b7d2 | ||
|
|
027c7ea6bf | ||
|
|
90fef472e2 | ||
|
|
3be9c1b242 | ||
|
|
30bb1ce6ff | ||
|
|
96f32ac4be | ||
|
|
da6aa237f3 | ||
|
|
e985a6285c | ||
|
|
994f567dbe | ||
|
|
b01979b4c7 | ||
|
|
16220fcd3a | ||
|
|
3ce7af6c6d | ||
|
|
0f0c1f508d | ||
|
|
25595e115c | ||
|
|
20cbc69f0a | ||
|
|
5527248d2d | ||
|
|
75b375d39e | ||
|
|
3226567932 | ||
|
|
15dfddcb81 | ||
|
|
e4c3469f02 | ||
|
|
f4aca68783 | ||
|
|
5063f35d5a | ||
|
|
3b27fc2dff | ||
|
|
648838f8ef | ||
|
|
4b39b90aea | ||
|
|
71e401bc18 | ||
|
|
37a2cbf8d2 | ||
|
|
89ed725862 | ||
|
|
e19bc0d9a0 | ||
|
|
29f622e73d | ||
|
|
60823d124b | ||
|
|
de9e561824 | ||
|
|
cd5bd28df5 | ||
|
|
b043d61f09 | ||
|
|
f0bb49ab42 | ||
|
|
c00a71e7fa | ||
|
|
4e02f285fc | ||
|
|
4773cab958 | ||
|
|
622e09acbc | ||
|
|
89e71b1a5d | ||
|
|
814c83bd43 | ||
|
|
69f3f29b81 | ||
|
|
f9d642a100 | ||
|
|
ef9b84375b | ||
|
|
1c223eb94e | ||
|
|
31918bf0be | ||
|
|
438ff23646 | ||
|
|
72a83cca9a | ||
|
|
aab7b16b85 | ||
|
|
1addd51212 | ||
|
|
5f06f9acf7 | ||
|
|
60f454bd95 | ||
|
|
afa32f7d91 | ||
|
|
378addf505 | ||
|
|
d89c40d76e | ||
|
|
89cae15136 | ||
|
|
0be1eb3d8d | ||
|
|
7e73c29402 | ||
|
|
b3db14a522 | ||
|
|
5b145d2d0d | ||
|
|
eb8c7e80c3 | ||
|
|
8d08de0033 | ||
|
|
2c52f9129e | ||
|
|
d250df5b2e | ||
|
|
13946d2558 | ||
|
|
4e15df1808 | ||
|
|
5e9b61802f | ||
|
|
7ddfa5e35f | ||
|
|
1795729d5b | ||
|
|
e406bf2f69 | ||
|
|
bd1e206716 | ||
|
|
5a46d38e2c | ||
|
|
59e4477e05 | ||
|
|
be634d59bb | ||
|
|
3a9ff99ff9 | ||
|
|
655286558f | ||
|
|
1a66c4e91a | ||
|
|
3aeee5afb2 | ||
|
|
8e005b7d97 | ||
|
|
9637a17923 | ||
|
|
e8a4484541 | ||
|
|
ef3d2176f6 | ||
|
|
1f170125f8 | ||
|
|
5ad4233ecc | ||
|
|
3675ead50e | ||
|
|
80783a92dd | ||
|
|
503f337faa | ||
|
|
e9dd9df2d4 | ||
|
|
7e29ce764e | ||
|
|
fc4dd5b763 | ||
|
|
62bcf9f5bf | ||
|
|
1e8093e1c4 | ||
|
|
0282b0e7e0 | ||
|
|
4077aa7510 | ||
|
|
ed07096efd | ||
|
|
7e659cd67f | ||
|
|
c738f05801 | ||
|
|
754d8f982c | ||
|
|
87df15205e | ||
|
|
f28ef1b839 | ||
|
|
5687e3dadc | ||
|
|
dc73f96f45 | ||
|
|
a4f77e07dd | ||
|
|
f618aaa07e | ||
|
|
be0cf45263 | ||
|
|
774c47d285 | ||
|
|
6a44224c09 | ||
|
|
245f888aed | ||
|
|
c8d557a36b | ||
|
|
826956731e | ||
|
|
dc6f3cc99f | ||
|
|
16a5ab0dab | ||
|
|
d9fe125e4f | ||
|
|
e371b8ca0b | ||
|
|
454a9b44b1 | ||
|
|
8215f85fed | ||
|
|
38530adfd5 | ||
|
|
e2cb29aae2 | ||
|
|
52d4b3a978 | ||
|
|
fcc74d7f83 | ||
|
|
f4df2ce618 | ||
|
|
ff7e0101c8 | ||
|
|
196f906fda | ||
|
|
8613333530 | ||
|
|
20091e485f | ||
|
|
7510a10da4 | ||
|
|
943895f38e | ||
|
|
1a42c5c0a0 | ||
|
|
eab75434d6 | ||
|
|
8d0b6ba5f6 | ||
|
|
9daaa884d3 | ||
|
|
c5c8cb9953 | ||
|
|
48018cab62 | ||
|
|
5771e21f3a | ||
|
|
7b0ed8b3b6 | ||
|
|
fc926b2483 | ||
|
|
4221aac09a | ||
|
|
862eebf979 | ||
|
|
bc8eecf77d | ||
|
|
f05f178945 | ||
|
|
15232864b1 | ||
|
|
4f4a986510 | ||
|
|
bd57e54ce9 | ||
|
|
c1384ef4b5 | ||
|
|
8dd22fc275 | ||
|
|
513e1deb15 | ||
|
|
f0ca56700f | ||
|
|
ba441bd2c3 | ||
|
|
c951cef5d4 | ||
|
|
2e97b4bb14 | ||
|
|
83ad7fb7de | ||
|
|
eca3f48030 | ||
|
|
6bc90af3b6 | ||
|
|
9405013ebb | ||
|
|
3f30dad649 | ||
|
|
9d2785a127 | ||
|
|
c0861b343d | ||
|
|
4f64f6d979 | ||
|
|
e017956696 | ||
|
|
26aca1a451 | ||
|
|
d712f19331 | ||
|
|
3c25e9e271 | ||
|
|
3a9e6edcfc | ||
|
|
d16b7ad3b2 | ||
|
|
29563d5e20 | ||
|
|
c72855d4cd | ||
|
|
bd6807a73f | ||
|
|
70c53db226 | ||
|
|
32e76de41f | ||
|
|
f7e5ee8aac | ||
|
|
41f8f1588c | ||
|
|
ec55ec40f4 | ||
|
|
0615514b2d | ||
|
|
e1ba667799 | ||
|
|
93db761bc2 | ||
|
|
7716e09c9b | ||
|
|
981c0029ca | ||
|
|
2e5bcbc71d | ||
|
|
185715483d | ||
|
|
1a29e111b9 | ||
|
|
af418b8964 | ||
|
|
8b6d7185f2 | ||
|
|
1614853b62 | ||
|
|
efa79b12da | ||
|
|
8ef30e4642 | ||
|
|
c666fd907c | ||
|
|
7c0a85f39b | ||
|
|
d99a50ea7d | ||
|
|
990f50f10e | ||
|
|
0e9108b716 | ||
|
|
349ca6efda | ||
|
|
79ffe30b6f | ||
|
|
c1bd951963 | ||
|
|
5fbb86c97c | ||
|
|
6961d37b2b | ||
|
|
e56e534eba | ||
|
|
4f84a1ea22 | ||
|
|
0c02afd5b0 | ||
|
|
ec78ceb96d | ||
|
|
738b9c222e | ||
|
|
de7025560a | ||
|
|
d56cdfdb6e | ||
|
|
7d9cc18f38 | ||
|
|
197757f473 | ||
|
|
3e0386d50d | ||
|
|
5be92574ca | ||
|
|
1f25cdce24 | ||
|
|
c52fcda6d9 | ||
|
|
18b21d6e55 | ||
|
|
2a96053fbf | ||
|
|
7bff95b200 | ||
|
|
3cb63f85ee | ||
|
|
9c19f67c78 | ||
|
|
1194fe2cdf | ||
|
|
39f980848a | ||
|
|
6b3b7b967f | ||
|
|
73843474c5 | ||
|
|
6eda573200 | ||
|
|
478a67466c | ||
|
|
9ca8f79a50 | ||
|
|
10a4ace8d7 | ||
|
|
23b57cd187 | ||
|
|
9bdcf1ff5b | ||
|
|
58d8a2387d | ||
|
|
f19889e7ce | ||
|
|
bbac827f3c | ||
|
|
6e0b836417 | ||
|
|
b1d9e748c3 | ||
|
|
fd2f47363e | ||
|
|
1d9011483b | ||
|
|
3cb175fe46 | ||
|
|
6caa951b8a | ||
|
|
46e54c45e8 | ||
|
|
9d3d5ed4c7 | ||
|
|
d057b21b2b | ||
|
|
51e80c713c | ||
|
|
493ba0d488 | ||
|
|
9cdb9697f3 | ||
|
|
0dddbe3161 | ||
|
|
face88ce47 | ||
|
|
125ea28567 | ||
|
|
abe653bc00 | ||
|
|
8eed9113eb | ||
|
|
36ccc4b6e5 | ||
|
|
c6d8c327af | ||
|
|
7d22c45cd9 | ||
|
|
8bcdb3f160 | ||
|
|
e040feae13 | ||
|
|
3eea1323fa | ||
|
|
05092b0bd4 | ||
|
|
d5085446ce | ||
|
|
9d1219eb20 | ||
|
|
f356faca5d | ||
|
|
00e4e00314 | ||
|
|
025ac33229 | ||
|
|
ccbb16aa13 | ||
|
|
57d0fb055b | ||
|
|
a49907449c | ||
|
|
a32c815702 | ||
|
|
850bd4adf3 | ||
|
|
dfd56e978e | ||
|
|
c63a2ad573 | ||
|
|
8cd6f4877c | ||
|
|
b812a08007 | ||
|
|
9aca05da67 | ||
|
|
9271409828 | ||
|
|
e2d2e841cf | ||
|
|
9409e929a4 | ||
|
|
917d141031 | ||
|
|
388b5a6bf2 | ||
|
|
2e4b9abb81 | ||
|
|
bad9b10ac0 | ||
|
|
38daccd862 | ||
|
|
3c608e2de4 | ||
|
|
d090287814 | ||
|
|
bee555b2e1 | ||
|
|
3276908916 | ||
|
|
df113e4766 | ||
|
|
e2663c81fc | ||
|
|
da1127113c | ||
|
|
213e507101 | ||
|
|
243056082c | ||
|
|
7b0891bb0d | ||
|
|
776b96b7e5 | ||
|
|
ecb3bb90ac | ||
|
|
7a0a28a0bc | ||
|
|
a9411e6509 | ||
|
|
c875ffe226 | ||
|
|
9f6736c359 | ||
|
|
e3220b2d5a | ||
|
|
484f4b3c70 | ||
|
|
552d399aa9 | ||
|
|
68584ae7d3 | ||
|
|
1dd366e223 | ||
|
|
dcd24665c2 | ||
|
|
6b31c051c3 | ||
|
|
354419ceb0 | ||
|
|
fb6e281741 | ||
|
|
633f1074ad | ||
|
|
06e31ec5af | ||
|
|
4a0576ca4b | ||
|
|
b66b8c0f29 | ||
|
|
d0f254f5e3 | ||
|
|
ba7ff89500 | ||
|
|
fda802f884 | ||
|
|
495f38bb37 | ||
|
|
21b048bb4c | ||
|
|
1b5944c44b | ||
|
|
42004449fd | ||
|
|
877e1c2e3f | ||
|
|
1b2496344a | ||
|
|
b1ca93b735 | ||
|
|
601fef379a | ||
|
|
c878e627ef | ||
|
|
f186427167 | ||
|
|
30ad8b9f50 | ||
|
|
d0198ac3c3 | ||
|
|
a51c70b864 | ||
|
|
a8062f8a74 | ||
|
|
c5c2768a38 | ||
|
|
60f48c6ff6 | ||
|
|
f7cd809ef7 | ||
|
|
80b94639f6 | ||
|
|
2dba7933c6 | ||
|
|
1d41888631 | ||
|
|
ccdb1c5b6c | ||
|
|
152112d242 | ||
|
|
d75fa60850 | ||
|
|
033a1c6c7a | ||
|
|
42818581fa | ||
|
|
450e1124d2 | ||
|
|
f492423ffe | ||
|
|
f189fe59b2 | ||
|
|
c47a16b117 | ||
|
|
6219fefbc0 | ||
|
|
76488cf701 | ||
|
|
6dfc9c412a | ||
|
|
8cf578dcec | ||
|
|
fb2fa1210e | ||
|
|
653d64741d | ||
|
|
6d68831728 | ||
|
|
e8e299a805 | ||
|
|
461141b934 | ||
|
|
ddd2451bd9 | ||
|
|
ae51db5306 | ||
|
|
6420d5d4c4 | ||
|
|
46b00069e7 | ||
|
|
916fe3c749 | ||
|
|
3f6f2f463f | ||
|
|
b95921df69 | ||
|
|
998141bdc7 | ||
|
|
0c816e2e3d | ||
|
|
89291d9858 | ||
|
|
e1f226f9a5 | ||
|
|
9e177d7608 | ||
|
|
9b890ef354 | ||
|
|
c4057084fe | ||
|
|
69cb4c2052 | ||
|
|
6f80ca071d | ||
|
|
fd7c18a054 | ||
|
|
55c9ff1670 | ||
|
|
b762e1fb03 | ||
|
|
eca3425581 | ||
|
|
f46c705db6 | ||
|
|
6458de2f64 | ||
|
|
7c62e0da27 | ||
|
|
820e618ecc | ||
|
|
cde590c915 | ||
|
|
16bbcf4d6e | ||
|
|
340c089ea4 | ||
|
|
4a8abe39e1 | ||
|
|
6037ccab3d | ||
|
|
95fa08793d | ||
|
|
642cc8abfd | ||
|
|
a055fa2ee0 | ||
|
|
ca883d98ca | ||
|
|
cbb8dd0e18 | ||
|
|
55e8b20566 | ||
|
|
62d57ef75d | ||
|
|
28e9c67913 | ||
|
|
6fd1ef159b | ||
|
|
ea001c798f | ||
|
|
a851e6acc6 | ||
|
|
201eb0ac3c | ||
|
|
6e20a6eb2e | ||
|
|
e0f839567f | ||
|
|
9e293b417d | ||
|
|
3c5343b192 | ||
|
|
9cd8e864a4 | ||
|
|
553c013b8f | ||
|
|
8f53911749 | ||
|
|
1539520065 | ||
|
|
e148b63e31 | ||
|
|
cf6ae11147 | ||
|
|
b00f482384 | ||
|
|
a62c4181e7 | ||
|
|
62ec100690 | ||
|
|
ceec2be1d6 | ||
|
|
b786e0a552 | ||
|
|
209b7272a7 | ||
|
|
ea26e372d6 | ||
|
|
37db9b4382 | ||
|
|
c4bc59ba99 | ||
|
|
f4ea2eb781 | ||
|
|
c5ed6fcc40 | ||
|
|
2a33ba895a | ||
|
|
d754b4da73 | ||
|
|
79803492ce | ||
|
|
1f11fc8d8e | ||
|
|
25dfead6d1 | ||
|
|
8b5a32a08f | ||
|
|
42ba76561d | ||
|
|
8688c233d2 | ||
|
|
9e204a6d43 | ||
|
|
50b7174ac2 | ||
|
|
d532a59b33 | ||
|
|
16c70c6026 | ||
|
|
99b3862bdc | ||
|
|
27e7888f27 | ||
|
|
92652300f8 | ||
|
|
5e95a0c810 | ||
|
|
b5c5560fe8 | ||
|
|
d26ffc3238 | ||
|
|
dbca9c31cd | ||
|
|
7b374b8bfc | ||
|
|
e9e7bdf17a | ||
|
|
dc430c1825 | ||
|
|
120a99eec3 | ||
|
|
f8531403d8 | ||
|
|
51c7e6ef61 | ||
|
|
13ea727286 | ||
|
|
feed3a8b31 | ||
|
|
9314963e7e | ||
|
|
92e41f883a | ||
|
|
658a44df10 | ||
|
|
2b3f088781 | ||
|
|
68c473e645 | ||
|
|
4a1e912f3d | ||
|
|
bfc920a1ac | ||
|
|
a9557ee0fe | ||
|
|
e79eefb45e | ||
|
|
bcd73c23f0 | ||
|
|
7f286bbd79 | ||
|
|
0f1f1d756f | ||
|
|
b1b64e509d | ||
|
|
911c55887a | ||
|
|
96c3730912 | ||
|
|
dd743382cd | ||
|
|
75dbf343b0 | ||
|
|
5c4da9693e | ||
|
|
b4cd0d32cb | ||
|
|
5fb6ed7a6d | ||
|
|
cc45ee1124 | ||
|
|
46cdf00614 | ||
|
|
be842f5fa8 | ||
|
|
a3dbe7ed94 | ||
|
|
78653f2366 | ||
|
|
8dc141af42 | ||
|
|
6d422c308b | ||
|
|
d5e5afaa3f | ||
|
|
d5125ea421 | ||
|
|
48a7546865 | ||
|
|
f39993ead6 | ||
|
|
ee68fdb707 | ||
|
|
3c0d9ae8dc | ||
|
|
bc0c90e21b | ||
|
|
ab206516ad | ||
|
|
53e105cc53 | ||
|
|
4dd70c9e8f | ||
|
|
685de4c2ee | ||
|
|
dd7d09f7d0 | ||
|
|
387aa955cf | ||
|
|
fe2051ba91 | ||
|
|
d309ef5463 | ||
|
|
d81d5ed99a | ||
|
|
99f17090ed | ||
|
|
c4a83d254a | ||
|
|
a3a1889c8e | ||
|
|
f700afef73 | ||
|
|
3a6f7d50d6 | ||
|
|
8c9eb7a6a0 | ||
|
|
638dc7a996 | ||
|
|
a81eb163f2 | ||
|
|
61f1116ac9 | ||
|
|
80454e995e | ||
|
|
1cdbf86de9 | ||
|
|
44e3404852 | ||
|
|
9a0e04567e | ||
|
|
86ec5d52a5 | ||
|
|
51b53c9adf | ||
|
|
a13a91f890 | ||
|
|
308ee78499 | ||
|
|
d27d4e8f0b | ||
|
|
363ec98435 | ||
|
|
34d1d8ad4c | ||
|
|
8aa2d3315f | ||
|
|
63ac0e0857 | ||
|
|
a85fcb13a8 | ||
|
|
99bb19f7c6 | ||
|
|
0a8c4e3046 | ||
|
|
ed6c91bc28 | ||
|
|
79b667c5d3 | ||
|
|
db93988df2 | ||
|
|
2c37004e56 | ||
|
|
bed35d3c0d | ||
|
|
73b5234365 | ||
|
|
21e0f17e47 | ||
|
|
39087ac07d | ||
|
|
fdd06f471c | ||
|
|
ae729f8c2b | ||
|
|
df0312fdc4 | ||
|
|
c33eef9145 | ||
|
|
6c24d80c47 | ||
|
|
91e660798b | ||
|
|
5b8834a8d8 | ||
|
|
5b93977d55 | ||
|
|
9c6da867dc | ||
|
|
1c308ece5a | ||
|
|
aee3fb89e3 | ||
|
|
381dff420a | ||
|
|
51f642d922 | ||
|
|
e25c737901 | ||
|
|
67ed113216 | ||
|
|
e587eeeed8 | ||
|
|
5395b4df31 | ||
|
|
75f41806a7 | ||
|
|
1ade034692 | ||
|
|
6589e59adf | ||
|
|
13e039e7c4 | ||
|
|
0197a7e8b8 | ||
|
|
26704eacb6 | ||
|
|
494f2e8cd5 | ||
|
|
6082c188c2 | ||
|
|
a9a9b04861 | ||
|
|
524b76a0ae | ||
|
|
144e359bfd | ||
|
|
38a28ffc2f | ||
|
|
8c35119676 | ||
|
|
58eace81bb | ||
|
|
c70f58ea6c | ||
|
|
1adc6d95e7 | ||
|
|
234933caf8 | ||
|
|
05c2d1c16d | ||
|
|
a5bf73890a | ||
|
|
a0c5774f3b | ||
|
|
3d85d95dbc | ||
|
|
ac00a63e73 | ||
|
|
1f10e15069 | ||
|
|
d691e163c8 | ||
|
|
0aa48d2762 | ||
|
|
a6513d3d59 | ||
|
|
2e2265895b | ||
|
|
845cb29030 | ||
|
|
bb26917e2f |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Copy to .env and fill in. Only secrets belong here — a provider's base_url and
|
||||
# model are configured in reasonix.toml, where api_key_env points at these variables.
|
||||
|
||||
DEEPSEEK_API_KEY=
|
||||
MIMO_API_KEY=
|
||||
@@ -1,11 +1,28 @@
|
||||
.gitattributes text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.npmrc text eol=lf
|
||||
*.astro text eol=lf
|
||||
*.css text eol=lf
|
||||
*.go text eol=lf
|
||||
*.html text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.nsi text eol=lf
|
||||
*.plist text eol=lf
|
||||
*.py text eol=lf
|
||||
*.rs text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.sql text eol=lf
|
||||
*.sum text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
.githooks/** text eol=lf
|
||||
benchmarks/**/*.sh text eol=lf
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Go-native pre-push gate replacing the retired v1 npm hook (install: make hooks).
|
||||
# Full tests run in CI; vet is the fast, cross-platform local check.
|
||||
set -e
|
||||
go vet ./...
|
||||
@@ -0,0 +1,3 @@
|
||||
# Every pull request requests review from the maintainers below.
|
||||
# Either maintainer can review and merge any change.
|
||||
* @SivanCola @esengine
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Bug report
|
||||
description: Report a bug in DeepSeek-Reasonix
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug. Pick which version line you're on below — it gets
|
||||
labeled (`v1`/`v2`) automatically, no maintainer action needed.
|
||||
- type: dropdown
|
||||
id: version-line
|
||||
attributes:
|
||||
label: Version line
|
||||
description: Which line are you running?
|
||||
options:
|
||||
- "v2 — Go rewrite (1.x), main-v2 (active development)"
|
||||
- "v1 — Legacy TypeScript (0.x), maintenance only"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Exact version
|
||||
description: Output of `reasonix --version`.
|
||||
placeholder: e.g. 1.0.0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: A clear description of the bug, and what you expected instead.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: OS / platform
|
||||
placeholder: e.g. macOS 15.3 (arm64), Ubuntu 24.04, Windows 11
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs or output
|
||||
description: Paste any error output. Automatically formatted as code.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions & discussions
|
||||
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
|
||||
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: version-line
|
||||
attributes:
|
||||
label: Version line
|
||||
description: Which line is this for?
|
||||
options:
|
||||
- "v2 — Go rewrite (1.x), main-v2 (active development)"
|
||||
- "v1 — Legacy TypeScript (0.x), maintenance only"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem does this solve?
|
||||
description: What are you trying to do, and what gets in the way today?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How would you like it to work?
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,49 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
go:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/desktop"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
go:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/desktop/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
@@ -0,0 +1,51 @@
|
||||
# Area labels auto-applied to PRs by .github/workflows/pr-auto-label.yml
|
||||
# (actions/labeler), based on the changed file paths. Keep the area set in sync
|
||||
# with issue-auto-label.yml. severity/platform are issue-only — a PR is a change,
|
||||
# not a bug report — and v1/v2 are handled by pr-version-label.yml.
|
||||
|
||||
agent:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'internal/agent/**'
|
||||
- 'internal/control/**'
|
||||
|
||||
mcp:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'internal/plugin/**'
|
||||
- 'internal/codegraph/**'
|
||||
|
||||
config:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'internal/config/**'
|
||||
- 'internal/boot/**'
|
||||
|
||||
provider:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'internal/provider/**'
|
||||
|
||||
skills:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'internal/skill/**'
|
||||
- 'internal/tool/**'
|
||||
|
||||
tui:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'internal/cli/**'
|
||||
|
||||
desktop:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'desktop/**'
|
||||
|
||||
updater:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'desktop/cmd/sign/**'
|
||||
- 'desktop/updater*.go'
|
||||
- 'scripts/desktop-build.sh'
|
||||
- '.github/workflows/release*.yml'
|
||||
@@ -0,0 +1,19 @@
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
-
|
||||
|
||||
## Cache impact
|
||||
|
||||
Cache-impact: TODO
|
||||
Cache-guard: TODO
|
||||
System-prompt-review: N/A
|
||||
|
||||
For cache-sensitive changes, fill these lines before requesting review:
|
||||
|
||||
- `Cache-impact`: `none`, `low`, `medium`, or `high`, plus the reason.
|
||||
- `Cache-guard`: the focused guard test/command added or run, or why an existing guard covers the change.
|
||||
- `System-prompt-review`: required reviewer/approval note when provider-visible system prompt, memory prefix, output style, or skill index behavior changes.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,37 @@
|
||||
name: Cache impact guard
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main-v2]
|
||||
types: [opened, synchronize, reopened, edited, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: cache-impact-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cache-impact:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Collect PR changed files
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \
|
||||
--jq '.[].filename' > "$RUNNER_TEMP/cache-impact-files.txt"
|
||||
|
||||
- name: Check cache-sensitive PR metadata
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
CACHE_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/cache-impact-files.txt
|
||||
run: bash scripts/check-cache-impact.sh
|
||||
@@ -0,0 +1,327 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
pull_request:
|
||||
branches: [main-v2]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install and verify Linux sandbox backend
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y bubblewrap
|
||||
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1
|
||||
fi
|
||||
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
|
||||
echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
|
||||
|
||||
# Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every
|
||||
# file. gofmt output is OS-independent, so the Unix legs already cover it.
|
||||
- name: gofmt
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
# Root module only — desktop/ is a separate module with its own tooling.
|
||||
unformatted=$(gofmt -l . | grep -v '^desktop/' || true)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "These files are not gofmt-clean:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: build
|
||||
run: go build ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os != 'Windows'
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
# built around.
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os == 'Windows'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
# built around.
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 4 -timeout=3m ./...
|
||||
|
||||
race:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install and verify Linux sandbox backend
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y bubblewrap
|
||||
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1
|
||||
fi
|
||||
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
|
||||
echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
|
||||
|
||||
# The matrix never runs -race (it needs cgo); the project's concurrency
|
||||
# (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise
|
||||
# ship without race coverage.
|
||||
- name: test -race
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test -race ./...
|
||||
|
||||
desktop:
|
||||
runs-on: ubuntu-22.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
go install github.com/wailsapp/wails/v2/cmd/[email protected]
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "These files are not gofmt-clean:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: go.mod tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet -- go.mod go.sum; then
|
||||
echo "desktop/go.mod or go.sum is stale - run 'cd desktop && go mod tidy' and commit."
|
||||
git diff -- go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# WebKitGTK 4.0 toolchain (pinned to ubuntu-22.04; no webkit2_41 tag).
|
||||
- name: Install Linux build deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
wails generate module
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir frontend build
|
||||
|
||||
- name: vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.12.2
|
||||
working-directory: desktop
|
||||
args: --timeout=5m
|
||||
|
||||
- name: build
|
||||
run: go build ./...
|
||||
|
||||
- name: test
|
||||
run: go test ./...
|
||||
|
||||
# Desktop project-root matching is case-insensitive only on Windows
|
||||
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
|
||||
# regression tests for that contract are named *OnWindows and can never
|
||||
# run on the ubuntu leg above.
|
||||
desktop-windows:
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
go install github.com/wailsapp/wails/v2/cmd/[email protected]
|
||||
|
||||
# go:embed of frontend/dist needs a built frontend before the package
|
||||
# compiles, same as the ubuntu desktop leg.
|
||||
- name: Build frontend
|
||||
run: |
|
||||
wails generate module
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir frontend build
|
||||
|
||||
- name: test (Windows desktop and update helper)
|
||||
timeout-minutes: 15
|
||||
run: go test ./...
|
||||
|
||||
- name: Install NSIS
|
||||
run: pwsh -NoProfile -File ../scripts/install-nsis.ps1
|
||||
|
||||
- name: Build Windows installer and portable archive
|
||||
timeout-minutes: 20
|
||||
run: ../scripts/desktop-build.sh windows/amd64 v0.0.0-ci canary
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.12.2
|
||||
args: --timeout=5m
|
||||
|
||||
- name: release workflow contracts
|
||||
run: |
|
||||
go run github.com/rhysd/actionlint/cmd/[email protected] \
|
||||
-ignore 'label "windows-11-arm" is unknown' \
|
||||
.github/workflows/release-stable.yml \
|
||||
.github/workflows/release.yml \
|
||||
.github/workflows/release-npm.yml \
|
||||
.github/workflows/release-desktop.yml
|
||||
bash scripts/release-workflows.test.sh
|
||||
|
||||
# The site/ auth client has security-sensitive redirect-validation logic
|
||||
# (safeNext) covered by node:test unit tests. Those tests use only Node
|
||||
# builtins, so no `npm install` is needed — run them directly on every PR so
|
||||
# a regression in redirect validation fails the build instead of shipping.
|
||||
site:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: site
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: test
|
||||
run: npm test
|
||||
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # informational — stdlib vulns need a Go patch release
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: govulncheck
|
||||
run: govulncheck ./...
|
||||
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: test with coverage
|
||||
run: go test -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: upload coverage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.out
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,40 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main-v2"]
|
||||
pull_request:
|
||||
branches: ["main-v2"]
|
||||
schedule:
|
||||
- cron: "27 3 * * 1"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: actions
|
||||
build-mode: none
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -1,27 +1,30 @@
|
||||
name: Deploy identity and remote services
|
||||
name: Deploy accounts worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/accounts/**'
|
||||
- '.github/workflows/deploy-accounts-worker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-identity-and-remote-services
|
||||
group: deploy-accounts-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy-accounts:
|
||||
if: github.ref == 'refs/heads/platform'
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -32,9 +35,6 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
pnpm db:apply:remote
|
||||
npx wrangler deploy
|
||||
- name: Sync RESEND_API_KEY to the worker
|
||||
working-directory: workers/accounts
|
||||
@@ -47,57 +47,3 @@ jobs:
|
||||
else
|
||||
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
|
||||
fi
|
||||
- name: Sync remote gateway secret
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
REMOTE_GATEWAY_TOKEN: ${{ secrets.REMOTE_GATEWAY_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$REMOTE_GATEWAY_TOKEN" ]; then
|
||||
echo "REMOTE_GATEWAY_TOKEN must be configured before enabling remote access."
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$REMOTE_GATEWAY_TOKEN" | npx wrangler secret put REMOTE_GATEWAY_TOKEN
|
||||
- name: Sync feedback account secret
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FEEDBACK_ACCOUNT_SECRET: ${{ secrets.FEEDBACK_ACCOUNT_SECRET }}
|
||||
run: |
|
||||
if [ -n "$FEEDBACK_ACCOUNT_SECRET" ]; then
|
||||
printf '%s' "$FEEDBACK_ACCOUNT_SECRET" | npx wrangler secret put FEEDBACK_ACCOUNT_SECRET
|
||||
else
|
||||
echo "FEEDBACK_ACCOUNT_SECRET not set in repo secrets; feedback account linking stays unavailable."
|
||||
fi
|
||||
|
||||
deploy-remote-gateway:
|
||||
needs: deploy-accounts
|
||||
if: github.ref == 'refs/heads/platform'
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: workers/remote-gateway/package-lock.json
|
||||
- name: Validate gateway secret
|
||||
env:
|
||||
REMOTE_GATEWAY_TOKEN: ${{ secrets.REMOTE_GATEWAY_TOKEN }}
|
||||
run: test -n "$REMOTE_GATEWAY_TOKEN"
|
||||
- name: Validate and deploy
|
||||
working-directory: workers/remote-gateway
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npm ci
|
||||
npm test
|
||||
npm run typecheck
|
||||
npx wrangler deploy
|
||||
- name: Sync gateway secret
|
||||
working-directory: workers/remote-gateway
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
REMOTE_GATEWAY_TOKEN: ${{ secrets.REMOTE_GATEWAY_TOKEN }}
|
||||
run: printf '%s' "$REMOTE_GATEWAY_TOKEN" | npx wrangler secret put REMOTE_GATEWAY_TOKEN
|
||||
@@ -1,23 +1,12 @@
|
||||
name: Deploy crash worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/crash-report/**'
|
||||
- '.github/workflows/deploy-crash-worker.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
firebase_data_action:
|
||||
description: Firebase crash history operation (migration actions do not deploy the Worker)
|
||||
required: true
|
||||
default: none
|
||||
type: choice
|
||||
options:
|
||||
- none
|
||||
- dry-run
|
||||
- apply
|
||||
- verify-only
|
||||
confirmation:
|
||||
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -28,150 +17,23 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/platform' && inputs.firebase_data_action == 'none'
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Apply and verify diagnostics v2 D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:diagnostics-v2
|
||||
- name: Apply and verify Firebase crash D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:firebase-crash
|
||||
- name: Apply dashboard performance indexes
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:dashboard-indexes
|
||||
- name: Apply telemetry queue receipts schema
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npx wrangler d1 execute reasonix-crash --remote --file=migrate-telemetry-queue.sql
|
||||
- name: Apply and verify feedback D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback.sql
|
||||
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ('feedback','feedback_quota','feedback_status_created','feedback_install_created','feedback_status_version','feedback_quota_day')")
|
||||
for name in feedback feedback_quota feedback_status_created feedback_install_created feedback_status_version feedback_quota_day; do
|
||||
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
|
||||
done
|
||||
- name: Apply and verify feedback triage D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-triage.sql
|
||||
names="feedback_releases feedback_releases_install feedback_replies feedback_blocks feedback_trust feedback_public_images feedback_config feedback_audit feedback_replies_receipt feedback_replies_unhandled feedback_blocks_expires feedback_public_images_receipt feedback_audit_at feedback_install_status_updated feedback_reply_attachments feedback_reply_attachments_receipt"
|
||||
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
|
||||
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
|
||||
for name in $names; do
|
||||
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
|
||||
done
|
||||
echo "Trusted installs (hash prefix only) for review:"
|
||||
npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT substr(install_hash,1,8) AS prefix, created_at, expires_at FROM feedback_trust ORDER BY created_at LIMIT 200"
|
||||
- name: Apply and verify feedback adoption D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-adoptions.sql
|
||||
names="feedback_adoptions feedback_adoptions_install feedback_adoptions_item feedback_level_state"
|
||||
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
|
||||
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
|
||||
for name in $names; do
|
||||
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
|
||||
done
|
||||
- name: Apply and verify feedback account link D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-account-links.sql
|
||||
names="feedback_account_links feedback_account_links_account"
|
||||
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
|
||||
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
|
||||
for name in $names; do
|
||||
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
|
||||
done
|
||||
- name: Apply and verify feedback contribution board D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-board.sql
|
||||
names="feedback_board_optin feedback_board_delisted"
|
||||
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
|
||||
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
|
||||
for name in $names; do
|
||||
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
|
||||
done
|
||||
- name: Apply Studio telemetry schema
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npx wrangler d1 execute reasonix-crash --remote --file=migrate-studio-telemetry.sql
|
||||
- name: Apply isolated telemetry database schema
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:telemetry-db
|
||||
- name: Apply registry performance indexes and install rollup
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:registry-indexes
|
||||
- name: Sync Firebase crash secrets
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
|
||||
echo "Firebase crash secrets must be either all configured or all absent."
|
||||
exit 1
|
||||
fi
|
||||
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
|
||||
- name: Deploy
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
npx wrangler deploy
|
||||
- name: Sync feedback secrets
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FEEDBACK_TOKEN_SECRET: ${{ secrets.FEEDBACK_TOKEN_SECRET }}
|
||||
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
|
||||
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
|
||||
FEEDBACK_ACCOUNT_SECRET: ${{ secrets.FEEDBACK_ACCOUNT_SECRET }}
|
||||
run: |
|
||||
node -e 'process.stdout.write(JSON.stringify({FEEDBACK_TOKEN_SECRET:process.env.FEEDBACK_TOKEN_SECRET||null,FEEDBACK_ADMIN_TOKEN:process.env.FEEDBACK_ADMIN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null,FEEDBACK_ACCOUNT_SECRET:process.env.FEEDBACK_ACCOUNT_SECRET||null}))' | npx wrangler secret bulk
|
||||
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
|
||||
# sentinel can push alerts (see runIngestSentinel). Managed here so
|
||||
# nobody needs local Cloudflare credentials; when the repo secret is
|
||||
@@ -188,67 +50,3 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
|
||||
|
||||
migrate-firebase-data:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
|
||||
runs-on: ubuntu-latest
|
||||
environment: canary
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Require the protected production branch
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/platform" ]; then
|
||||
echo "Firebase crash data migration must run from platform."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Run Firebase crash history operation
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
|
||||
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 4 ]; then
|
||||
echo "Cloudflare and Firebase migration secrets must all be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$FIREBASE_DATA_ACTION" in
|
||||
dry-run)
|
||||
npm run migrate:firebase-data
|
||||
;;
|
||||
apply)
|
||||
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
|
||||
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
|
||||
exit 1
|
||||
fi
|
||||
npm run migrate:firebase-data -- --apply
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
verify-only)
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Firebase crash data action."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,6 +1,11 @@
|
||||
name: Deploy forum worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/forum/**'
|
||||
- '.github/workflows/deploy-forum-worker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -12,16 +17,14 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/platform'
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -32,7 +35,4 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
|
||||
npx wrangler deploy
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Deploy ops events relay
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-ops-events
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/platform'
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/ops-events/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/ops-events
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Deploy
|
||||
working-directory: workers/ops-events
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npx wrangler deploy
|
||||
- name: Sync secrets
|
||||
working-directory: workers/ops-events
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
GITHUB_WEBHOOK_SECRET: ${{ secrets.OPS_GITHUB_WEBHOOK_SECRET }}
|
||||
OPS_LISTEN_TOKEN: ${{ secrets.OPS_LISTEN_TOKEN }}
|
||||
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
|
||||
run: |
|
||||
node -e 'process.stdout.write(JSON.stringify({GITHUB_WEBHOOK_SECRET:process.env.GITHUB_WEBHOOK_SECRET||null,OPS_LISTEN_TOKEN:process.env.OPS_LISTEN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null}))' | npx wrangler secret bulk
|
||||
@@ -0,0 +1,160 @@
|
||||
name: e2e-bot
|
||||
|
||||
# Comment "/e2e" (fixed suite) or "/e2e diff" (generate tests for the PR's diff)
|
||||
# on a pull request to run the e2e benchmark against the real provider and post a
|
||||
# report back. Gated to trusted authors: the job checks out PR-head code and runs
|
||||
# it with the provider API key, so only the repo owner, members, and collaborators
|
||||
# may trigger it.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: e2e-bot-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/e2e') &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
# Running PR-head code with the provider secret is gated twice: the author_
|
||||
# association check above, and this deployment environment. Configure the
|
||||
# `e2e-bot` environment with required reviewers in repo settings to force a
|
||||
# human approval per run (actions/untrusted-checkout-toctou).
|
||||
environment: e2e-bot
|
||||
steps:
|
||||
- name: Acknowledge
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id, content: 'eyes',
|
||||
});
|
||||
|
||||
# Default-branch checkout: this is where the harness (cmd/e2ebench), the
|
||||
# suite, and a run --metrics-capable agent live.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Build harness + fallback agent from the default branch
|
||||
# Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken
|
||||
# its own grader or tests. The agent is rebuilt from the PR head below; this
|
||||
# main-v2 build is only the fallback for heads that predate `run --metrics`.
|
||||
run: |
|
||||
go build -o "$RUNNER_TEMP/reasonix-base" ./cmd/reasonix
|
||||
go build -o "$RUNNER_TEMP/e2ebench" ./cmd/e2ebench
|
||||
cp -r benchmarks/e2e "$RUNNER_TEMP/suite"
|
||||
|
||||
- name: Check out the PR head
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Pin to the head commit resolved now and check it out detached, not the
|
||||
# mutable PR ref: a force-push mid-run can't swap in different code after
|
||||
# the trusted-author gate passed.
|
||||
run: |
|
||||
SHA=$(gh pr view ${{ github.event.issue.number }} --json headRefOid -q .headRefOid)
|
||||
git fetch -q origin "$SHA"
|
||||
git checkout -q --detach "$SHA"
|
||||
|
||||
- name: Build the agent from the PR head
|
||||
# The whole point of the bot is to drive the PR's code, not main-v2's. Fall
|
||||
# back to the main-v2 build only when the PR head can't yield a
|
||||
# run --metrics-capable binary (build break or predates the flag).
|
||||
id: agent
|
||||
run: |
|
||||
bin="$RUNNER_TEMP/reasonix-base"
|
||||
src="main-v2 fallback (PR head lacks run --metrics)"
|
||||
if go build -o "$RUNNER_TEMP/reasonix-pr" ./cmd/reasonix \
|
||||
&& "$RUNNER_TEMP/reasonix-pr" run -h 2>&1 | grep -q -- '-metrics'; then
|
||||
bin="$RUNNER_TEMP/reasonix-pr"
|
||||
src="PR head ($(git rev-parse --short HEAD))"
|
||||
fi
|
||||
echo "bin=$bin" >> "$GITHUB_OUTPUT"
|
||||
echo "src=$src" >> "$GITHUB_OUTPUT"
|
||||
echo "agent under test: $src"
|
||||
|
||||
- name: Write provider config
|
||||
# User config covers suite tasks (they run in temp dirs); the repo-root copy
|
||||
# covers diff mode (the agent runs in the repo root, where project config wins).
|
||||
run: |
|
||||
mkdir -p ~/.config/reasonix
|
||||
cat > /tmp/reasonix-e2e.toml <<EOF
|
||||
default_model = "e2e"
|
||||
|
||||
[[providers]]
|
||||
name = "e2e"
|
||||
kind = "openai"
|
||||
base_url = "${{ vars.REASONIX_E2E_BASE_URL || 'https://api.deepseek.com' }}"
|
||||
model = "${{ vars.REASONIX_E2E_MODEL || 'deepseek-v4-flash' }}"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
context_window = 20000 # small so the suite actually exercises compaction + cache churn
|
||||
|
||||
[providers.price]
|
||||
cache_hit = ${{ vars.REASONIX_E2E_PRICE_CACHE_HIT || '0.02' }}
|
||||
input = ${{ vars.REASONIX_E2E_PRICE_INPUT || '1' }}
|
||||
output = ${{ vars.REASONIX_E2E_PRICE_OUTPUT || '2' }}
|
||||
currency = "${{ vars.REASONIX_E2E_PRICE_CURRENCY || '¥' }}"
|
||||
|
||||
[permissions]
|
||||
mode = "allow"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
EOF
|
||||
cp /tmp/reasonix-e2e.toml ~/.config/reasonix/config.toml
|
||||
cp /tmp/reasonix-e2e.toml ./reasonix.toml
|
||||
|
||||
- name: Run e2e
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
if [ -z "$DEEPSEEK_API_KEY" ]; then
|
||||
printf 'missing DEEPSEEK_API_KEY secret\n\nAdd a repository secret named DEEPSEEK_API_KEY to enable the e2e bot.\n' > report.md
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s' "$COMMENT_BODY" | grep -q '/e2e[[:space:]]\+diff'; then
|
||||
ATTEMPTS=$(printf '%s' "$COMMENT_BODY" | sed -nE 's@.*/e2e[[:space:]]+diff[[:space:]]+x([0-9]+).*@\1@p' | head -1)
|
||||
[ -z "$ATTEMPTS" ] && ATTEMPTS=1
|
||||
[ "$ATTEMPTS" -gt 5 ] && ATTEMPTS=5
|
||||
BASE_REF=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName)
|
||||
git fetch -q origin "$BASE_REF"
|
||||
BASE=$(git merge-base "origin/$BASE_REF" HEAD)
|
||||
"$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
|
||||
else
|
||||
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
|
||||
fi
|
||||
|
||||
- name: Post report
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TRIGGER_USER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
printf '\n> agent: %s · triggered by @%s\n' "${{ steps.agent.outputs.src }}" "$TRIGGER_USER" >> report.md
|
||||
gh pr comment ${{ github.event.issue.number }} --body-file report.md
|
||||
|
||||
- name: Report failure
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh pr comment ${{ github.event.issue.number }} --body "🤖 e2e bot failed — see the [run log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Auto-label issues
|
||||
|
||||
# Classify new issues into area / platform / severity labels using the DeepSeek
|
||||
# API (the project's own model). The model is constrained to a fixed label set —
|
||||
# anything it returns outside the set is dropped — so it can't invent labels.
|
||||
# Issues it can't place into any area get `needs-triage` for a human to look at.
|
||||
# Version (v1/v2) labels are handled separately by issue-version-label.yml.
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-autolabel-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
classify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v9
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
with:
|
||||
script: |
|
||||
const AREA = ['agent','mcp','config','updater','provider','desktop','tui','skills','rendering'];
|
||||
const PLATFORM = ['windows','macos','linux'];
|
||||
const SEVERITY = ['crash','data-loss','security'];
|
||||
const ALLOWED = new Set([...AREA, ...PLATFORM, ...SEVERITY]);
|
||||
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title || '';
|
||||
const body = (issue.body || '').slice(0, 4000);
|
||||
|
||||
const system = [
|
||||
'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with a Wails desktop app and a terminal UI.',
|
||||
'Pick labels ONLY from these fixed sets. Never invent labels.',
|
||||
'area (0-2, the affected subsystem):',
|
||||
' agent: core agent loop / tool-calling / reasoning',
|
||||
' mcp: MCP servers, plugins, codegraph',
|
||||
' config: configuration, setup wizard, .toml/.env',
|
||||
' updater: auto-update, installer, release packaging',
|
||||
' provider: model providers, model selection/switching',
|
||||
' desktop: Wails desktop GUI',
|
||||
' tui: terminal UI / CLI',
|
||||
' skills: skills system',
|
||||
' rendering: terminal rendering / flicker / repaint',
|
||||
'platform (only if clearly specific to one OS): windows, macos, linux',
|
||||
'severity (only if clearly applicable):',
|
||||
' crash: app crashes, hangs, or freezes',
|
||||
' data-loss: loss of sessions, config, or history',
|
||||
' security: credential/secret exposure or a security flaw',
|
||||
'Be conservative: omit a label when unsure. The issue may be in Chinese.',
|
||||
'Reply with JSON only: {"area":[],"platform":[],"severity":[]}',
|
||||
].join('\n');
|
||||
|
||||
let labels = [];
|
||||
try {
|
||||
const res = await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-chat',
|
||||
temperature: 0,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: `Title: ${title}\n\nBody:\n${body}` },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
core.warning(`DeepSeek API ${res.status}: ${await res.text()}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const parsed = JSON.parse(data.choices[0].message.content);
|
||||
labels = [...(parsed.area || []), ...(parsed.platform || []), ...(parsed.severity || [])]
|
||||
.filter(l => ALLOWED.has(l));
|
||||
} catch (e) {
|
||||
core.warning(`Classification failed: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!labels.some(l => AREA.includes(l))) labels.push('needs-triage');
|
||||
|
||||
if (labels.length) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: issue.number,
|
||||
labels,
|
||||
});
|
||||
core.info(`Applied: ${labels.join(', ')}`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Label issue version
|
||||
|
||||
# Issue forms can't let a reporter set a label directly (that needs triage rights),
|
||||
# so the bug/feature forms carry a "Version line" dropdown and this workflow reads
|
||||
# the submitted value and applies the matching v1/v2 label on their behalf.
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-version-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.issue.body || '';
|
||||
// Pull the "Version line" section out of the rendered issue form.
|
||||
const section = body.split(/^###\s+/m).find(s => /^Version line/i.test(s)) || '';
|
||||
const m = section.match(/\bv([12])\b/i);
|
||||
if (!m) {
|
||||
core.info('No version line found; nothing to label.');
|
||||
return;
|
||||
}
|
||||
const choice = 'v' + m[1];
|
||||
const other = choice === 'v2' ? 'v1' : 'v2';
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [choice],
|
||||
});
|
||||
// Keep v1/v2 mutually exclusive in case an edit flipped the choice.
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: other,
|
||||
});
|
||||
} catch (e) {
|
||||
core.info(`No ${other} label to remove.`);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
name: MCP catalog
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "cmd/mcp-catalog/**"
|
||||
- "internal/mcpcatalog/**"
|
||||
- "catalog/mcp/**"
|
||||
- ".github/workflows/mcp-catalog.yml"
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- "cmd/mcp-catalog/**"
|
||||
- "internal/mcpcatalog/**"
|
||||
- "catalog/mcp/**"
|
||||
- ".github/workflows/mcp-catalog.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Validate schema, duplicate entries, reader declarations, and bundled signature
|
||||
run: |
|
||||
go test ./internal/mcpcatalog
|
||||
go run ./cmd/mcp-catalog validate \
|
||||
--index internal/mcpcatalog/catalog-v1.json \
|
||||
--signature internal/mcpcatalog/catalog-v1.json.minisig \
|
||||
--packages catalog/mcp/packages
|
||||
|
||||
sign:
|
||||
# A manually selected feature branch must never be able to publish a new
|
||||
# trust root payload. Catalog releases only run from the protected branch.
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main-v2'
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Sign catalog with the dedicated release secret
|
||||
env:
|
||||
MCP_CATALOG_MINISIGN_PRIVATE_KEY: ${{ secrets.MCP_CATALOG_MINISIGN_PRIVATE_KEY }}
|
||||
MCP_CATALOG_MINISIGN_PASSWORD: ${{ secrets.MCP_CATALOG_MINISIGN_PASSWORD }}
|
||||
run: |
|
||||
go run ./cmd/mcp-catalog sign \
|
||||
--index internal/mcpcatalog/catalog-v1.json \
|
||||
--output index.json.minisig
|
||||
cp internal/mcpcatalog/catalog-v1.json index.json
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mcp-catalog-v1-${{ github.run_id }}
|
||||
path: |
|
||||
index.json
|
||||
index.json.minisig
|
||||
if-no-files-found: error
|
||||
- name: Publish the signed catalog to the Reasonix R2 origin
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$AWS_ACCESS_KEY_ID"
|
||||
test -n "$AWS_SECRET_ACCESS_KEY"
|
||||
test -n "$R2_ACCOUNT_ID"
|
||||
test -n "$R2_BUCKET"
|
||||
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
sequence="$(jq -er '.sequence' index.json)"
|
||||
if aws s3 cp "s3://${R2_BUCKET}/plugins/catalog/v1/index.json" current-index.json --endpoint-url "$endpoint"; then
|
||||
current_sequence="$(jq -er '.sequence' current-index.json)"
|
||||
if [ "$sequence" -lt "$current_sequence" ]; then
|
||||
echo "catalog sequence rollback: new=$sequence current=$current_sequence" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$sequence" -eq "$current_sequence" ] && ! cmp -s index.json current-index.json; then
|
||||
echo "catalog content changed without increasing sequence $sequence" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
prefix="plugins/catalog/v1/history/${sequence}"
|
||||
aws s3 cp index.json "s3://${R2_BUCKET}/${prefix}/index.json" \
|
||||
--endpoint-url "$endpoint" --content-type application/json --cache-control "public, max-age=31536000, immutable"
|
||||
aws s3 cp index.json.minisig "s3://${R2_BUCKET}/${prefix}/index.json.minisig" \
|
||||
--endpoint-url "$endpoint" --content-type application/octet-stream --cache-control "public, max-age=31536000, immutable"
|
||||
# Readers retain their last-known-good copy across the brief pair
|
||||
# update; a mismatched pair therefore fails closed instead of running.
|
||||
aws s3 cp index.json "s3://${R2_BUCKET}/plugins/catalog/v1/index.json" \
|
||||
--endpoint-url "$endpoint" --content-type application/json --cache-control "public, max-age=300"
|
||||
aws s3 cp index.json.minisig "s3://${R2_BUCKET}/plugins/catalog/v1/index.json.minisig" \
|
||||
--endpoint-url "$endpoint" --content-type application/octet-stream --cache-control "public, max-age=300"
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Deploy site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths: ['site/**', 'release-notes/**', '.github/workflows/pages.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- name: Build
|
||||
working-directory: site
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: site/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- id: deploy
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -1,89 +0,0 @@
|
||||
name: Platform CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [platform]
|
||||
pull_request:
|
||||
branches: [platform]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: platform-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
accounts:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workers/accounts
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/accounts/pnpm-lock.yaml
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test
|
||||
- run: pnpm typecheck
|
||||
|
||||
forum:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workers/forum
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/forum/pnpm-lock.yaml
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test
|
||||
- run: pnpm typecheck
|
||||
|
||||
crash-report:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workers/crash-report
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
- run: npx wrangler deploy --dry-run
|
||||
|
||||
remote-gateway:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workers/remote-gateway
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: workers/remote-gateway/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npm run typecheck
|
||||
- run: npx wrangler deploy --dry-run
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Auto-label PRs
|
||||
|
||||
# Apply area labels to PRs based on changed paths (see .github/labeler.yml).
|
||||
# Complements the AI-based issue labeler (issue-auto-label.yml). Version (v1/v2)
|
||||
# labels are handled separately by pr-version-label.yml.
|
||||
#
|
||||
# pull_request_target runs in the base repo context so it has the write token
|
||||
# needed to label PRs from forks; actions/labeler only reads the diff file list
|
||||
# (it never checks out or runs PR code), so this is safe.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-autolabel-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
sync-labels: false
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Label PR version
|
||||
|
||||
# Contributors (especially from forks) can't set labels themselves — that needs
|
||||
# triage rights. A PR's version line is fully determined by its base branch, so
|
||||
# this reads base.ref and applies v1/v2 on their behalf. pull_request_target runs
|
||||
# in the base repo's context so it has write access even for fork PRs; it only
|
||||
# reads trusted metadata (the base ref) and never checks out or runs PR code.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-version-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const base = context.payload.pull_request.base.ref;
|
||||
const label = base === 'v1' ? 'v1'
|
||||
: base === 'main-v2' ? 'v2'
|
||||
: null;
|
||||
if (!label) {
|
||||
core.info(`Base branch '${base}' isn't a release line; skipping.`);
|
||||
return;
|
||||
}
|
||||
const other = label === 'v2' ? 'v1' : 'v2';
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: [label],
|
||||
});
|
||||
// Drop the other line's label if the base was changed after opening.
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
name: other,
|
||||
});
|
||||
} catch (e) {
|
||||
core.info(`No ${other} label to remove.`);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Prepare release notes
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version without a tag prefix (for example 1.18.0)"
|
||||
required: true
|
||||
type: string
|
||||
from_tag:
|
||||
description: "Previous release tag (defaults to the newest catalog entry)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
draft:
|
||||
name: generate reviewable bilingual draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Prepare the release-notes branch
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
branch="release-notes/v${VERSION#v}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch" main-v2
|
||||
git checkout -b "$branch" --track "origin/$branch"
|
||||
git merge --no-edit origin/main-v2
|
||||
else
|
||||
git checkout -b "$branch"
|
||||
fi
|
||||
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate and validate release notes
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
DEEPSEEK_MODEL: deepseek-v4-pro
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
FROM_TAG: ${{ inputs.from_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=(--version "$VERSION")
|
||||
if [ -n "$FROM_TAG" ]; then args+=(--from "$FROM_TAG"); fi
|
||||
node scripts/generate-release-notes.mjs "${args[@]}"
|
||||
node scripts/release-notes.mjs validate
|
||||
node --test scripts/release-notes.test.mjs
|
||||
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
|
||||
|
||||
- name: Open or update the review PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add release-notes/releases.json
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "docs(release): prepare v${VERSION#v} notes" \
|
||||
-m "Generate a bilingual, product-focused draft from merged pull request metadata. The reviewed catalog will drive the website, desktop app, and GitHub releases."
|
||||
fi
|
||||
git push -u origin "$RELEASE_NOTES_BRANCH"
|
||||
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
|
||||
gh pr create \
|
||||
--base main-v2 \
|
||||
--head "$RELEASE_NOTES_BRANCH" \
|
||||
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
|
||||
--body-file /tmp/release-notes.md
|
||||
fi
|
||||
@@ -0,0 +1,590 @@
|
||||
name: Release desktop
|
||||
|
||||
# Desktop (Wails) release line. Stable releases are called by
|
||||
# release-stable.yml after its single GitHub environment approval. Direct tag
|
||||
# pushes are retained only for desktop prereleases so they can still ship
|
||||
# independently.
|
||||
#
|
||||
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
|
||||
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
|
||||
# latest.json manifest is generated, and everything is published to a GitHub
|
||||
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
|
||||
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
|
||||
#
|
||||
# The same build/sign/manifest pipeline serves two channels. An orchestrated
|
||||
# `desktop-v*` tag (or manual stable recovery) publishes a GitHub release and
|
||||
# moves R2's latest/ pointer. A manual `channel: canary` dispatch builds with
|
||||
# -X main.channel=canary
|
||||
# and uploads only to R2's canary/ pointer — no GitHub release, so canary never
|
||||
# shows on the releases page and never touches latest/.
|
||||
on:
|
||||
push:
|
||||
tags: ["desktop-v*-*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Release channel"
|
||||
type: choice
|
||||
options: [stable, canary]
|
||||
default: stable
|
||||
tag:
|
||||
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
|
||||
required: false
|
||||
type: string
|
||||
base_version:
|
||||
description: "canary: intended next stable version, e.g. 1.5.0"
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Release channel selected by the approved orchestrator"
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: "Existing desktop tag to publish"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
base_version:
|
||||
description: "Base version used for canary builds"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
approved_cli_tag:
|
||||
description: "Stable CLI tag recorded by the approved orchestrator"
|
||||
required: true
|
||||
type: string
|
||||
approved_sha:
|
||||
description: "Immutable commit recorded by the approved orchestrator"
|
||||
required: true
|
||||
type: string
|
||||
orchestrated:
|
||||
description: "True only when called by release-stable.yml after approval"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write # create the release and upload artifacts
|
||||
|
||||
jobs:
|
||||
orchestration-guard:
|
||||
name: verify approved orchestrator
|
||||
if: ${{ inputs.orchestrated }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
- name: Verify caller and approved release ref
|
||||
env:
|
||||
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
|
||||
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }}
|
||||
CALLER_EVENT_NAME: ${{ github.event_name }}
|
||||
CALLER_REF: ${{ github.ref }}
|
||||
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
|
||||
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
CALLER_SHA: ${{ github.sha }}
|
||||
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
VERIFY_RELEASE_CHECKOUT: false
|
||||
run: |
|
||||
bash scripts/verify-release-authorization.sh
|
||||
bash scripts/verify-release-tag.sh
|
||||
|
||||
release-gate:
|
||||
name: approve standalone desktop release
|
||||
if: ${{ !inputs.orchestrated && !(github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: echo "Approved standalone desktop release $RELEASE_TAG"
|
||||
|
||||
cache-guard:
|
||||
name: cache hit guard
|
||||
needs: [orchestration-guard, release-gate]
|
||||
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') || needs.release-gate.result == 'success'))) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Cache hit guard
|
||||
run: ./scripts/cache-guard.sh
|
||||
|
||||
build:
|
||||
name: build (${{ matrix.name }})
|
||||
needs: cache-guard
|
||||
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
|
||||
permissions:
|
||||
contents: read # checkout only; the publish job holds contents: write
|
||||
actions: read # SignPath reads run details + downloads the unsigned artifact
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# One universal macOS build (Intel + Apple Silicon) on a single arm64
|
||||
# runner — avoids the scarce/slow macos-13 Intel runner.
|
||||
- { runner: macos-14, platform: darwin/universal, name: darwin-universal }
|
||||
- { runner: windows-latest, platform: windows/amd64, name: windows-amd64 }
|
||||
- { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64 }
|
||||
- { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
# Windows Authenticode signing engages only when the SignPath token is set, so
|
||||
# forks / token-less runs still build (unsigned), mirroring the APPLE_* gate.
|
||||
HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
|
||||
# Linux: WebKitGTK 4.1 toolchain (-tags webkit2_41 in desktop-build.sh).
|
||||
# 4.1 ships from ubuntu-22.04 on and is the only one present on 24.04+/Fedora 40+.
|
||||
- name: Install Linux build deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
# Linux: nfpm builds the .deb in desktop-build.sh's linux branch. go install
|
||||
# drops it in ~/go/bin, already on PATH (same place the wails CLI lands below).
|
||||
- name: Install nfpm
|
||||
if: runner.os == 'Linux'
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/[email protected]
|
||||
|
||||
# Windows: NSIS provides makensis for `wails build -nsis`.
|
||||
- name: Install NSIS
|
||||
if: runner.os == 'Windows'
|
||||
run: pwsh -NoProfile -File scripts/install-nsis.ps1
|
||||
|
||||
# macOS: create-dmg packages the .app into a drag-to-Applications .dmg.
|
||||
- name: Install create-dmg
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install create-dmg
|
||||
|
||||
# macOS signing: import the Developer ID cert into a throwaway keychain and
|
||||
# stage the notarization key. No-ops (and the build ad-hoc signs) when the
|
||||
# APPLE_* secrets aren't set, so forks still build.
|
||||
- name: Import Apple signing certificate
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
|
||||
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
|
||||
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||
run: |
|
||||
if [ -z "$APPLE_CERT_P12" ]; then
|
||||
echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)"
|
||||
exit 0
|
||||
fi
|
||||
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
|
||||
KEYCHAIN_PASS="$(uuidgen)"
|
||||
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
|
||||
echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
|
||||
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
|
||||
# Prepend the signing keychain to the search list so codesign / find-identity see it.
|
||||
existing_keychains=()
|
||||
while IFS= read -r keychain; do
|
||||
[ -n "$keychain" ] && existing_keychains+=("$keychain")
|
||||
done < <(security list-keychains -d user | sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//')
|
||||
security list-keychains -d user -s "$KEYCHAIN" "${existing_keychains[@]}"
|
||||
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
|
||||
rm -f "$RUNNER_TEMP/cert.p12"
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/[email protected]
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: bash scripts/resolve-desktop-release.sh
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IN_CHANNEL: ${{ inputs.channel }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_BASE_VERSION: ${{ inputs.base_version }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
|
||||
- name: Build and package
|
||||
env:
|
||||
# macOS Developer ID + notarization path turns on only when all five
|
||||
# APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs.
|
||||
# Harmless on Windows/Linux runners (only the darwin branch reads these).
|
||||
HAS_APPLE_CERT: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
|
||||
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.channel }}"
|
||||
|
||||
# Authenticode-sign the Windows installer through SignPath. Runs BEFORE minisign:
|
||||
# the signature rewrites the installer's bytes, so the updater's minisign sig and
|
||||
# the manifest SHA must be computed over the signed build. SignPath pulls the file
|
||||
# from the Actions artifact API, so it must be uploaded first. Canary signs with the
|
||||
# test certificate; stable/rc sign with the Foundation release certificate, but only
|
||||
# once SIGNPATH_RELEASE_SIGNING_READY is set. The release-signing policy verifies the
|
||||
# trusted GitHub origin and intentionally retains its manual approval process as a
|
||||
# second-party control. Each Windows architecture can require its own confirmation.
|
||||
- name: Upload unsigned installer for SignPath
|
||||
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
|
||||
id: unsigned-installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unsigned-${{ matrix.name }}
|
||||
path: dist/*installer*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Submit installer for Authenticode signing
|
||||
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: DeepSeek-Reasonix
|
||||
signing-policy-slug: ${{ steps.ver.outputs.channel == 'canary' && 'test-signing' || 'release-signing' }}
|
||||
artifact-configuration-slug: windows-installer
|
||||
github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }}
|
||||
github-token: ${{ github.token }}
|
||||
wait-for-completion: true
|
||||
# Leave headroom for the manual SignPath confirmation, origin verification,
|
||||
# malware scanning, and signing.
|
||||
wait-for-completion-timeout-in-seconds: 1800
|
||||
output-artifact-directory: signed
|
||||
|
||||
- name: Replace installer with signed build
|
||||
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
|
||||
run: cp signed/*installer*.exe dist/
|
||||
|
||||
- name: Sign artifacts (minisign)
|
||||
working-directory: desktop
|
||||
env:
|
||||
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
||||
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
|
||||
run: go run ./cmd/sign sign ../dist/*
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist-${{ matrix.name }}
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: publish release
|
||||
needs: build
|
||||
if: ${{ always() && !cancelled() && needs.build.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# The stable caller has already passed the single GitHub release approval.
|
||||
# Direct prereleases/manual stable recovery pass release-gate; canaries run
|
||||
# free. Windows signing retains its separate SignPath confirmations.
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
pattern: dist-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: bash scripts/resolve-desktop-release.sh
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IN_CHANNEL: ${{ inputs.channel }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_BASE_VERSION: ${{ inputs.base_version }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
|
||||
# Generate latest.json with GitHub release download URLs; the mirror step
|
||||
# rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner.
|
||||
- name: Generate manifest
|
||||
working-directory: desktop
|
||||
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Canary never appears on the GitHub releases page; the mirror job picks up
|
||||
# the signed dist via the canary-dist artifact below. Stable publishes a
|
||||
# GitHub release as usual.
|
||||
- name: Render reviewed release notes
|
||||
if: steps.ver.outputs.channel != 'canary'
|
||||
run: node scripts/release-notes.mjs render --version "${{ steps.ver.outputs.version }}" --output /tmp/release-notes.md
|
||||
|
||||
- name: Revalidate approved release ref
|
||||
if: ${{ inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
run: bash scripts/verify-release-tag.sh
|
||||
|
||||
- name: Publish GitHub release
|
||||
if: steps.ver.outputs.channel != 'canary'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Keep the repository homepage focused on the installable desktop app;
|
||||
# the CLI release line is configured not to claim repository-wide latest.
|
||||
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --notes-file /tmp/release-notes.md)
|
||||
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
|
||||
args+=(--prerelease --latest=false)
|
||||
else
|
||||
args+=(--latest)
|
||||
fi
|
||||
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
|
||||
|
||||
- name: Upload canary dist for mirror
|
||||
if: steps.ver.outputs.channel == 'canary'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: canary-dist
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
mirror:
|
||||
name: mirror to R2
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # gh release download + compatibility manifest upload
|
||||
actions: write # dispatch pages.yml to re-bake the site version
|
||||
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
|
||||
if: ${{ always() && !cancelled() && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
|
||||
env:
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: bash scripts/resolve-desktop-release.sh
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IN_CHANNEL: ${{ inputs.channel }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_BASE_VERSION: ${{ inputs.base_version }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
|
||||
- name: Revalidate approved release ref
|
||||
if: ${{ inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
run: bash scripts/verify-release-tag.sh
|
||||
|
||||
# Canary has no GitHub release — pull the signed dist from the workflow
|
||||
# artifact. Stable pulls from the published release.
|
||||
- name: Download canary dist
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: canary-dist
|
||||
path: assets
|
||||
|
||||
- name: Download release assets
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
mkdir -p assets
|
||||
gh release download "${{ steps.ver.outputs.tag }}" -R "${{ github.repository }}" -D assets
|
||||
|
||||
# Rewrite both url and sig inside latest.json from github.com to the R2 CDN,
|
||||
# so the updater pulls the manifest AND the heavy artifacts from R2.
|
||||
- name: Rewrite latest.json URLs to R2
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
R2_PUBLIC_BASE: https://dl.reasonix.io
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
f=assets/latest.json
|
||||
jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" '
|
||||
.platforms |= with_entries(
|
||||
.value.url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
|
||||
| .value.sig |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
|
||||
)
|
||||
' "$f" > "$f.new"
|
||||
mv "$f.new" "$f"
|
||||
cat "$f"
|
||||
|
||||
- name: Configure AWS CLI for R2
|
||||
if: env.HAS_R2 == 'true'
|
||||
run: |
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set region auto
|
||||
|
||||
- name: Mirror to R2
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$ENDPOINT"
|
||||
# The pointer a release moves is its channel's alone: canary writes
|
||||
# canary/, stable writes latest/. A stable prerelease (rc) moves neither.
|
||||
if [ "${{ steps.ver.outputs.channel }}" = "canary" ]; then
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/canary/" --recursive --endpoint-url "$ENDPOINT"
|
||||
elif [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
|
||||
echo "prerelease — leaving R2 latest/ untouched"
|
||||
else
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
|
||||
fi
|
||||
|
||||
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
|
||||
# protection), so smoke the mirrored objects over the authenticated S3 API
|
||||
# instead of the public edge. This verifies the mirror landed; the public
|
||||
# edge itself is not reachable from CI and is covered by end users' traffic.
|
||||
- name: Smoke desktop release pointers
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
f=assets/latest.json
|
||||
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
|
||||
.version == $version and
|
||||
.download_page == "https://reasonix.io/?download=desktop#start" and
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
|
||||
' "$f" >/dev/null
|
||||
|
||||
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
|
||||
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null
|
||||
if [ "$CHANNEL" = "canary" ]; then
|
||||
pointer="canary"
|
||||
elif [ "$PRERELEASE" = "true" ]; then
|
||||
pointer=""
|
||||
else
|
||||
pointer="latest"
|
||||
fi
|
||||
if [ -n "$pointer" ]; then
|
||||
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
|
||||
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
|
||||
fi
|
||||
|
||||
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
|
||||
key="${asset#https://dl.reasonix.io/}"
|
||||
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
|
||||
done
|
||||
|
||||
# Best-effort probe of the release gateway — the updater's second
|
||||
# manifest source — over the same public edge and Go client UA end users
|
||||
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
|
||||
# datacenter/proxy egress gets blocked before the worker runs) and must
|
||||
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
|
||||
# lands; it is surfaced as a warning so the run shows whether the edge
|
||||
# is open. Anything else unexpected (404, 5xx, wrong version) means the
|
||||
# gateway route or pointer regressed and fails hard.
|
||||
- name: Probe public release gateway
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
echo "prerelease — no pointer moved; skipping gateway probe"
|
||||
exit 0
|
||||
fi
|
||||
chan="stable"
|
||||
[ "$CHANNEL" = "canary" ] && chan="canary"
|
||||
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
|
||||
# curl already prints 000 for a transport failure; || true keeps -e
|
||||
# from killing the step so the case below can route it.
|
||||
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
|
||||
case "$code" in
|
||||
200)
|
||||
if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
|
||||
echo "gateway serves $VERSION on $chan"
|
||||
else
|
||||
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
403)
|
||||
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
|
||||
;;
|
||||
000|"")
|
||||
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
|
||||
;;
|
||||
*)
|
||||
echo "::error::gateway returned $code for $url — route or pointer regression"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Attach desktop manifest to matching CLI release
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$VERSION" >/dev/null 2>&1; then
|
||||
gh release upload "$VERSION" assets/latest.json --clobber
|
||||
else
|
||||
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
|
||||
fi
|
||||
|
||||
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
|
||||
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
|
||||
- name: Refresh site to the new version
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Release notes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "release-notes/**"
|
||||
- "scripts/release-notes*.mjs"
|
||||
- "site/src/pages/changelog/**"
|
||||
- "site/src/components/ChangelogPage.astro"
|
||||
- "desktop/frontend/src/App.tsx"
|
||||
- "desktop/frontend/src/components/SettingsPanel.tsx"
|
||||
- "desktop/frontend/src/components/UpdateBanner.tsx"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node scripts/release-notes.mjs validate
|
||||
- run: node --test scripts/release-notes.test.mjs
|
||||
@@ -0,0 +1,194 @@
|
||||
name: Release npm
|
||||
|
||||
# npm line. Stable releases are called by release-stable.yml after its single
|
||||
# GitHub environment approval. Direct tag pushes are retained only for
|
||||
# prereleases so npm `next` can still ship independently without touching the
|
||||
# stable Homebrew cask. Bump a prerelease with:
|
||||
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
|
||||
# git push origin <tag>
|
||||
#
|
||||
# A manual workflow_dispatch can publish either an opt-in canary from the current
|
||||
# ref or recover an approved stable version from an existing tag. Canary publishes
|
||||
# to the `canary` dist-tag (npm i reasonix@canary) and never moves
|
||||
# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`,
|
||||
# any other prerelease is `next`, and a stable version is `latest` (#5822 — the
|
||||
# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
|
||||
# `npm update -g` users). The verify step below asserts the tag actually landed.
|
||||
on:
|
||||
push:
|
||||
tags: ['npm-v*-*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Publish channel"
|
||||
required: true
|
||||
default: canary
|
||||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
base_version:
|
||||
description: "Version to publish. Canary appends -canary.<run_number>; stable publishes exactly this version."
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: "stable: existing npm tag to publish (for example npm-v1.18.0)"
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Publish channel selected by the approved release orchestrator"
|
||||
required: true
|
||||
type: string
|
||||
base_version:
|
||||
description: "Version to publish"
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: "Existing npm tag to check out for stable publication"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
approved_cli_tag:
|
||||
description: "Stable CLI tag recorded by the approved orchestrator"
|
||||
required: true
|
||||
type: string
|
||||
approved_sha:
|
||||
description: "Immutable commit recorded by the approved orchestrator"
|
||||
required: true
|
||||
type: string
|
||||
orchestrated:
|
||||
description: "True only when called by release-stable.yml after approval"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
orchestration-guard:
|
||||
name: verify approved orchestrator
|
||||
if: ${{ inputs.orchestrated }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
- name: Verify caller and approved release ref
|
||||
env:
|
||||
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
|
||||
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }}
|
||||
CALLER_EVENT_NAME: ${{ github.event_name }}
|
||||
CALLER_REF: ${{ github.ref }}
|
||||
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
|
||||
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
CALLER_SHA: ${{ github.sha }}
|
||||
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
VERIFY_RELEASE_CHECKOUT: false
|
||||
run: |
|
||||
bash scripts/verify-release-authorization.sh
|
||||
bash scripts/verify-release-tag.sh
|
||||
|
||||
release-gate:
|
||||
name: approve standalone npm release
|
||||
if: ${{ !inputs.orchestrated && !(github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: echo "Approved standalone npm release $RELEASE_TAG"
|
||||
|
||||
cache-guard:
|
||||
name: cache hit guard
|
||||
needs: [orchestration-guard, release-gate]
|
||||
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') || needs.release-gate.result == 'success'))) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: ./scripts/cache-guard.sh
|
||||
|
||||
npm:
|
||||
name: publish npm packages
|
||||
needs: cache-guard
|
||||
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# The stable caller has already passed the single GitHub release approval.
|
||||
# Direct prereleases/manual stable recovery pass release-gate above; canaries
|
||||
# run free.
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
# Tag push uses the npm-v* tag. A canary dispatch synthesizes a
|
||||
# <base>-canary.<run_number> version; a manual stable dispatch publishes
|
||||
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
|
||||
IN_CHANNEL: ${{ inputs.channel }}
|
||||
IN_BASE_VERSION: ${{ inputs.base_version }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: bash scripts/resolve-npm-release.sh
|
||||
- name: Revalidate approved release ref
|
||||
if: ${{ inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
run: bash scripts/verify-release-tag.sh
|
||||
- env:
|
||||
VERSION_ARG: ${{ steps.ver.outputs.arg }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: node npm/build.mjs "$VERSION_ARG" --publish
|
||||
# Assert the dist-tag landed where build.mjs aimed it. This is the guard
|
||||
# that #5822 lacked: `latest` sat on 0.53.2 for months while stables went
|
||||
# to `next`, and nothing noticed until users got downgraded. The registry
|
||||
# applies tags asynchronously after publish, hence the poll.
|
||||
- name: Verify dist-tags
|
||||
env:
|
||||
VERSION_ARG: ${{ steps.ver.outputs.arg }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$VERSION_ARG"
|
||||
version="${version#npm-}"; version="${version#v}"
|
||||
case "$version" in
|
||||
*-canary.*) tag=canary ;;
|
||||
*-*) tag=next ;;
|
||||
*) tag=latest ;;
|
||||
esac
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)"
|
||||
if [ "$got" = "$version" ]; then
|
||||
echo "dist-tag $tag -> $got"
|
||||
exit 0
|
||||
fi
|
||||
echo "dist-tag $tag -> ${got:-<unset>}, want $version (attempt $attempt)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::npm dist-tag '$tag' never landed on $version"
|
||||
exit 1
|
||||
@@ -0,0 +1,175 @@
|
||||
name: Release stable
|
||||
run-name: Release stable ${{ inputs.tag || github.ref_name }}
|
||||
|
||||
# Stable publication has exactly one GitHub environment gate. Push the CLI,
|
||||
# npm, and desktop tags atomically; the vX.Y.Z tag starts this workflow,
|
||||
# preflight verifies that all three tags point to the current main-v2 commit,
|
||||
# and one GitHub approval releases every channel. A manual recovery runs the
|
||||
# fixed control plane from protected main-v2 while preserving an older tagged
|
||||
# candidate on main-v2 history. SignPath intentionally keeps its separate
|
||||
# manual confirmations. Prerelease and canary workflows remain independently
|
||||
# callable.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing stable CLI tag to recover (for example v1.18.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: stable-release-${{ inputs.tag || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# Reusable release workflows can only reduce caller permissions, so the
|
||||
# orchestrator grants the union needed by CLI/Desktop publication.
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: validate stable release set
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
cli_tag: ${{ steps.release.outputs.cli_tag }}
|
||||
npm_tag: ${{ steps.release.outputs.npm_tag }}
|
||||
desktop_tag: ${{ steps.release.outputs.desktop_tag }}
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# A recovery dispatch uses the fixed workflow/scripts from protected
|
||||
# main-v2. Publishers still check out the immutable approved tag SHA.
|
||||
ref: ${{ github.sha }}
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Resolve stable release
|
||||
id: release
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
ALLOW_STABLE_RECOVERY: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: bash scripts/resolve-stable-release.sh
|
||||
- name: Validate reviewed release notes
|
||||
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
|
||||
- name: Cache hit guard
|
||||
run: ./scripts/cache-guard.sh
|
||||
|
||||
authorize:
|
||||
name: approve stable release
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.approved.outputs.version }}
|
||||
cli_tag: ${{ steps.approved.outputs.cli_tag }}
|
||||
npm_tag: ${{ steps.approved.outputs.npm_tag }}
|
||||
desktop_tag: ${{ steps.approved.outputs.desktop_tag }}
|
||||
sha: ${{ steps.approved.outputs.sha }}
|
||||
steps:
|
||||
- name: Record approved release
|
||||
id: approved
|
||||
env:
|
||||
VERSION: ${{ needs.preflight.outputs.version }}
|
||||
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
|
||||
NPM_TAG: ${{ needs.preflight.outputs.npm_tag }}
|
||||
DESKTOP_TAG: ${{ needs.preflight.outputs.desktop_tag }}
|
||||
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
|
||||
run: |
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "cli_tag=$CLI_TAG"
|
||||
echo "npm_tag=$NPM_TAG"
|
||||
echo "desktop_tag=$DESKTOP_TAG"
|
||||
echo "sha=$RELEASE_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Approved stable release $VERSION at $RELEASE_SHA"
|
||||
|
||||
cli:
|
||||
name: publish CLI and Homebrew
|
||||
needs: authorize
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_sha: ${{ needs.authorize.outputs.sha }}
|
||||
orchestrated: true
|
||||
secrets: inherit
|
||||
|
||||
npm:
|
||||
name: publish npm
|
||||
needs: authorize
|
||||
uses: ./.github/workflows/release-npm.yml
|
||||
with:
|
||||
channel: stable
|
||||
base_version: ${{ needs.authorize.outputs.version }}
|
||||
tag: ${{ needs.authorize.outputs.npm_tag }}
|
||||
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_sha: ${{ needs.authorize.outputs.sha }}
|
||||
orchestrated: true
|
||||
secrets: inherit
|
||||
|
||||
desktop:
|
||||
name: publish desktop
|
||||
needs: authorize
|
||||
uses: ./.github/workflows/release-desktop.yml
|
||||
with:
|
||||
channel: stable
|
||||
tag: ${{ needs.authorize.outputs.desktop_tag }}
|
||||
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_sha: ${{ needs.authorize.outputs.sha }}
|
||||
orchestrated: true
|
||||
secrets: inherit
|
||||
|
||||
postflight:
|
||||
name: verify stable release artifacts
|
||||
needs: [authorize, cli, npm, desktop]
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require every publisher to succeed
|
||||
env:
|
||||
CLI_RESULT: ${{ needs.cli.result }}
|
||||
NPM_RESULT: ${{ needs.npm.result }}
|
||||
DESKTOP_RESULT: ${{ needs.desktop.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "::error::stable publisher result is $result, expected success"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Postflight belongs to the trusted control plane, not the old build
|
||||
# candidate, which may predate this verifier.
|
||||
ref: ${{ github.sha }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Verify public artifacts and npm latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
|
||||
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
@@ -0,0 +1,261 @@
|
||||
name: Release
|
||||
|
||||
# CLI binary line. Stable releases are called by release-stable.yml after its
|
||||
# single GitHub `release`-environment approval. Direct tag pushes are retained
|
||||
# only for CLI prereleases so they can still ship independently; those runs keep
|
||||
# their own approval gate. goreleaser auto-skips the cask upload for prereleases
|
||||
# (brew has no prerelease channel — see .goreleaser.yaml).
|
||||
on:
|
||||
push:
|
||||
tags: ['v*-*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing CLI tag to publish (for example v1.18.0-rc.1)"
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing CLI tag selected by the approved release orchestrator"
|
||||
required: true
|
||||
type: string
|
||||
approved_cli_tag:
|
||||
description: "Stable CLI tag recorded by the approved orchestrator"
|
||||
required: true
|
||||
type: string
|
||||
approved_sha:
|
||||
description: "Immutable commit recorded by the approved orchestrator"
|
||||
required: true
|
||||
type: string
|
||||
orchestrated:
|
||||
description: "True only when called by release-stable.yml after approval"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write # create the release and upload archives
|
||||
|
||||
jobs:
|
||||
orchestration-guard:
|
||||
name: verify approved orchestrator
|
||||
if: ${{ inputs.orchestrated }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
- name: Verify caller and approved release ref
|
||||
env:
|
||||
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
|
||||
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }}
|
||||
CALLER_EVENT_NAME: ${{ github.event_name }}
|
||||
CALLER_REF: ${{ github.ref }}
|
||||
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
|
||||
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
CALLER_SHA: ${{ github.sha }}
|
||||
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
VERIFY_RELEASE_CHECKOUT: false
|
||||
run: |
|
||||
bash scripts/verify-release-authorization.sh
|
||||
bash scripts/verify-release-tag.sh
|
||||
|
||||
release-gate:
|
||||
name: approve standalone CLI release
|
||||
if: ${{ !inputs.orchestrated }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: echo "Approved standalone CLI release $RELEASE_TAG"
|
||||
|
||||
cache-guard:
|
||||
name: cache hit guard
|
||||
needs: [orchestration-guard, release-gate]
|
||||
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: ./scripts/cache-guard.sh
|
||||
|
||||
goreleaser:
|
||||
name: archives + checksums + homebrew tap
|
||||
needs: cache-guard
|
||||
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# The stable caller has already passed the single GitHub release approval.
|
||||
# Standalone prereleases pass release-gate above. This job therefore must not
|
||||
# add a second GitHub environment approval.
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Render reviewed release notes
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
|
||||
- name: Revalidate approved release ref
|
||||
if: ${{ inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
run: bash scripts/verify-release-tag.sh
|
||||
- uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
# workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
|
||||
# though checkout is on the release tag. Pin GoReleaser explicitly, and
|
||||
# avoid ambiguity from the three release tags sharing one commit.
|
||||
GORELEASER_CURRENT_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
|
||||
- name: Publish product release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: gh release edit "$TAG" --notes-file /tmp/release-notes.md
|
||||
|
||||
- name: Attach desktop manifest compatibility asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
|
||||
exit 0
|
||||
fi
|
||||
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
|
||||
# protection), so read the manifest over the authenticated S3 API instead
|
||||
# of the public edge.
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set region auto
|
||||
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
jq '.download_page = "https://reasonix.io/?download=desktop#start"' latest.raw.json > latest.json
|
||||
jq -e '
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
|
||||
' latest.json >/dev/null
|
||||
gh release upload "$TAG" latest.json --clobber
|
||||
|
||||
# The compatibility asset exists for pre-v1.16 desktop updaters that
|
||||
# still poll GitHub's repository-wide latest URL. Desktop releases now
|
||||
# own that Latest badge, but this check still exercises the public fallback
|
||||
# path exactly the way those clients fetch it: anonymously, over the public
|
||||
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
|
||||
# 403s Actions egress — see the R2 note above), GitHub serves its own
|
||||
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
|
||||
# for weeks precisely because nothing exercised the public path. Retries
|
||||
# cover the release CDN propagating the freshly uploaded asset.
|
||||
- name: Smoke public compatibility manifest
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — no compatibility asset uploaded; skipping"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
|
||||
exit 0
|
||||
fi
|
||||
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
|
||||
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
|
||||
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
|
||||
exit 0
|
||||
fi
|
||||
echo "attempt $attempt failed; retrying in 10s"
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::public compatibility manifest unreachable at $url"
|
||||
exit 1
|
||||
|
||||
# A stable CLI release must never leave the npm line behind: v1.17.5
|
||||
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
|
||||
# (#5822) — every `npm update -g` user was silently downgraded to a
|
||||
# months-old version, and nothing noticed because the npm line
|
||||
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
|
||||
# stable npm tag was simply never pushed. release-npm.yml's own verify
|
||||
# step only guards runs that happen; this guard catches the run that
|
||||
# DIDN'T.
|
||||
#
|
||||
# Two distinct states, two responses (the approved orchestrator starts the
|
||||
# CLI and npm reusable workflows concurrently, and npm dist-tags propagate
|
||||
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
|
||||
# mid-release state, not a failure):
|
||||
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
|
||||
# nobody pushed the npm release at all.
|
||||
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
|
||||
# The npm job may still be publishing; release-npm.yml's verify step
|
||||
# owns asserting the dist-tag lands.
|
||||
- name: Check npm latest dist-tag freshness
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
version="${TAG#v}"
|
||||
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
|
||||
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
|
||||
if [ -n "$got" ]; then
|
||||
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
|
||||
if [ "$newest" = "$got" ]; then
|
||||
echo "npm latest -> $got (>= $version) OK"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the concurrent npm publish is likely still running or propagating. Monitor the npm job; its verify step asserts the dist-tag lands."
|
||||
exit 0
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Update acknowledgments
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '17 3 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Update README contributor tables
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/update-acknowledgments.mjs
|
||||
- name: Open pull request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: chore/update-acknowledgments
|
||||
delete-branch: true
|
||||
title: 'Update acknowledgments contributors / 更新致谢贡献者'
|
||||
commit-message: 'docs: update acknowledgments contributors'
|
||||
body: |
|
||||
Refreshes the README acknowledgments tables from the GitHub contributors API.
|
||||
|
||||
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=20&anon=1`
|
||||
- Anonymous contributors are shown without email addresses.
|
||||
labels: documentation
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Update Star History chart
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main-v2
|
||||
paths:
|
||||
- '.github/workflows/update-star-history.yml'
|
||||
- 'scripts/update-star-history.mjs'
|
||||
- 'scripts/update-star-history.test.mjs'
|
||||
schedule:
|
||||
- cron: '23 */3 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: update-star-history
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Test chart generator
|
||||
run: node --test scripts/update-star-history.test.mjs
|
||||
- name: Generate chart assets
|
||||
env:
|
||||
STAR_HISTORY_GITHUB_TOKEN: ${{ secrets.STAR_HISTORY_GITHUB_TOKEN }}
|
||||
STAR_HISTORY_OUTPUT_DIR: ${{ runner.temp }}/star-history
|
||||
run: node scripts/update-star-history.mjs
|
||||
- name: Publish chart assets
|
||||
env:
|
||||
OUTPUT_DIR: ${{ runner.temp }}/star-history
|
||||
TARGET_BRANCH: star-history
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
if git fetch origin "$TARGET_BRANCH"; then
|
||||
git switch --force-create "$TARGET_BRANCH" "origin/$TARGET_BRANCH"
|
||||
else
|
||||
git switch --create "$TARGET_BRANCH"
|
||||
fi
|
||||
|
||||
mkdir -p assets/star-history
|
||||
cp "$OUTPUT_DIR/star-history-light.svg" assets/star-history/star-history-light.svg
|
||||
cp "$OUTPUT_DIR/star-history-dark.svg" assets/star-history/star-history-dark.svg
|
||||
git add assets/star-history
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "Star History chart is already current."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit \
|
||||
-m "docs: update Star History chart" \
|
||||
-m "Automated update:
|
||||
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.
|
||||
|
||||
Verification:
|
||||
- chart generator tests passed
|
||||
- both SVG assets were rendered from timestamped stargazer data"
|
||||
git push origin HEAD:"$TARGET_BRANCH"
|
||||
+65
-8
@@ -1,16 +1,73 @@
|
||||
# Build artifacts
|
||||
/bin/
|
||||
/dist/
|
||||
/stage/
|
||||
/npm/.stage/
|
||||
/reasonix
|
||||
/reasonix.exe
|
||||
|
||||
# Website (Astro) build output
|
||||
/site/dist/
|
||||
/site/.astro/
|
||||
/site/_preview.png
|
||||
|
||||
# Go test/build scratch
|
||||
*.test
|
||||
*.out
|
||||
*.coverprofile
|
||||
coverage.out
|
||||
cpu.prof
|
||||
mem.prof
|
||||
|
||||
# Wails desktop artifacts
|
||||
/desktop/build/*
|
||||
!/desktop/build/appicon.png
|
||||
!/desktop/build/appicon.svg
|
||||
/desktop/desktop
|
||||
/desktop/frontend/dist/*
|
||||
!/desktop/frontend/dist/.gitkeep
|
||||
/desktop/frontend/sourcemaps/
|
||||
/desktop/frontend/wailsjs/
|
||||
/desktop/frontend/package.json.md5
|
||||
|
||||
# Environment / local config (reasonix.example.toml is the shared template)
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
/reasonix.toml
|
||||
/desktop/.env
|
||||
/desktop/reasonix.toml
|
||||
|
||||
# Node / frontend dependencies and caches
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
.npm/
|
||||
.wrangler/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
**/.firebase-crash-migration-state.json
|
||||
**/.firebase-crash-migration-state.json.tmp-*
|
||||
|
||||
# Project-local Reasonix state; keep the checked-in review command.
|
||||
/.reasonix/*
|
||||
!/.reasonix/commands/
|
||||
!/.reasonix/commands/review.md
|
||||
|
||||
# Stray Reasonix state written under a package dir by a test whose RealPath
|
||||
# resolves to CWD (e.g. remote bootstrap EnsureServe). Never commit these.
|
||||
internal/**/.reasonix/
|
||||
desktop/**/.reasonix/
|
||||
|
||||
# CodeGraph per-project index (rebuilt locally; never committed)
|
||||
.codegraph/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
/site/public/av/
|
||||
|
||||
benchmarks/context-maintenance-e2e/run/
|
||||
|
||||
# Local scratch
|
||||
temp/
|
||||
|
||||
# Official theme art intermediate outputs / caches
|
||||
scripts/official-theme-art/out/
|
||||
scripts/official-theme-art/__pycache__/
|
||||
@@ -0,0 +1,48 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: false
|
||||
exclude-functions:
|
||||
- (*os.File).Close
|
||||
- (io.Closer).Close
|
||||
- (*net/http.Response).Body.Close
|
||||
- golang.org/x/term.Restore
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- -ST1005 # error strings ending with punctuation — too strict for initial adoption
|
||||
- -ST1018 # Unicode format characters — intentional in TUI code
|
||||
- -QF1001 # De Morgan's law — readability preference
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
# Setup/teardown in tests routinely ignores cleanup errors.
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
# The pinned golangci-lint binary's staticcheck reports SA5011 false
|
||||
# positives on `if x == nil { t.Fatal(...) }`-guarded derefs in tests (it
|
||||
# doesn't treat t.Fatal as terminating); the same code is clean under a
|
||||
# locally-built golangci-lint. Scope the suppression to tests so SA5011
|
||||
# still guards production code.
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- staticcheck
|
||||
text: SA5011
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -0,0 +1,62 @@
|
||||
version: 2
|
||||
project_name: reasonix
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
|
||||
builds:
|
||||
- id: reasonix
|
||||
main: ./cmd/reasonix
|
||||
binary: reasonix
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X main.version={{ .Tag }}
|
||||
goos: [darwin, linux, windows]
|
||||
goarch: [amd64, arm64]
|
||||
|
||||
archives:
|
||||
- id: reasonix
|
||||
ids: [reasonix]
|
||||
name_template: "reasonix-{{ .Os }}-{{ .Arch }}"
|
||||
formats: [tar.gz]
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
checksum:
|
||||
name_template: SHA256SUMS
|
||||
algorithm: sha256
|
||||
|
||||
homebrew_casks:
|
||||
- name: reasonix
|
||||
ids: [reasonix]
|
||||
# Prereleases (v*-rc*) must not update the stable tap; brew has no separate
|
||||
# prerelease channel, so a pushed rc cask becomes the default `brew install`.
|
||||
# npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no
|
||||
# longer drags brew along — a stable `v*` cask can ship while npm stays on rc.
|
||||
skip_upload: "auto"
|
||||
repository:
|
||||
owner: esengine
|
||||
name: homebrew-reasonix
|
||||
branch: main
|
||||
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
|
||||
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
|
||||
description: "Cache-first DeepSeek coding agent for the terminal."
|
||||
commit_author:
|
||||
name: reasonix
|
||||
email: [email protected]
|
||||
hooks:
|
||||
post:
|
||||
install: |
|
||||
if OS.mac?
|
||||
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/reasonix"]
|
||||
end
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
make_latest: false
|
||||
name_template: "Reasonix CLI {{ .Tag }}"
|
||||
@@ -1 +0,0 @@
|
||||
24.20.0
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: Review a file for bugs
|
||||
argument-hint: [path]
|
||||
---
|
||||
Read $1 and list any correctness bugs or risky patterns, with file:line references, most important first. Focus: $ARGUMENTS.
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- GitHub Actions wraps every uploaded artifact in a zip, so the installer arrives
|
||||
zipped even though it is a single file — match and sign the .exe inside. -->
|
||||
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
|
||||
<zip-file>
|
||||
<pe-file-set>
|
||||
<include path="*installer*.exe" min-matches="1" max-matches="1" />
|
||||
<for-each>
|
||||
<authenticode-sign />
|
||||
</for-each>
|
||||
</pe-file-set>
|
||||
</zip-file>
|
||||
</artifact-configuration>
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
# Reasonix platform capacity contract
|
||||
|
||||
This document defines what “ready for 10,000 concurrent users” means. It is an
|
||||
acceptance contract, not a claim that the current production system has already
|
||||
passed the target.
|
||||
|
||||
## Budget and design constraints
|
||||
|
||||
- Keep recurring infrastructure within CNY 200/month at the current traffic
|
||||
level. Prefer Cloudflare managed primitives over idle servers.
|
||||
- Never make synchronous telemetry storage part of a product launch path.
|
||||
- Validate at the edge, durably enqueue, then persist asynchronously.
|
||||
- A successful HTTP response must expose whether the event was `queued`,
|
||||
`stored`, `sampled`, or `ignored` through
|
||||
`x-reasonix-telemetry-result`. HTTP 202 alone is not proof of durable intake.
|
||||
- Production load tests require owner approval. The repository probe refuses
|
||||
remote and production targets unless they are unlocked separately.
|
||||
|
||||
## Service-level objectives
|
||||
|
||||
| Path | Workload | Pass condition |
|
||||
| --- | --- | --- |
|
||||
| Telemetry admission | 10,000 distributed requests over 60 seconds | >=99.9% `queued`, p95 <=500 ms, no 5xx |
|
||||
| Telemetry drain | 10,000 queued events, batches up to 100 | backlog returns to zero within 15 minutes, no DLQ growth |
|
||||
| Identity session read | 1,000 requests/second for 5 minutes | >=99.9% success, p95 <=300 ms |
|
||||
| Registry public read | 1,000 requests/second for 5 minutes | >=99.9% success, p95 <=300 ms |
|
||||
| Admin dashboards | 20 concurrent viewers for 10 minutes | >=99% success, p95 <=2 seconds |
|
||||
|
||||
“Distributed” matters because per-IP abuse protection intentionally rejects a
|
||||
single machine long before the platform capacity limit. A staging environment
|
||||
must mirror production bindings and use isolated databases, queues, and R2.
|
||||
|
||||
## Required evidence before calling the platform stable
|
||||
|
||||
1. A saved probe report with request count, latency percentiles, HTTP statuses,
|
||||
durable/sample ratios, queue backlog peak, drain time, D1 query latency, and
|
||||
DLQ delta.
|
||||
2. A rollback or traffic-shed procedure exercised in staging.
|
||||
3. Alerts for sustained 5xx, queue backlog age, DLQ growth, D1 overload, and
|
||||
database size thresholds.
|
||||
4. Seven consecutive days without unexplained ingestion gaps after a production
|
||||
rollout.
|
||||
|
||||
## Current known gaps
|
||||
|
||||
- CLI and Studio telemetry use a verified isolated D1 read/write plane. The
|
||||
legacy copy is retained temporarily as a rollback snapshot. Legacy desktop
|
||||
telemetry is entering a dry-run-verified dual-write migration; diagnostic
|
||||
denominators are queried separately so the final cutover does not require
|
||||
cross-database SQL joins.
|
||||
- The global telemetry budget is currently 1,000 events/minute. It protects the
|
||||
database but cannot satisfy the 10,000/minute durable-intake target.
|
||||
- Queue consumption is deliberately serialized for D1. A delivery is persisted
|
||||
as one atomic D1 batch with two statements per valid envelope (at most 200
|
||||
statements for the configured 100-message queue batch). Drain throughput must
|
||||
be measured before raising the global intake budget.
|
||||
- Read replication and the Sessions API have not yet been enabled for eligible
|
||||
read-heavy paths.
|
||||
|
||||
## Local probe
|
||||
|
||||
Run the Worker locally, then run:
|
||||
|
||||
```sh
|
||||
npm run capacity:telemetry -- --requests 1000 --concurrency 50
|
||||
```
|
||||
|
||||
This probe fails when the p95 target or durable-intake ratio is missed. A local
|
||||
run validates the admission contract and tool; it is not evidence for global
|
||||
capacity. Remote runs need `--allow-remote`, and `*.reasonix.io` additionally
|
||||
needs `--allow-production` after an approved test window.
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy
|
||||
`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1)
|
||||
branch.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added a **Remote SSH** module (VS Code Remote-SSH style): a user-global
|
||||
`[remote]` host config, `reasonix remote` CLI (add/list/remove/import/test/
|
||||
connect/status/forward/serve/fs) and `/remote` slash command, an SSH transport
|
||||
with trust-on-first-use host-key verification, keepalive + exponential-backoff
|
||||
reconnect, `-L`/`-R` port forwarding, and SFTP file access. `connect`
|
||||
bootstraps a persistent `reasonix serve` on the remote host and tunnels its
|
||||
loopback port so the full agent runs remotely. The desktop app adds a
|
||||
**Settings -> Remote SSH** host manager, a remote file browser/editor, a
|
||||
port-forwarding panel, and a status-bar connection chip. Linux/macOS remotes.
|
||||
- Added `reasonix serve --port-file/--token-file/--pid-file` so a supervised
|
||||
headless serve can bind an ephemeral port and read its auth token from a file
|
||||
(keeping it out of `ps`).
|
||||
- Added Claude Code-style searchable CLI pickers for models, providers, and
|
||||
sessions, with arrow, Vim, and `Ctrl+P` / `Ctrl+N` navigation.
|
||||
- Added `-p` / `--print`, `text`, `json`, and `stream-json` output modes for
|
||||
one-shot use and automation.
|
||||
- Added session-scoped `--allowed-tools`, repeatable `--add-dir`, Claude-compatible
|
||||
permission modes, flexible `--resume [QUERY]`, and the `--copy` resume escape
|
||||
hatch.
|
||||
- Added `/status` details for the active model, effort, cache, Git state,
|
||||
background jobs, work profile, and provider balance where available.
|
||||
|
||||
### Changed
|
||||
|
||||
- Automatic Plan Mode has been retired. Plan Mode is now always entered through
|
||||
an explicit user choice, and the one-time config v5 upgrade removes legacy
|
||||
`agent.auto_plan` and `agent.auto_plan_classifier` values so upgraded users
|
||||
receive the same behavior as new users.
|
||||
- `Shift+Tab` now cycles CLI safe modes from Ask to Auto to Plan, while YOLO
|
||||
remains an independent `Ctrl+Y` toggle.
|
||||
- Model, provider, resume, and approval menus now use consistent row selection;
|
||||
slash completion, help, aliases, and dispatch share one command registry.
|
||||
- The full-screen CLI composer now uses theme-accented borders and a slim bar
|
||||
cursor by default, grows within the available terminal height, scrolls long
|
||||
drafts independently, and preserves selections across explicit image paste.
|
||||
- The persistent CLI footer now uses a responsive, theme-aware layout for
|
||||
interaction state, model, effort, localized work mode, Git identity, cache,
|
||||
context, compaction headroom, jobs, and balance. Narrow terminals move or
|
||||
compact complete groups instead of clipping labels.
|
||||
- CLI clipboard actions now separate terminal-native text paste from explicit
|
||||
image paste: `Ctrl+V` on macOS/Linux, `Alt+V` on Windows, or `/paste-image`.
|
||||
Local transcript copy verifies the native clipboard write, while SSH uses a
|
||||
clearly labelled OSC 52 fallback.
|
||||
- Runtime rebuilds after model, effort, or work-mode changes now preserve the
|
||||
conversation, session permission overrides, additional directories, and
|
||||
session lease ownership.
|
||||
- Agent execution now monitors host-observed Todo progress automatically. A
|
||||
stalled current item receives a recovery nudge after 8 tool-call rounds with
|
||||
no new completion, unique read, command, or mutation, and pauses with saved
|
||||
work after 16. Exact repeats do not renew the progress lease; real work does.
|
||||
Two-level task lists keep the single in_progress contract: the active
|
||||
sub-step is the only current item while its phase stays pending, and the
|
||||
phase becomes in_progress to sign off only after all of its sub-steps are
|
||||
completed. A level-1 sub-step with no phase header above it is rejected.
|
||||
Executor and planner rounds now use automatic progress management. Retired
|
||||
`[agent].max_steps` and `planner_max_steps` keys remain parseable for upgrades,
|
||||
but are ignored and removed by a one-time migration so stale hidden limits
|
||||
cannot truncate new behavior. One-off CLI and unattended bot limits remain.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Isolated the Windows desktop WebView2 shell from stale system proxies, so an
|
||||
exited proxy client cannot leave the embedded UI hidden during startup. If
|
||||
WebView2 still does not reach DOM-ready within 15 seconds, Reasonix now shows
|
||||
the native window with a recovery prompt instead of appearing not to launch.
|
||||
Remote Markdown images are fetched by the backend with Reasonix's proxy
|
||||
configuration instead of bypassing that proxy through the isolated WebView.
|
||||
- Restored captured-mouse right-click text paste, made composer drag selection
|
||||
copy through the verified native clipboard path, and kept non-Git footer
|
||||
telemetry left-aligned without reserving an empty data band.
|
||||
- Restored stateful MCP behavior after the v1.17.13 regression: user-added
|
||||
servers work without extra trust settings (including delivery-mode on-demand
|
||||
calls), repository-provided servers use one exact launch confirmation, and
|
||||
stdio tools reuse one persistent process so browser sessions survive across
|
||||
calls without repeated startup latency. The former trust/reverify/catalog
|
||||
management UI and CLI are removed.
|
||||
- Localized persistent-footer labels and displayed work-mode values in English,
|
||||
Simplified Chinese, and Traditional Chinese, while keeping command arguments
|
||||
stable.
|
||||
- Restored the `0.53` content boundary: model output, tool output, session
|
||||
transcripts, recovery branches, and background-job artifacts retain their
|
||||
original text instead of being rewritten by heuristic secret redaction.
|
||||
Credential masking remains in key-entry summaries and explicit diagnostic or
|
||||
session-cleanup paths. Transcript-bearing session/job sidecars are kept
|
||||
private (`0600`, with private job directories), and the retired
|
||||
`redact_tool_output` setting is removed with a one-time upgrade notice.
|
||||
|
||||
## [1.0.0] — 2026-06-03
|
||||
|
||||
First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x`
|
||||
TypeScript line; a new codebase that becomes the default (`main-v2`).
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Go kernel**: a single static binary (CGO-free), cross-compiled for
|
||||
darwin/linux/windows on amd64 + arm64. Distributed via npm (the package wraps
|
||||
the native binary), Homebrew (`esengine/reasonix` tap), and release archives;
|
||||
no Node runtime needed to run it.
|
||||
- **Agent core**: the loop, built-in tools (read/write/edit/multi_edit/glob/grep/
|
||||
ls/bash/web_fetch/todo_write), permission gate, sandboxed bash, and the
|
||||
DeepSeek prefix-cache–oriented design.
|
||||
- **Subagents**: `task` plus explore/research/review/security_review skill agents.
|
||||
- **Skills & hooks**: Claude-Code-style skills (`internal/skill`) and hooks
|
||||
(`internal/hook`), symlink-aware and slash-integrated.
|
||||
- **MCP client**: connect external servers over stdio / Streamable HTTP; reads
|
||||
`[[plugins]]` and a Claude-Code `.mcp.json`.
|
||||
- **Code intelligence via CodeGraph**: a tree-sitter symbol/call graph
|
||||
(`codegraph_*` tools) replaces embedding semantic search — no embedding service
|
||||
or API cost. Fetched into a local cache on first use (or `reasonix codegraph
|
||||
install`) and indexed in the background, so installs and startup stay fast.
|
||||
- **Plan mode** with evidence-backed step sign-off (`complete_step`).
|
||||
- **Memory**: `REASONIX.md` hierarchy + auto-memory, folded into the cache-stable
|
||||
prefix.
|
||||
- **ACP** (`reasonix acp`) and an HTTP/SSE server frontend; desktop app (Wails).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **File encoding support restored** — GBK/GB18030 (and other non-UTF-8) files
|
||||
can now be read, edited, and grepped correctly. The v2 rewrite had dropped
|
||||
v1's encoding detection; files in CJK Windows charsets were silently misread
|
||||
or rejected as binary. The read/edit/write round-trip now preserves the
|
||||
original file encoding. (#2637)
|
||||
|
||||
### Notes
|
||||
|
||||
- Versions: the legacy TypeScript line stays in `0.x`; the Go line starts at
|
||||
`1.0.0`. See [docs/MIGRATING.md](docs/MIGRATING.md).
|
||||
- Release archives ship a bare binary; CodeGraph is fetched on first use. Windows
|
||||
support for the fetched runtime is unverified — install `codegraph` on PATH if
|
||||
the auto-fetch doesn't resolve there.
|
||||
|
||||
[1.0.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.0.0
|
||||
@@ -1,6 +0,0 @@
|
||||
# Reasonix
|
||||
|
||||
The project's standing instructions live in `REASONIX.md` — one file, read by
|
||||
both Reasonix and Claude Code. Add rules there, never here.
|
||||
|
||||
@REASONIX.md
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# Contributing to Reasonix
|
||||
|
||||
Thank you for your interest in contributing to Reasonix! This guide covers
|
||||
everything you need to get started.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.25+** — the project targets the latest stable Go release
|
||||
- **Git** — for version control
|
||||
- **Node.js** (optional) — only if you work on the desktop app (`desktop/`)
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
git clone https://github.com/esengine/DeepSeek-Reasonix.git
|
||||
cd DeepSeek-Reasonix
|
||||
go build ./cmd/reasonix # builds the CLI binary
|
||||
go test ./... # runs the full test suite
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `cmd/reasonix` | CLI entry point |
|
||||
| `internal/agent` | Agent loop, session, coordinator |
|
||||
| `internal/cli` | TUI, subcommands, setup wizard |
|
||||
| `internal/control` | Transport-agnostic controller |
|
||||
| `internal/config` | TOML configuration loading |
|
||||
| `internal/tool/builtin` | Built-in tools (bash, read_file, …) |
|
||||
| `internal/provider` | Model-backend abstraction |
|
||||
| `internal/provider/openai` | OpenAI-compatible provider |
|
||||
| `internal/plugin` | MCP client (stdio + HTTP) |
|
||||
| `internal/event` | Typed event stream |
|
||||
| `internal/hook` | Shell hooks (PreToolUse, …) |
|
||||
| `internal/memory` | REASONIX.md hierarchy + auto-memory |
|
||||
| `internal/skill` | Skill discovery from Markdown |
|
||||
| `internal/sandbox` | OS-level sandboxing |
|
||||
| `internal/serve` | HTTP/SSE server frontend |
|
||||
| `internal/checkpoint` | Snapshot-based rewind |
|
||||
| `desktop/` | Wails-based desktop app (separate Go module) |
|
||||
| `docs/` | Engineering spec, migration guide |
|
||||
|
||||
### Dependency direction
|
||||
|
||||
```
|
||||
cli → {agent, plugin, config} → {tool, provider}
|
||||
```
|
||||
|
||||
Built-in subpackages import their parent to self-register via `init()`.
|
||||
Parents never import children.
|
||||
|
||||
## Development workflow
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
make build # go build ./...
|
||||
make test # go test ./...
|
||||
make vet # go vet ./...
|
||||
make fmt # gofmt -w .
|
||||
make hooks # install git hooks (pre-push: go vet)
|
||||
make cross # cross-compile for all 6 targets
|
||||
```
|
||||
|
||||
### Isolated development environment
|
||||
|
||||
A source-built binary shares no on-disk state with a stable release when launched
|
||||
with `REASONIX_HOME` set. This gives each build its own self-contained directory
|
||||
tree — config, credentials, sessions, cache, skills, commands, hooks, and
|
||||
desktop tab state — so the two builds never interfere:
|
||||
|
||||
**CLI**
|
||||
|
||||
```bash
|
||||
REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
|
||||
# or after building:
|
||||
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
|
||||
```
|
||||
|
||||
**Desktop**
|
||||
|
||||
```bash
|
||||
cd desktop && wails build
|
||||
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
|
||||
```
|
||||
|
||||
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
|
||||
Command Prompt; the binary extension is `.exe`.
|
||||
|
||||
The directory is empty on first launch; the app behaves exactly like a fresh
|
||||
install. Every subsequent write — config saves, credential storage, session
|
||||
logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention
|
||||
directory scanning, and all other fallback paths are skipped so no production
|
||||
data leaks in or out.
|
||||
|
||||
### Cache-first review gate
|
||||
|
||||
Reasonix treats high prompt-cache hit rate as product behavior. Changes that
|
||||
touch provider-visible system prompt construction, memory prefix, output styles,
|
||||
skill index behavior, default tool surfaces, tool schemas, provider request
|
||||
serialization, compaction, or MCP/tool registration need explicit cache review.
|
||||
|
||||
For these changes:
|
||||
|
||||
- Keep system prompt changes low-frequency and require explicit review.
|
||||
- Fill the PR body `Cache-impact:` line with `none`, `low`, `medium`, or `high`
|
||||
plus the reason.
|
||||
- Fill the PR body `Cache-guard:` line with the focused guard test/command added
|
||||
or run, or explain why an existing guard covers the change.
|
||||
- Fill `System-prompt-review:` when system prompt, memory prefix, output style,
|
||||
or skill index behavior changes.
|
||||
- Prefer focused guard tests near the changed surface; `scripts/cache-guard.sh`
|
||||
remains the broader release-level cache-hit check.
|
||||
|
||||
CI enforces this metadata for cache-sensitive paths so prompt/tool prefix churn
|
||||
is called out before review.
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
go test ./... # all tests
|
||||
go test ./internal/agent/ -v # verbose, one package
|
||||
go test ./internal/tool/builtin/ -run TestGrep # one test
|
||||
```
|
||||
|
||||
### Code style
|
||||
|
||||
- `gofmt` is enforced by CI — format before committing
|
||||
- Follow existing patterns: wrap errors with `fmt.Errorf("...: %w", err)`
|
||||
- Library code never calls `os.Exit` or prints to stdout/stderr
|
||||
- Only `cli/` and `main/` decide exit codes and user-facing messages
|
||||
- Exported identifiers must have doc comments
|
||||
|
||||
### Commit messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(glob): add ** recursive pattern support
|
||||
fix: replace silent error discards with structured logging
|
||||
test(event): add comprehensive unit tests for event package
|
||||
docs: add CONTRIBUTING.md
|
||||
ci: add golangci-lint and govulncheck
|
||||
```
|
||||
|
||||
## Adding a new built-in tool
|
||||
|
||||
1. Create `internal/tool/builtin/mytool.go`
|
||||
2. Implement the `tool.Tool` interface: `Name()`, `Description()`, `Schema()`, `ReadOnly()`, `Execute()`
|
||||
3. Register via `func init() { tool.RegisterBuiltin(myTool{}) }`
|
||||
4. Add tests in `internal/tool/builtin/builtin_test.go` or a separate `mytool_test.go`
|
||||
5. The tool is automatically available — `main` blank-imports `builtin`
|
||||
|
||||
## Adding a new model provider
|
||||
|
||||
(For MCP tool servers see `internal/plugin` instead — that's a different layer.)
|
||||
|
||||
1. Create `internal/provider/myprovider/`
|
||||
2. Implement `provider.Provider`: `Name()`, `Stream()`
|
||||
3. Register via `func init() { provider.Register("mykind", New) }`
|
||||
4. The provider is available from config with `kind = "mykind"`
|
||||
|
||||
## Adding i18n strings
|
||||
|
||||
1. Add the field to `internal/i18n/i18n.go` (`Messages` struct)
|
||||
2. Add the value in `internal/i18n/messages_en.go` and `messages_zh.go`
|
||||
3. The `TestCatalogsComplete` test will fail if you miss a locale
|
||||
|
||||
## Submitting changes
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch from `main-v2`
|
||||
3. Make your changes with tests
|
||||
4. Ensure `go test ./...` passes
|
||||
5. Ensure `gofmt -l .` shows no changes
|
||||
6. Submit a pull request to `main-v2`
|
||||
|
||||
## Reporting issues
|
||||
|
||||
Open an issue on GitHub with:
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Go version and OS
|
||||
- Relevant logs or error messages
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the
|
||||
same license as the project.
|
||||
@@ -0,0 +1,42 @@
|
||||
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOEXE := $(shell go env GOEXE)
|
||||
|
||||
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix-plugin-example$(GOEXE) ./cmd/reasonix-plugin-example
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
desktop-test:
|
||||
cd desktop && go test .
|
||||
|
||||
desktop-test-short:
|
||||
cd desktop && go test -short .
|
||||
|
||||
desktop-test-times:
|
||||
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
|
||||
hooks:
|
||||
@git config core.hooksPath .githooks
|
||||
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
|
||||
|
||||
cross:
|
||||
@mkdir -p dist
|
||||
@for p in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do \
|
||||
os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \
|
||||
echo "build $$os/$$arch"; \
|
||||
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" -o dist/reasonix-$$os-$$arch$$ext ./cmd/reasonix; \
|
||||
done
|
||||
|
||||
clean:
|
||||
rm -rf bin dist
|
||||
@@ -1,28 +1,210 @@
|
||||
# Reasonix Platform
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
|
||||
</p>
|
||||
|
||||
This branch contains only the shared Reasonix service platform:
|
||||
<p align="center">
|
||||
<strong>English</strong>
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<a href="./docs/GUIDE.md">Guide</a>
|
||||
·
|
||||
<a href="./docs/ACP.md">ACP</a>
|
||||
·
|
||||
<a href="./docs/SPEC.md">Spec</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
</p>
|
||||
|
||||
- identity and account services;
|
||||
- community and forum services;
|
||||
- crash, diagnostics, telemetry, and registry services;
|
||||
- Cloudflare Worker, D1, Queue, and R2 configuration;
|
||||
- production deployment workflows for those services.
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
|
||||
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
Product clients live on `main-v2` and `studio`. The public website lives on
|
||||
`website`. Production deployments are accepted only from this branch.
|
||||
<br/>
|
||||
|
||||
## Services
|
||||
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
|
||||
<p align="center">A config- and plugin-driven harness — a single static Go binary, tuned around DeepSeek's prefix cache so token costs stay low across long sessions.</p>
|
||||
|
||||
| Directory | Responsibility |
|
||||
| --- | --- |
|
||||
| `workers/accounts` | Shared identity, sessions, device grants, and email login |
|
||||
| `workers/forum` | Community forum APIs |
|
||||
| `workers/crash-report` | Diagnostics, telemetry, registry, and admin dashboards |
|
||||
<br/>
|
||||
|
||||
Each service owns its package manager lockfile, tests, schema, migrations, and
|
||||
Wrangler configuration. Run validation from the service directory before using
|
||||
the matching manual production deployment workflow.
|
||||
> [!IMPORTANT]
|
||||
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
|
||||
|
||||
The accounts worker removes expired sessions, email tokens, and device grants
|
||||
daily. Raw queued telemetry is retained in R2 for eight days; aggregated D1
|
||||
rows follow the service-specific retention windows in the crash worker.
|
||||
<br/>
|
||||
|
||||
## Features
|
||||
|
||||
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
|
||||
declared in `reasonix.toml`. No hardcoded models.
|
||||
- **Multi-model & composable.** DeepSeek ships as a preset; any
|
||||
OpenAI-compatible endpoint is a config entry, not new code. Optionally run
|
||||
two models together (executor + planner) in separate, cache-stable sessions.
|
||||
- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC
|
||||
(MCP-compatible). Built-in tools self-register at compile time.
|
||||
- **Cache-aware context maintenance.** Startup injects a small stable environment
|
||||
summary, stale tool output is snipped/pruned before summary compaction, and the
|
||||
built-in tool schema contract is documented for regression review.
|
||||
- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile
|
||||
to six targets with one command. The only dependency is a TOML parser.
|
||||
|
||||
## Install
|
||||
|
||||
Choose the path that matches how you want to use Reasonix. The CLI/TUI,
|
||||
desktop app, and VS Code extension all use the same local Reasonix engine.
|
||||
|
||||
### Path A: CLI / TUI
|
||||
|
||||
Install the native binary through npm on any supported platform, or use
|
||||
Homebrew on macOS:
|
||||
|
||||
```sh
|
||||
npm i -g reasonix # any OS; pulls the prebuilt native binary
|
||||
brew install esengine/reasonix/reasonix # macOS
|
||||
```
|
||||
|
||||
Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on
|
||||
every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases).
|
||||
|
||||
### Path B: Desktop app
|
||||
|
||||
Use the [official download page](https://reasonix.io/?download=desktop#start)
|
||||
for the latest desktop build.
|
||||
|
||||
| Platform | Package | Architecture |
|
||||
| --- | --- | --- |
|
||||
| macOS | Universal `.dmg` or `.zip` | Apple Silicon / Intel |
|
||||
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
|
||||
| Linux | `.deb` or `.tar.gz` | x64 |
|
||||
|
||||
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
|
||||
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
|
||||
|
||||
### Path C: VS Code extension
|
||||
|
||||
Complete Path A first. The extension does not bundle the CLI; it starts your
|
||||
local `reasonix acp` backend and adds native chat, editor context, tool-call
|
||||
approvals, model selection, and workspace sessions.
|
||||
|
||||
- **VS Code:** [install from Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
|
||||
- **VSCodium / Eclipse Theia:** [install from Open VSX Registry](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
|
||||
- **Extension ID:** `SivanLiu.reasonix-agent` · [source and usage guide](https://github.com/SivanCola/reasonix-vscode)
|
||||
|
||||
### Path D: Build from source
|
||||
|
||||
```sh
|
||||
git clone https://github.com/esengine/DeepSeek-Reasonix.git
|
||||
cd DeepSeek-Reasonix
|
||||
make build # -> bin/reasonix(.exe)
|
||||
make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### CLI / TUI
|
||||
|
||||
These commands are for the CLI/TUI installed through Path A:
|
||||
|
||||
```sh
|
||||
reasonix setup # configure a provider and model
|
||||
reasonix # start an interactive session
|
||||
reasonix run "implement the TODOs in main.go"
|
||||
```
|
||||
|
||||
In an interactive session, run `/init` when you want Reasonix to create project
|
||||
instructions.
|
||||
|
||||
### Desktop app
|
||||
|
||||
Download the installer for your platform from the
|
||||
[official download page](https://reasonix.io/?download=desktop#start), install
|
||||
and launch Reasonix, then configure a provider and model in the app. The CLI
|
||||
commands above are not required for the desktop app.
|
||||
|
||||
For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.md)**,
|
||||
**[Guide](./docs/GUIDE.md)**, and
|
||||
**[configuration paths](./docs/CONFIG_PATHS.md)**.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
|
||||
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
|
||||
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
|
||||
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
|
||||
[Recovery and Safe Mode](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
|
||||
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
|
||||
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
|
||||
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
|
||||
[Tool contract](./docs/TOOL_CONTRACT.md) · [Migrating from 0.x](./docs/MIGRATING.md)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-dark.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
|
||||
<img alt="Star History Chart" src="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
A small list of folks whose work has shaped Reasonix the most — the current top
|
||||
20 contributors by commit count. The full contributor graph is on
|
||||
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1).
|
||||
|
||||
<!-- reasonix-top-contributors:start -->
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| **reasonix** (anonymous) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
|
||||
| **merge-order-check** (anonymous) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** (anonymous) |
|
||||
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Also a separate thank-you to [**Bernardxu123**](https://github.com/Bernardxu123)
|
||||
for designing the project logo, and to
|
||||
[AIGC Link](https://xhslink.com/m/80ngts127cA) for promoting the project on XiaoHongShu.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>MIT — see <a href="./LICENSE">LICENSE</a></sub>
|
||||
<br/>
|
||||
<sub>Built by the community at <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a></sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub><strong>Support this project</strong></sub></p>
|
||||
|
||||
If Reasonix has been useful and you'd like to say thanks, you can. It stays a
|
||||
coffee, not a contract — donations don't buy feature priority or change how
|
||||
issues get triaged.
|
||||
|
||||
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
- **国内** — 微信支付(扫码)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
|
||||
</p>
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a>
|
||||
·
|
||||
<strong>简体中文</strong>
|
||||
·
|
||||
<a href="./docs/GUIDE.zh-CN.md">指南</a>
|
||||
·
|
||||
<a href="./docs/ACP.zh-CN.md">ACP</a>
|
||||
·
|
||||
<a href="./docs/SPEC.zh-CN.md">规格</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
|
||||
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
|
||||
<p align="center">由配置与插件驱动的极薄 harness——单一静态 Go 二进制,围绕 DeepSeek 的前缀缓存调优,长会话也能把 token 成本压低。</p>
|
||||
|
||||
<br/>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
|
||||
|
||||
## 特性
|
||||
|
||||
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
|
||||
内核无硬编码模型。
|
||||
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
|
||||
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
|
||||
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
|
||||
内置工具在编译期自注册。
|
||||
- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune,
|
||||
再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。
|
||||
- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
|
||||
唯一依赖是一个 TOML 解析库。
|
||||
|
||||
## 安装
|
||||
|
||||
选择适合你的使用路径。CLI/TUI、桌面端和 VS Code 扩展都使用同一套本地
|
||||
Reasonix 引擎。
|
||||
|
||||
### 路径 A:CLI / TUI
|
||||
|
||||
任意支持的平台都可以通过 npm 安装原生二进制;macOS 也可以使用 Homebrew:
|
||||
|
||||
```sh
|
||||
npm i -g reasonix # 任意系统;自动拉取对应平台的原生二进制
|
||||
brew install esengine/reasonix/reasonix # macOS
|
||||
```
|
||||
|
||||
预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个
|
||||
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。
|
||||
|
||||
### 路径 B:桌面端
|
||||
|
||||
前往[官方下载页](https://reasonix.io/?download=desktop#start)获取最新桌面版本。
|
||||
|
||||
| 平台 | 安装包 | 架构 |
|
||||
| --- | --- | --- |
|
||||
| macOS | 通用 `.dmg` 或 `.zip` | Apple Silicon / Intel |
|
||||
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
|
||||
| Linux | `.deb` 或 `.tar.gz` | x64 |
|
||||
|
||||
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
|
||||
[SignPath 基金会](https://signpath.org/) 免费提供。
|
||||
|
||||
### 路径 C:VS Code 扩展
|
||||
|
||||
请先完成路径 A。扩展不内置 CLI,而是启动本机的 `reasonix acp` 后端,
|
||||
并提供原生聊天、编辑器上下文、工具调用审批、模型选择和工作区会话。
|
||||
|
||||
- **VS Code:** [从 Visual Studio Marketplace 安装](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
|
||||
- **VSCodium / Eclipse Theia:** [从 Open VSX Registry 安装](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
|
||||
- **扩展 ID:** `SivanLiu.reasonix-agent` · [源码与使用说明](https://github.com/SivanCola/reasonix-vscode)
|
||||
|
||||
### 路径 D:从源码构建
|
||||
|
||||
```sh
|
||||
git clone https://github.com/esengine/DeepSeek-Reasonix.git
|
||||
cd DeepSeek-Reasonix
|
||||
make build # -> bin/reasonix(.exe)
|
||||
make cross # -> dist/(darwin|linux|windows × amd64|arm64)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### CLI / TUI
|
||||
|
||||
以下命令仅适用于通过路径 A 安装的 CLI/TUI:
|
||||
|
||||
```sh
|
||||
reasonix setup # 配置 provider 和模型
|
||||
reasonix # 启动交互式会话
|
||||
reasonix run "把 main.go 里的 TODO 实现掉"
|
||||
```
|
||||
|
||||
需要项目指令时,可在交互式会话中运行 `/init`。
|
||||
|
||||
### 桌面端
|
||||
|
||||
从[官方下载页](https://reasonix.io/?download=desktop#start)下载对应系统的安装包,
|
||||
安装并启动 Reasonix,然后在应用内配置 provider 和模型即可使用。桌面端无需执行
|
||||
上面的 CLI 命令。
|
||||
|
||||
CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**、
|
||||
**[指南](./docs/GUIDE.zh-CN.md)** 和
|
||||
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**。
|
||||
|
||||
## 文档
|
||||
|
||||
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
|
||||
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.zh-CN.md) ·
|
||||
[ACP 编辑器接入](./docs/ACP.zh-CN.md)
|
||||
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md) ·
|
||||
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md) ·
|
||||
[恢复与安全模式](./docs/RECOVERY.zh-CN.md) ·
|
||||
[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md) ·
|
||||
[Checkpoints 与 rewind](./docs/CHECKPOINTS.zh-CN.md)
|
||||
- **工程与迁移:** [规格](./docs/SPEC.zh-CN.md) ·
|
||||
[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md) ·
|
||||
[工具合约](./docs/TOOL_CONTRACT.zh-CN.md) ·
|
||||
[从 0.x 迁移](./docs/MIGRATING.zh-CN.md)
|
||||
|
||||
## Star 趋势
|
||||
|
||||
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-dark.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
|
||||
<img alt="Star History Chart" src="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
## 致谢
|
||||
|
||||
下面这些朋友的工作塑造了 Reasonix 今天的样子 —— 当前按 commit 数统计的前 20 名贡献者。
|
||||
完整贡献者列表在
|
||||
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1)。
|
||||
|
||||
<!-- reasonix-top-contributors:start -->
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| **reasonix**(anonymous) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
|
||||
| **merge-order-check**(anonymous) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan**(anonymous) |
|
||||
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
另外特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo,
|
||||
以及 [AIGC Link](https://xhslink.com/m/80ngts127cA) 在小红书上的推广。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="esengine/DeepSeek-Reasonix 贡献者" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>MIT —— 见 <a href="./LICENSE">LICENSE</a></sub>
|
||||
<br/>
|
||||
<sub>由 <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> 社区共建</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub><strong>支持本项目</strong></sub></p>
|
||||
|
||||
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
|
||||
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
|
||||
|
||||
- **国内** — 微信支付(扫下方二维码)
|
||||
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
|
||||
</p>
|
||||
+72
-13
@@ -1,14 +1,73 @@
|
||||
# Reasonix Platform instructions
|
||||
# Reasonix project memory
|
||||
|
||||
- This branch owns shared online services only. Do not add product client or
|
||||
public website code here.
|
||||
- Keep every production deployment manually triggered and restricted to the
|
||||
`platform` branch.
|
||||
- Database changes must be backward-compatible, idempotent, and accompanied by
|
||||
a migration or runtime schema guard.
|
||||
- Ingest endpoints must validate before enqueueing, acknowledge only durable
|
||||
work, and remain safe under retries.
|
||||
- Add tests at the HTTP, queue, or storage boundary where behavior becomes
|
||||
externally observable.
|
||||
- Never commit credentials, tokens, customer content, or personal user data.
|
||||
- Comments are English and explain only non-obvious constraints.
|
||||
This file is loaded into every session's system prompt (the cache-stable prefix),
|
||||
so keep it concise and durable — it is the project's standing instructions to the
|
||||
agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Go kernel under `internal/`; each package owns one concern and documents it in a
|
||||
package comment. Match the surrounding comment density and idiom when editing.
|
||||
- One transport-agnostic `control.Controller` sits behind every frontend (chat
|
||||
TUI, HTTP/SSE serve, Wails desktop). Add behavior to the controller, not a
|
||||
frontend, so all three inherit it.
|
||||
- Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay
|
||||
byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never
|
||||
mutate it mid-session — ride the turn tail instead (see `control.Compose`).
|
||||
|
||||
## Memory
|
||||
|
||||
- Hierarchical docs: `REASONIX.md` (this file, committed/shared), `REASONIX.local.md`
|
||||
(personal, git-ignored), user-global `~/.config/reasonix/REASONIX.md`, and any
|
||||
`REASONIX.md` in an ancestor dir. `AGENTS.md` is accepted as a fallback name.
|
||||
- `@path` on its own line imports another file's contents.
|
||||
- `#<note>` in chat quick-adds a line here. The `remember` tool saves durable
|
||||
facts to the per-project auto-memory store (frontmatter files + `MEMORY.md`
|
||||
index), which loads into the prefix on the next session.
|
||||
|
||||
## Notes
|
||||
|
||||
## Pre-push CI simulation
|
||||
|
||||
Run these **before every commit** to catch the fastest CI failures locally:
|
||||
|
||||
```bash
|
||||
gofmt -w . # catches gofmt (saves ~13s CI)
|
||||
go vet ./... # catches vet warnings (saves ~52s CI/lint)
|
||||
go test ./internal/tool/builtin/ ./internal/boot/ # catches tool/boot test breaks
|
||||
```
|
||||
|
||||
CI runs `golangci-lint` (not locally available), but gofmt + vet already block ~80% of fast-fail scenarios.
|
||||
|
||||
## Import cycle rule
|
||||
|
||||
Before importing a new internal package from a non-test file, verify the target package's **test files** aren't already importing back to you:
|
||||
|
||||
```
|
||||
# BAD: agent(_test.go) → tool/builtin(sessions.go) → agent → setup failed
|
||||
```
|
||||
|
||||
Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup failed]` message means a cycle exists.
|
||||
|
||||
## PR hygiene
|
||||
|
||||
- **One force-push per round of review feedback.** Multiple force-pushes destroy review history and confuse reviewers.
|
||||
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
|
||||
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
|
||||
|
||||
## Cache-impact PR metadata
|
||||
|
||||
When PR changes touch files under `internal/boot/`, `internal/tool/`, `internal/provider/`, or other cache-sensitive paths (listed in `scripts/check-cache-impact.sh`), the PR body MUST include these lines at the end:
|
||||
|
||||
```
|
||||
Cache-impact: <none|low|medium|high> — <reason>
|
||||
Cache-guard: <focused guard test/command or existing guard rationale>
|
||||
```
|
||||
|
||||
If the PR also touches files under `internal/config/`, `internal/memory/`, `internal/outputstyle/`, `internal/skill/`, or `internal/boot/`, add:
|
||||
|
||||
```
|
||||
System-prompt-review: <reviewer/approval note>
|
||||
```
|
||||
|
||||
Values `n/a`, `none`, `todo`, `tbd` are rejected — use a descriptive reason instead.
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="480" viewBox="0 0 960 480" role="img" aria-labelledby="title description">
|
||||
<title id="title">esengine/DeepSeek-Reasonix Star History</title>
|
||||
<desc id="description">36,039 current GitHub stargazers through 2026-11-02.</desc>
|
||||
<rect width="960" height="480" rx="12" fill="#0d1117" stroke="#30363d" />
|
||||
<text x="76" y="37" fill="#f0f6fc" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="600">Star History</text>
|
||||
<text x="76" y="60" fill="#8b949e" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13">esengine/DeepSeek-Reasonix</text>
|
||||
<g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif">
|
||||
|
||||
<line x1="76" y1="418.00" x2="926" y2="418.00" stroke="#21262d" stroke-width="1" />
|
||||
<text x="64" y="422.00" text-anchor="end" fill="#8b949e" font-size="12">0</text>
|
||||
<line x1="76" y1="350.00" x2="926" y2="350.00" stroke="#21262d" stroke-width="1" />
|
||||
<text x="64" y="354.00" text-anchor="end" fill="#8b949e" font-size="12">8,000</text>
|
||||
<line x1="76" y1="282.00" x2="926" y2="282.00" stroke="#21262d" stroke-width="1" />
|
||||
<text x="64" y="286.00" text-anchor="end" fill="#8b949e" font-size="12">16,000</text>
|
||||
<line x1="76" y1="214.00" x2="926" y2="214.00" stroke="#21262d" stroke-width="1" />
|
||||
<text x="64" y="218.00" text-anchor="end" fill="#8b949e" font-size="12">24,000</text>
|
||||
<line x1="76" y1="146.00" x2="926" y2="146.00" stroke="#21262d" stroke-width="1" />
|
||||
<text x="64" y="150.00" text-anchor="end" fill="#8b949e" font-size="12">32,000</text>
|
||||
<line x1="76" y1="78.00" x2="926" y2="78.00" stroke="#21262d" stroke-width="1" />
|
||||
<text x="64" y="82.00" text-anchor="end" fill="#8b949e" font-size="12">40,000</text>
|
||||
|
||||
<line x1="76.00" y1="78" x2="76.00" y2="418" stroke="#21262d" stroke-width="1" />
|
||||
<text x="76.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Apr 21</text>
|
||||
<line x1="246.00" y1="78" x2="246.00" y2="418" stroke="#21262d" stroke-width="1" />
|
||||
<text x="246.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">May 30</text>
|
||||
<line x1="416.00" y1="78" x2="416.00" y2="418" stroke="#21262d" stroke-width="1" />
|
||||
<text x="416.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Jul 8</text>
|
||||
<line x1="586.00" y1="78" x2="586.00" y2="418" stroke="#21262d" stroke-width="1" />
|
||||
<text x="586.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Aug 16</text>
|
||||
<line x1="756.00" y1="78" x2="756.00" y2="418" stroke="#21262d" stroke-width="1" />
|
||||
<text x="756.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Sep 24</text>
|
||||
<line x1="926.00" y1="78" x2="926.00" y2="418" stroke="#21262d" stroke-width="1" />
|
||||
<text x="926.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Nov 2</text>
|
||||
<path d="M76.00,417.99 L84.72,417.86 L89.08,417.50 L93.44,417.39 L97.79,417.21 L102.15,417.01 L106.51,416.92 L110.87,416.56 L115.23,416.35 L119.59,416.19 L123.95,415.88 L128.31,415.56 L132.67,415.26 L137.03,414.91 L141.38,414.50 L145.74,413.14 L150.10,412.16 L154.46,410.57 L158.82,409.61 L163.18,408.26 L167.54,407.05 L171.90,404.59 L176.26,400.57 L180.62,395.38 L184.97,392.38 L189.33,389.34 L193.69,385.41 L198.05,382.14 L202.41,379.58 L206.77,377.85 L211.13,376.10 L215.49,372.89 L219.85,366.67 L224.21,353.02 L228.56,339.54 L232.92,326.89 L237.28,318.30 L241.64,309.11 L246.00,303.02 L250.36,298.30 L254.72,292.25 L259.08,286.56 L263.44,280.80 L267.79,275.76 L272.15,271.94 L276.51,268.99 L280.87,265.95 L285.23,261.30 L289.59,256.93 L293.95,252.91 L298.31,249.60 L302.67,246.76 L307.03,245.01 L311.38,243.07 L315.74,240.73 L320.10,237.75 L324.46,235.56 L328.82,233.90 L333.18,232.85 L337.54,231.80 L341.90,230.07 L346.26,227.80 L350.62,225.39 L354.97,223.11 L359.33,220.88 L363.69,218.67 L368.05,217.38 L372.41,216.35 L376.77,215.23 L381.13,214.09 L385.49,213.14 L389.85,212.05 L394.21,211.08 L398.56,210.28 L402.92,209.53 L407.28,208.17 L411.64,207.05 L416.00,206.10 L420.36,205.14 L424.72,204.49 L429.08,203.90 L433.44,203.25 L437.79,202.34 L442.15,201.61 L446.51,201.00 L450.87,200.39 L455.23,199.79 L459.59,198.73 L463.95,198.21 L468.31,197.42 L472.67,196.71 L477.03,195.95 L481.38,195.22 L485.74,194.64 L490.10,194.16 L494.46,193.80 L498.82,192.99 L503.18,192.52 L507.54,191.99 L511.90,191.50 L516.26,190.02 L520.62,187.74 L524.97,183.58 L529.33,176.57 L533.69,169.67 L538.05,162.99 L542.41,156.34 L546.77,151.98 L551.13,149.72 L555.49,147.31 L559.85,144.95 L564.21,142.81 L568.56,140.76 L572.92,138.15 L577.28,136.79 L581.64,136.23 L586.00,135.69 L590.36,134.91 L594.72,133.77 L599.08,132.79 L603.44,131.90 L607.79,131.13 L612.15,130.65 L616.51,130.15 L620.87,129.58 L625.23,129.03 L629.59,128.64 L633.95,128.12 L638.31,127.79 L642.67,127.56 L647.03,127.18 L651.38,126.83 L655.74,126.43 L660.10,126.02 L664.46,125.63 L668.82,125.39 L673.18,125.14 L677.54,124.87 L681.90,124.70 L686.26,124.42 L690.62,124.25 L694.97,123.77 L699.33,123.44 L703.69,123.16 L708.05,122.94 L712.41,122.61 L716.77,122.32 L721.13,122.01 L725.49,121.67 L729.85,121.38 L734.21,121.11 L738.56,120.78 L742.92,120.47 L747.28,120.23 L751.64,119.93 L756.00,119.68 L760.36,119.53 L764.72,119.34 L769.08,119.23 L773.44,119.05 L777.79,118.81 L782.15,118.49 L786.51,118.34 L790.87,118.15 L795.23,117.98 L799.59,117.87 L803.95,117.68 L808.31,117.57 L812.67,117.41 L817.03,117.18 L821.38,116.99 L825.74,116.74 L830.10,116.56 L834.46,116.35 L838.82,116.15 L843.18,115.82 L847.54,115.60 L851.90,115.37 L856.26,115.16 L860.62,114.92 L864.97,114.55 L869.33,114.30 L873.69,113.99 L878.05,113.67 L882.41,113.48 L886.77,113.27 L891.13,113.12 L895.49,112.85 L899.85,112.65 L904.21,112.44 L908.56,112.30 L912.92,112.09 L917.28,111.94 L921.64,111.80 L926.00,111.67 L926.00,418.00 L76.00,418.00 Z" fill="#58a6ff" opacity="0.12" />
|
||||
<path d="M76.00,417.99 L84.72,417.86 L89.08,417.50 L93.44,417.39 L97.79,417.21 L102.15,417.01 L106.51,416.92 L110.87,416.56 L115.23,416.35 L119.59,416.19 L123.95,415.88 L128.31,415.56 L132.67,415.26 L137.03,414.91 L141.38,414.50 L145.74,413.14 L150.10,412.16 L154.46,410.57 L158.82,409.61 L163.18,408.26 L167.54,407.05 L171.90,404.59 L176.26,400.57 L180.62,395.38 L184.97,392.38 L189.33,389.34 L193.69,385.41 L198.05,382.14 L202.41,379.58 L206.77,377.85 L211.13,376.10 L215.49,372.89 L219.85,366.67 L224.21,353.02 L228.56,339.54 L232.92,326.89 L237.28,318.30 L241.64,309.11 L246.00,303.02 L250.36,298.30 L254.72,292.25 L259.08,286.56 L263.44,280.80 L267.79,275.76 L272.15,271.94 L276.51,268.99 L280.87,265.95 L285.23,261.30 L289.59,256.93 L293.95,252.91 L298.31,249.60 L302.67,246.76 L307.03,245.01 L311.38,243.07 L315.74,240.73 L320.10,237.75 L324.46,235.56 L328.82,233.90 L333.18,232.85 L337.54,231.80 L341.90,230.07 L346.26,227.80 L350.62,225.39 L354.97,223.11 L359.33,220.88 L363.69,218.67 L368.05,217.38 L372.41,216.35 L376.77,215.23 L381.13,214.09 L385.49,213.14 L389.85,212.05 L394.21,211.08 L398.56,210.28 L402.92,209.53 L407.28,208.17 L411.64,207.05 L416.00,206.10 L420.36,205.14 L424.72,204.49 L429.08,203.90 L433.44,203.25 L437.79,202.34 L442.15,201.61 L446.51,201.00 L450.87,200.39 L455.23,199.79 L459.59,198.73 L463.95,198.21 L468.31,197.42 L472.67,196.71 L477.03,195.95 L481.38,195.22 L485.74,194.64 L490.10,194.16 L494.46,193.80 L498.82,192.99 L503.18,192.52 L507.54,191.99 L511.90,191.50 L516.26,190.02 L520.62,187.74 L524.97,183.58 L529.33,176.57 L533.69,169.67 L538.05,162.99 L542.41,156.34 L546.77,151.98 L551.13,149.72 L555.49,147.31 L559.85,144.95 L564.21,142.81 L568.56,140.76 L572.92,138.15 L577.28,136.79 L581.64,136.23 L586.00,135.69 L590.36,134.91 L594.72,133.77 L599.08,132.79 L603.44,131.90 L607.79,131.13 L612.15,130.65 L616.51,130.15 L620.87,129.58 L625.23,129.03 L629.59,128.64 L633.95,128.12 L638.31,127.79 L642.67,127.56 L647.03,127.18 L651.38,126.83 L655.74,126.43 L660.10,126.02 L664.46,125.63 L668.82,125.39 L673.18,125.14 L677.54,124.87 L681.90,124.70 L686.26,124.42 L690.62,124.25 L694.97,123.77 L699.33,123.44 L703.69,123.16 L708.05,122.94 L712.41,122.61 L716.77,122.32 L721.13,122.01 L725.49,121.67 L729.85,121.38 L734.21,121.11 L738.56,120.78 L742.92,120.47 L747.28,120.23 L751.64,119.93 L756.00,119.68 L760.36,119.53 L764.72,119.34 L769.08,119.23 L773.44,119.05 L777.79,118.81 L782.15,118.49 L786.51,118.34 L790.87,118.15 L795.23,117.98 L799.59,117.87 L803.95,117.68 L808.31,117.57 L812.67,117.41 L817.03,117.18 L821.38,116.99 L825.74,116.74 L830.10,116.56 L834.46,116.35 L838.82,116.15 L843.18,115.82 L847.54,115.60 L851.90,115.37 L856.26,115.16 L860.62,114.92 L864.97,114.55 L869.33,114.30 L873.69,113.99 L878.05,113.67 L882.41,113.48 L886.77,113.27 L891.13,113.12 L895.49,112.85 L899.85,112.65 L904.21,112.44 L908.56,112.30 L912.92,112.09 L917.28,111.94 L921.64,111.80 L926.00,111.67" fill="none" stroke="#58a6ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="926.00" cy="111.67" r="5" fill="#58a6ff" stroke="#0d1117" stroke-width="2" />
|
||||
<g transform="translate(646, 25)">
|
||||
<circle cx="0" cy="0" r="5" fill="#58a6ff" />
|
||||
<text x="12" y="5" fill="#f0f6fc" font-size="14" font-weight="600">esengine/DeepSeek-Reasonix</text>
|
||||
<text x="12" y="24" fill="#8b949e" font-size="12">36,039 stars</text>
|
||||
</g>
|
||||
<text x="926" y="466" text-anchor="end" fill="#8b949e" font-size="11">Updated automatically from GitHub stargazer history · 2026-11-02 UTC</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="480" viewBox="0 0 960 480" role="img" aria-labelledby="title description">
|
||||
<title id="title">esengine/DeepSeek-Reasonix Star History</title>
|
||||
<desc id="description">36,039 current GitHub stargazers through 2026-11-02.</desc>
|
||||
<rect width="960" height="480" rx="12" fill="#ffffff" stroke="#d0d7de" />
|
||||
<text x="76" y="37" fill="#24292f" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="600">Star History</text>
|
||||
<text x="76" y="60" fill="#57606a" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13">esengine/DeepSeek-Reasonix</text>
|
||||
<g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif">
|
||||
|
||||
<line x1="76" y1="418.00" x2="926" y2="418.00" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="64" y="422.00" text-anchor="end" fill="#57606a" font-size="12">0</text>
|
||||
<line x1="76" y1="350.00" x2="926" y2="350.00" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="64" y="354.00" text-anchor="end" fill="#57606a" font-size="12">8,000</text>
|
||||
<line x1="76" y1="282.00" x2="926" y2="282.00" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="64" y="286.00" text-anchor="end" fill="#57606a" font-size="12">16,000</text>
|
||||
<line x1="76" y1="214.00" x2="926" y2="214.00" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="64" y="218.00" text-anchor="end" fill="#57606a" font-size="12">24,000</text>
|
||||
<line x1="76" y1="146.00" x2="926" y2="146.00" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="64" y="150.00" text-anchor="end" fill="#57606a" font-size="12">32,000</text>
|
||||
<line x1="76" y1="78.00" x2="926" y2="78.00" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="64" y="82.00" text-anchor="end" fill="#57606a" font-size="12">40,000</text>
|
||||
|
||||
<line x1="76.00" y1="78" x2="76.00" y2="418" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="76.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Apr 21</text>
|
||||
<line x1="246.00" y1="78" x2="246.00" y2="418" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="246.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">May 30</text>
|
||||
<line x1="416.00" y1="78" x2="416.00" y2="418" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="416.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Jul 8</text>
|
||||
<line x1="586.00" y1="78" x2="586.00" y2="418" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="586.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Aug 16</text>
|
||||
<line x1="756.00" y1="78" x2="756.00" y2="418" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="756.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Sep 24</text>
|
||||
<line x1="926.00" y1="78" x2="926.00" y2="418" stroke="#d8dee4" stroke-width="1" />
|
||||
<text x="926.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Nov 2</text>
|
||||
<path d="M76.00,417.99 L84.72,417.86 L89.08,417.50 L93.44,417.39 L97.79,417.21 L102.15,417.01 L106.51,416.92 L110.87,416.56 L115.23,416.35 L119.59,416.19 L123.95,415.88 L128.31,415.56 L132.67,415.26 L137.03,414.91 L141.38,414.50 L145.74,413.14 L150.10,412.16 L154.46,410.57 L158.82,409.61 L163.18,408.26 L167.54,407.05 L171.90,404.59 L176.26,400.57 L180.62,395.38 L184.97,392.38 L189.33,389.34 L193.69,385.41 L198.05,382.14 L202.41,379.58 L206.77,377.85 L211.13,376.10 L215.49,372.89 L219.85,366.67 L224.21,353.02 L228.56,339.54 L232.92,326.89 L237.28,318.30 L241.64,309.11 L246.00,303.02 L250.36,298.30 L254.72,292.25 L259.08,286.56 L263.44,280.80 L267.79,275.76 L272.15,271.94 L276.51,268.99 L280.87,265.95 L285.23,261.30 L289.59,256.93 L293.95,252.91 L298.31,249.60 L302.67,246.76 L307.03,245.01 L311.38,243.07 L315.74,240.73 L320.10,237.75 L324.46,235.56 L328.82,233.90 L333.18,232.85 L337.54,231.80 L341.90,230.07 L346.26,227.80 L350.62,225.39 L354.97,223.11 L359.33,220.88 L363.69,218.67 L368.05,217.38 L372.41,216.35 L376.77,215.23 L381.13,214.09 L385.49,213.14 L389.85,212.05 L394.21,211.08 L398.56,210.28 L402.92,209.53 L407.28,208.17 L411.64,207.05 L416.00,206.10 L420.36,205.14 L424.72,204.49 L429.08,203.90 L433.44,203.25 L437.79,202.34 L442.15,201.61 L446.51,201.00 L450.87,200.39 L455.23,199.79 L459.59,198.73 L463.95,198.21 L468.31,197.42 L472.67,196.71 L477.03,195.95 L481.38,195.22 L485.74,194.64 L490.10,194.16 L494.46,193.80 L498.82,192.99 L503.18,192.52 L507.54,191.99 L511.90,191.50 L516.26,190.02 L520.62,187.74 L524.97,183.58 L529.33,176.57 L533.69,169.67 L538.05,162.99 L542.41,156.34 L546.77,151.98 L551.13,149.72 L555.49,147.31 L559.85,144.95 L564.21,142.81 L568.56,140.76 L572.92,138.15 L577.28,136.79 L581.64,136.23 L586.00,135.69 L590.36,134.91 L594.72,133.77 L599.08,132.79 L603.44,131.90 L607.79,131.13 L612.15,130.65 L616.51,130.15 L620.87,129.58 L625.23,129.03 L629.59,128.64 L633.95,128.12 L638.31,127.79 L642.67,127.56 L647.03,127.18 L651.38,126.83 L655.74,126.43 L660.10,126.02 L664.46,125.63 L668.82,125.39 L673.18,125.14 L677.54,124.87 L681.90,124.70 L686.26,124.42 L690.62,124.25 L694.97,123.77 L699.33,123.44 L703.69,123.16 L708.05,122.94 L712.41,122.61 L716.77,122.32 L721.13,122.01 L725.49,121.67 L729.85,121.38 L734.21,121.11 L738.56,120.78 L742.92,120.47 L747.28,120.23 L751.64,119.93 L756.00,119.68 L760.36,119.53 L764.72,119.34 L769.08,119.23 L773.44,119.05 L777.79,118.81 L782.15,118.49 L786.51,118.34 L790.87,118.15 L795.23,117.98 L799.59,117.87 L803.95,117.68 L808.31,117.57 L812.67,117.41 L817.03,117.18 L821.38,116.99 L825.74,116.74 L830.10,116.56 L834.46,116.35 L838.82,116.15 L843.18,115.82 L847.54,115.60 L851.90,115.37 L856.26,115.16 L860.62,114.92 L864.97,114.55 L869.33,114.30 L873.69,113.99 L878.05,113.67 L882.41,113.48 L886.77,113.27 L891.13,113.12 L895.49,112.85 L899.85,112.65 L904.21,112.44 L908.56,112.30 L912.92,112.09 L917.28,111.94 L921.64,111.80 L926.00,111.67 L926.00,418.00 L76.00,418.00 Z" fill="#0969da" opacity="0.12" />
|
||||
<path d="M76.00,417.99 L84.72,417.86 L89.08,417.50 L93.44,417.39 L97.79,417.21 L102.15,417.01 L106.51,416.92 L110.87,416.56 L115.23,416.35 L119.59,416.19 L123.95,415.88 L128.31,415.56 L132.67,415.26 L137.03,414.91 L141.38,414.50 L145.74,413.14 L150.10,412.16 L154.46,410.57 L158.82,409.61 L163.18,408.26 L167.54,407.05 L171.90,404.59 L176.26,400.57 L180.62,395.38 L184.97,392.38 L189.33,389.34 L193.69,385.41 L198.05,382.14 L202.41,379.58 L206.77,377.85 L211.13,376.10 L215.49,372.89 L219.85,366.67 L224.21,353.02 L228.56,339.54 L232.92,326.89 L237.28,318.30 L241.64,309.11 L246.00,303.02 L250.36,298.30 L254.72,292.25 L259.08,286.56 L263.44,280.80 L267.79,275.76 L272.15,271.94 L276.51,268.99 L280.87,265.95 L285.23,261.30 L289.59,256.93 L293.95,252.91 L298.31,249.60 L302.67,246.76 L307.03,245.01 L311.38,243.07 L315.74,240.73 L320.10,237.75 L324.46,235.56 L328.82,233.90 L333.18,232.85 L337.54,231.80 L341.90,230.07 L346.26,227.80 L350.62,225.39 L354.97,223.11 L359.33,220.88 L363.69,218.67 L368.05,217.38 L372.41,216.35 L376.77,215.23 L381.13,214.09 L385.49,213.14 L389.85,212.05 L394.21,211.08 L398.56,210.28 L402.92,209.53 L407.28,208.17 L411.64,207.05 L416.00,206.10 L420.36,205.14 L424.72,204.49 L429.08,203.90 L433.44,203.25 L437.79,202.34 L442.15,201.61 L446.51,201.00 L450.87,200.39 L455.23,199.79 L459.59,198.73 L463.95,198.21 L468.31,197.42 L472.67,196.71 L477.03,195.95 L481.38,195.22 L485.74,194.64 L490.10,194.16 L494.46,193.80 L498.82,192.99 L503.18,192.52 L507.54,191.99 L511.90,191.50 L516.26,190.02 L520.62,187.74 L524.97,183.58 L529.33,176.57 L533.69,169.67 L538.05,162.99 L542.41,156.34 L546.77,151.98 L551.13,149.72 L555.49,147.31 L559.85,144.95 L564.21,142.81 L568.56,140.76 L572.92,138.15 L577.28,136.79 L581.64,136.23 L586.00,135.69 L590.36,134.91 L594.72,133.77 L599.08,132.79 L603.44,131.90 L607.79,131.13 L612.15,130.65 L616.51,130.15 L620.87,129.58 L625.23,129.03 L629.59,128.64 L633.95,128.12 L638.31,127.79 L642.67,127.56 L647.03,127.18 L651.38,126.83 L655.74,126.43 L660.10,126.02 L664.46,125.63 L668.82,125.39 L673.18,125.14 L677.54,124.87 L681.90,124.70 L686.26,124.42 L690.62,124.25 L694.97,123.77 L699.33,123.44 L703.69,123.16 L708.05,122.94 L712.41,122.61 L716.77,122.32 L721.13,122.01 L725.49,121.67 L729.85,121.38 L734.21,121.11 L738.56,120.78 L742.92,120.47 L747.28,120.23 L751.64,119.93 L756.00,119.68 L760.36,119.53 L764.72,119.34 L769.08,119.23 L773.44,119.05 L777.79,118.81 L782.15,118.49 L786.51,118.34 L790.87,118.15 L795.23,117.98 L799.59,117.87 L803.95,117.68 L808.31,117.57 L812.67,117.41 L817.03,117.18 L821.38,116.99 L825.74,116.74 L830.10,116.56 L834.46,116.35 L838.82,116.15 L843.18,115.82 L847.54,115.60 L851.90,115.37 L856.26,115.16 L860.62,114.92 L864.97,114.55 L869.33,114.30 L873.69,113.99 L878.05,113.67 L882.41,113.48 L886.77,113.27 L891.13,113.12 L895.49,112.85 L899.85,112.65 L904.21,112.44 L908.56,112.30 L912.92,112.09 L917.28,111.94 L921.64,111.80 L926.00,111.67" fill="none" stroke="#0969da" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="926.00" cy="111.67" r="5" fill="#0969da" stroke="#ffffff" stroke-width="2" />
|
||||
<g transform="translate(646, 25)">
|
||||
<circle cx="0" cy="0" r="5" fill="#0969da" />
|
||||
<text x="12" y="5" fill="#24292f" font-size="14" font-weight="600">esengine/DeepSeek-Reasonix</text>
|
||||
<text x="12" y="24" fill="#57606a" font-size="12">36,039 stars</text>
|
||||
</g>
|
||||
<text x="926" y="466" text-anchor="end" fill="#57606a" font-size="11">Updated automatically from GitHub stargazer history · 2026-11-02 UTC</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,255 @@
|
||||
// Drives the context-maintenance E2E scenarios against the real DeepSeek API:
|
||||
// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
_ "reasonix/internal/provider/openai"
|
||||
"reasonix/internal/tool"
|
||||
"reasonix/internal/tool/builtin"
|
||||
)
|
||||
|
||||
const (
|
||||
model = "deepseek-v4-flash"
|
||||
baseURL = "https://api.deepseek.com"
|
||||
fatResults = 20
|
||||
fatBytes = 12_000
|
||||
)
|
||||
|
||||
func prov() provider.Provider {
|
||||
key := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if key == "" {
|
||||
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
p, err := provider.New("openai", provider.Config{Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func fakeGoFile(nonce string, i, size int) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "// module %s file%02d\npackage stress\n\n", nonce, i)
|
||||
line := 0
|
||||
for b.Len() < size {
|
||||
fmt.Fprintf(&b, "func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\n", nonce, i, line, line+3, line*7)
|
||||
line++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func seedSession(nonce string) *agent.Session {
|
||||
s := agent.NewSession("You are a terse coding agent reviewing a Go codebase.")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "Review every file in module " + nonce + " one by one. Keep notes short."})
|
||||
for i := 0; i < fatResults; i++ {
|
||||
id := fmt.Sprintf("c%02d", i)
|
||||
name := fmt.Sprintf("src/file%02d.go", i)
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, name)}}})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fakeGoFile(nonce, i, fatBytes)})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("Reviewed %s.", name)})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// oneShot appends a user message and runs a single completion, returning usage.
|
||||
func oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) {
|
||||
req := provider.Request{
|
||||
Messages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}),
|
||||
MaxTokens: 32,
|
||||
}
|
||||
ch, err := p.Stream(context.Background(), req)
|
||||
if err != nil {
|
||||
return provider.Usage{}, err
|
||||
}
|
||||
var u provider.Usage
|
||||
for c := range ch {
|
||||
switch c.Type {
|
||||
case provider.ChunkUsage:
|
||||
u = *c.Usage
|
||||
case provider.ChunkError:
|
||||
return u, c.Err
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type meta struct {
|
||||
SeededAt time.Time `json:"seeded_at"`
|
||||
Nonces map[string]string `json:"nonces"`
|
||||
SeedUse map[string]provider.Usage `json:"seed_usage"`
|
||||
}
|
||||
|
||||
func seed(dir string) {
|
||||
p := prov()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
m := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}}
|
||||
for _, arm := range []string{"pruned", "control"} {
|
||||
nonce := fmt.Sprintf("%s%d", arm, time.Now().UnixNano()%1_000_000)
|
||||
s := seedSession(nonce)
|
||||
u, err := oneShot(p, s.Snapshot(), "How many files have you reviewed so far? Reply with just the number.")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "seed %s: %v\n", arm, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := s.Save(filepath.Join(dir, arm+".jsonl")); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
m.Nonces[arm] = nonce
|
||||
m.SeedUse[arm] = u
|
||||
fmt.Printf("seeded %-7s prompt=%d hit=%d miss=%d\n", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
|
||||
}
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func resume(dir string) {
|
||||
p := prov()
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var m meta
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
idle := time.Since(m.SeededAt).Round(time.Minute)
|
||||
|
||||
out := map[string]provider.Usage{}
|
||||
for _, arm := range []string{"pruned", "control"} {
|
||||
s, err := agent.LoadSession(filepath.Join(dir, arm+".jsonl"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
prunes := 0
|
||||
if arm == "pruned" {
|
||||
a := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, "archive")}, event.Discard)
|
||||
st, err := a.PruneStaleToolResults()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "prune:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
prunes = st.Results
|
||||
}
|
||||
u, err := oneShot(p, s.Snapshot(), "Which file did you review first? Reply with just the path.")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "resume %s: %v\n", arm, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
out[arm] = u
|
||||
fmt.Printf("resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\n", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
|
||||
}
|
||||
b, _ := json.MarshalIndent(map[string]any{"idle": idle.String(), "resume_usage": out}, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("resume-%d.json", time.Now().Unix())), b, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
c, pr := out["control"], out["pruned"]
|
||||
if c.CacheMissTokens > 0 {
|
||||
fmt.Printf("\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\n",
|
||||
c.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens)))
|
||||
}
|
||||
}
|
||||
|
||||
// comprehension checks that the agent re-reads a file behind a prune placeholder
|
||||
// instead of hallucinating: the answer is a number that exists only in the file.
|
||||
func comprehension(trials int) {
|
||||
p := prov()
|
||||
pass := 0
|
||||
for t := 0; t < trials; t++ {
|
||||
dir, err := os.MkdirTemp("", "cm-e2e-")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
secret := fmt.Sprintf("%d", 1000+time.Now().UnixNano()%9000)
|
||||
content := "package cfg\n\n// retention floor, milliseconds\nconst cacheRetentionFloor = " + secret + "\n" + strings.Repeat("// padding line filler for prune eligibility\n", 400)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.go"), []byte(content), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s := agent.NewSession("You are a terse coding agent. Use tools when you need file contents.")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "Read config.go and note its constants."})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"config.go"}`}}})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: content})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Noted the constants in config.go."})
|
||||
for i := 0; i < 4; i++ {
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("ack %d", i)})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
|
||||
}
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
for _, tl := range (builtin.Workspace{Dir: dir}).Tools("read_file") {
|
||||
reg.Add(tl)
|
||||
}
|
||||
a := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard)
|
||||
st, err := a.PruneStaleToolResults()
|
||||
if err != nil || st.Results == 0 {
|
||||
fmt.Fprintf(os.Stderr, "trial %d: prune did not fire (st=%+v err=%v)\n", t, st, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = a.Run(context.Background(), "What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.")
|
||||
reRead, answered := false, false
|
||||
for _, msg := range s.Snapshot() {
|
||||
if msg.Role == provider.RoleTool && strings.Contains(msg.Content, "cacheRetentionFloor = "+secret) {
|
||||
reRead = true
|
||||
}
|
||||
if msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) {
|
||||
answered = true
|
||||
}
|
||||
}
|
||||
ok := err == nil && reRead && answered
|
||||
if ok {
|
||||
pass++
|
||||
}
|
||||
fmt.Printf("trial %d: re-read=%v answered=%v err=%v\n", t, reRead, answered, err)
|
||||
os.RemoveAll(dir)
|
||||
}
|
||||
fmt.Printf("\ncomprehension: %d/%d passed\n", pass, trials)
|
||||
if pass < trials {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory for seed/resume")
|
||||
trials := flag.Int("trials", 5, "comprehension trials")
|
||||
flag.Parse()
|
||||
switch flag.Arg(0) {
|
||||
case "seed":
|
||||
seed(*dir)
|
||||
case "resume":
|
||||
resume(*dir)
|
||||
case "comprehension":
|
||||
comprehension(*trials)
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [seed|resume|comprehension]")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
prompt = "Begin by reading the file story/chapter-1.md. Each chapter ends by telling you which chapter to read next; follow that trail one chapter at a time — read a chapter, find where it points you, then read that next chapter — until a chapter says the trail ends. You cannot know the next chapter without reading the current one, so do not guess or read ahead. In chapter 1 a river village is named; in the final chapter on the trail a noble House is named. When the trail ends, write a file answer.txt whose entire contents are the village name from chapter 1, a single hyphen, and the House name from the final chapter — for example Riverton-Stark — and nothing else."
|
||||
max_steps = 40
|
||||
timeout_sec = 900
|
||||
@@ -0,0 +1,4 @@
|
||||
set -e
|
||||
got=$(tr -d '[:space:]' < answer.txt | tr '[:upper:]' '[:lower:]')
|
||||
want="aldermoor-verrin"
|
||||
[ "$got" = "$want" ] || { echo "answer.txt normalized to '$got', want '$want'"; exit 1; }
|
||||
@@ -0,0 +1,220 @@
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 4 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 10 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 16 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 22 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 28 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 34 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 40 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 46 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 52 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 58 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 64 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 70 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 76 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 82 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 88 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 94 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 100 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 106 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
It was from Aldermoor, the cluster of cottages where the north and south rivers braid together, that our courier first set out.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 112 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 118 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 124 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 130 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 136 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 142 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 148 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 154 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 160 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 166 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 172 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 178 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 184 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 190 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 196 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 202 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 208 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
To pick up the trail again, the next page you must read is chapter-4.md.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 214 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 220 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
@@ -0,0 +1,220 @@
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 3 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 9 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 15 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 21 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 27 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 33 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 39 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 45 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 51 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 57 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 63 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 69 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 75 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 81 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 87 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 93 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 99 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 105 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 111 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 117 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 123 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 129 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 135 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 141 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 147 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 153 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 159 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 165 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 171 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 177 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 183 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 189 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 195 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 201 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 207 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
To pick up the trail again, the next page you must read is chapter-5.md.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 213 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 219 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
@@ -0,0 +1,220 @@
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 2 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 8 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 14 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 20 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 26 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 32 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 38 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 44 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 50 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 56 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 62 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 68 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 74 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 80 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 86 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 92 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 98 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 104 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 110 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 116 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 122 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 128 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 134 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 140 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 146 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 152 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 158 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 164 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 170 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 176 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 182 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 188 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 194 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 200 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 206 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
To pick up the trail again, the next page you must read is chapter-6.md.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 212 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 218 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
@@ -0,0 +1,220 @@
|
||||
Line 1 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 7 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 13 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 19 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 25 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 31 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 37 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 43 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 49 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 55 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 61 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 67 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 73 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 79 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 85 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 91 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 97 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 103 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 109 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 115 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 121 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 127 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 133 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 139 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 145 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 151 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 157 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 163 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 169 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 175 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 181 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 187 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 193 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 199 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 205 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
To pick up the trail again, the next page you must read is chapter-2.md.
|
||||
Line 211 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 217 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
@@ -0,0 +1,220 @@
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 6 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 12 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 18 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 24 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 30 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 36 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 42 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 48 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 54 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 60 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 66 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 72 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 78 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 84 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 90 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 96 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 102 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 108 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 114 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 120 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 126 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 132 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 138 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 144 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 150 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 156 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 162 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 168 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 174 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 180 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 186 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 192 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 198 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 204 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
To pick up the trail again, the next page you must read is chapter-3.md.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 216 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
@@ -0,0 +1,220 @@
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 5 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 11 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 17 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 23 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 29 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 35 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 41 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 47 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 53 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 59 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 65 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 71 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 77 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 83 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 89 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 95 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 101 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 107 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
With that, the ancestral seal of the Verrin was pressed into the warm wax and then, before the whole assembly, ceremonially broken.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 113 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 119 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 125 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 131 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 137 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 143 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 149 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 155 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 161 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 167 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 173 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 179 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 185 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 191 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 197 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 203 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 209 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
Here the trail ends; there is no further page to follow. You now hold both names you were sent to find: the river village of Aldermoor, where the courier began in the first chapter, and the House whose seal was broken above. Your task is complete the moment you write a file named answer.txt whose entire contents are that village name, a single hyphen, and that House name, and nothing else. Write it now.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 215 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
@@ -0,0 +1,3 @@
|
||||
prompt = "The file calc.py has a bug: add(a, b) returns the wrong result. Fix add so it returns the sum of a and b. Keep the other functions unchanged."
|
||||
max_steps = 12
|
||||
timeout_sec = 180
|
||||
@@ -0,0 +1,7 @@
|
||||
set -e
|
||||
python3 - <<'PY'
|
||||
import calc
|
||||
assert calc.add(2, 3) == 5, calc.add(2, 3)
|
||||
assert calc.add(10, 5) == 15, calc.add(10, 5)
|
||||
assert calc.mul(2, 3) == 6, calc.mul(2, 3)
|
||||
PY
|
||||
@@ -0,0 +1,6 @@
|
||||
def add(a, b):
|
||||
return a - b
|
||||
|
||||
|
||||
def mul(a, b):
|
||||
return a * b
|
||||
@@ -0,0 +1,3 @@
|
||||
prompt = "Create a file named fizzbuzz.py containing a function fizzbuzz(n) that returns the string 'Fizz' when n is divisible by 3, 'Buzz' when divisible by 5, 'FizzBuzz' when divisible by both 3 and 5, and otherwise the number as a string. Do not print anything at import time."
|
||||
max_steps = 12
|
||||
timeout_sec = 180
|
||||
@@ -0,0 +1,8 @@
|
||||
set -e
|
||||
python3 - <<'PY'
|
||||
import fizzbuzz
|
||||
assert fizzbuzz.fizzbuzz(3) == "Fizz", fizzbuzz.fizzbuzz(3)
|
||||
assert fizzbuzz.fizzbuzz(5) == "Buzz", fizzbuzz.fizzbuzz(5)
|
||||
assert fizzbuzz.fizzbuzz(15) == "FizzBuzz", fizzbuzz.fizzbuzz(15)
|
||||
assert fizzbuzz.fizzbuzz(7) == "7", fizzbuzz.fizzbuzz(7)
|
||||
PY
|
||||
@@ -0,0 +1,3 @@
|
||||
prompt = "Create a file named palindrome.py with a function is_palindrome(s) that returns True if s reads the same forwards and backwards ignoring case, spaces, and punctuation, and False otherwise. Do not print anything at import time."
|
||||
max_steps = 12
|
||||
timeout_sec = 180
|
||||
@@ -0,0 +1,7 @@
|
||||
set -e
|
||||
python3 - <<'PY'
|
||||
import palindrome
|
||||
assert palindrome.is_palindrome("Race car") is True
|
||||
assert palindrome.is_palindrome("A man, a plan, a canal: Panama") is True
|
||||
assert palindrome.is_palindrome("hello") is False
|
||||
PY
|
||||
@@ -0,0 +1,3 @@
|
||||
prompt = "This directory contains a data/ folder with three files: alpha.txt, beta.txt, and gamma.txt. Each file holds a single line of the form name=number. You MUST delegate the reading to a sub-agent: call the `task` tool exactly once, instructing the sub-agent to read all three files under data/ and report the three numbers back to you. Do NOT read the files yourself with your own tools. After the sub-agent reports the numbers, add them together and write ONLY the resulting integer (no other text, no trailing label or newline-prefixed words) to a file named result.txt in the current directory."
|
||||
max_steps = 15
|
||||
timeout_sec = 300
|
||||
@@ -0,0 +1,12 @@
|
||||
set -e
|
||||
# Exercises a fresh `task` sub-agent delegation in the headless `reasonix run`
|
||||
# path: 17 + 28 + 41 = 86. The numbers are arbitrary so the answer can only be
|
||||
# produced by actually reading the three seed files (the prompt mandates doing
|
||||
# that via the `task` tool). Before sub-agents could run without a parent
|
||||
# session, the `task` call errored here with "parent session is required".
|
||||
test -f result.txt
|
||||
got=$(tr -d '[:space:]' < result.txt)
|
||||
if [ "$got" != "86" ]; then
|
||||
echo "result.txt = '$got', want 86"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1 @@
|
||||
alpha=17
|
||||
@@ -0,0 +1 @@
|
||||
beta=28
|
||||
@@ -0,0 +1 @@
|
||||
gamma=41
|
||||
@@ -0,0 +1,9 @@
|
||||
# MCP catalog package inputs
|
||||
|
||||
Place each release-ready plugin tree in a directory named after its catalog
|
||||
entry ID. Catalog CI recomputes the deterministic package tree and manifest
|
||||
SHA-256 values from these directories before a catalog can be signed.
|
||||
|
||||
These inputs are catalog-release artifacts, not runtime plugin installs. The
|
||||
private minisign key remains only in the `MCP_CATALOG_MINISIGN_PRIVATE_KEY`
|
||||
release secret.
|
||||
@@ -0,0 +1,658 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/shellparse"
|
||||
)
|
||||
|
||||
type diffOpts struct {
|
||||
bin, model, repo, base, testCmd, profile string
|
||||
maxSteps, timeoutSec, attempts int
|
||||
}
|
||||
|
||||
type testRef struct{ name, pkg string }
|
||||
|
||||
// pinResult records whether one generated test fails when the PR's source is
|
||||
// reverted (so it pins the change) and, if so, whether it failed by assertion
|
||||
// (strong: it checks the new behavior) or only by compile error (weak: it just
|
||||
// references a symbol the PR added).
|
||||
type pinResult struct {
|
||||
testRef
|
||||
pins bool
|
||||
byAssertion bool
|
||||
}
|
||||
|
||||
// runDiff asks the agent to write tests covering what the PR changed, grades
|
||||
// them against the repo's own tests, and — because the agent is stochastic —
|
||||
// retries up to o.attempts times until a run passes, keeping the best result.
|
||||
func runDiff(o diffOpts) string {
|
||||
srcFiles := changedGoFiles(o.repo, o.base, false)
|
||||
if len(srcFiles) == 0 {
|
||||
profile := o.profile
|
||||
if profile == "" {
|
||||
profile = benchmarkProfileBaseline
|
||||
}
|
||||
return fmt.Sprintf("## 🤖 Reasonix e2e — diff test-gen (%s)\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n", profile)
|
||||
}
|
||||
pkgs := packagesOf(srcFiles)
|
||||
prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--")))
|
||||
|
||||
attempts := o.attempts
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
var best diffReport
|
||||
made := 0
|
||||
for i := 1; i <= attempts; i++ {
|
||||
if i > 1 {
|
||||
resetTree(o.repo)
|
||||
}
|
||||
r := runOnce(o, srcFiles, pkgs, prompt)
|
||||
made = i
|
||||
if i == 1 || better(r, best) {
|
||||
best = r
|
||||
}
|
||||
if best.passed {
|
||||
break // stop at the first passing run; attempts is a retry budget
|
||||
}
|
||||
}
|
||||
best.attempt, best.attempts = made, attempts
|
||||
return renderDiff(best)
|
||||
}
|
||||
|
||||
// runOnce does one agent run + grade: generate tests, check they pass on HEAD,
|
||||
// differential-check each against the reverted source, measure changed-line
|
||||
// coverage, and confirm the agent didn't break the build anywhere.
|
||||
func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
|
||||
metricsPath := filepath.Join(o.repo, ".e2e-diff-metrics.json")
|
||||
_ = os.Remove(metricsPath)
|
||||
defer os.Remove(metricsPath)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
args := []string{"run", "--metrics", metricsPath, "--max-steps", fmt.Sprint(o.maxSteps)}
|
||||
if o.model != "" {
|
||||
args = append(args, "--model", o.model)
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, o.profile)
|
||||
args = append(args, prompt)
|
||||
cmd := exec.CommandContext(ctx, o.bin, args...)
|
||||
cmd.Dir = o.repo
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.WaitDelay = 10 * time.Second // bound the wait for a wedged child after ctx timeout
|
||||
runErr := cmd.Run()
|
||||
|
||||
// The agent's new files are untracked, so `git diff HEAD` would miss them;
|
||||
// intent-to-add surfaces them as additions without committing.
|
||||
_ = exec.Command("git", "-C", o.repo, "add", "-AN").Run()
|
||||
|
||||
m, _ := readMetrics(metricsPath)
|
||||
testDiff := gitOut(o.repo, "diff", "HEAD", "--", "*_test.go")
|
||||
refs := parseNewTests(testDiff)
|
||||
sourceTouched := len(changedGoFilesWorktree(o.repo, false))
|
||||
testsPass, testOut := runTests(o.repo, o.testCmd, pkgs)
|
||||
|
||||
var pins []pinResult
|
||||
var mut mutationResult
|
||||
covered, coverTotal := 0, 0
|
||||
if len(refs) > 0 && testsPass {
|
||||
covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles)
|
||||
pins = differentialPerTest(o.repo, o.base, srcFiles, refs)
|
||||
mut = runMutation(o.repo, o.base, srcFiles, refs)
|
||||
}
|
||||
buildOK, buildOut := goBuildAll(o.repo)
|
||||
|
||||
passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0
|
||||
return diffReport{
|
||||
srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff),
|
||||
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
|
||||
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
|
||||
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
|
||||
passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
|
||||
}
|
||||
}
|
||||
|
||||
// better reports whether candidate a is a stronger result than b: a pass beats a
|
||||
// fail, then more assertion-pins, then more pins, then higher changed-line
|
||||
// coverage.
|
||||
func better(a, b diffReport) bool {
|
||||
if a.passed != b.passed {
|
||||
return a.passed
|
||||
}
|
||||
if x, y := countAssertionPins(a.pins), countAssertionPins(b.pins); x != y {
|
||||
return x > y
|
||||
}
|
||||
if x, y := countPins(a.pins), countPins(b.pins); x != y {
|
||||
return x > y
|
||||
}
|
||||
if a.mut.caught != b.mut.caught {
|
||||
return a.mut.caught > b.mut.caught
|
||||
}
|
||||
return ratio(a.covered, a.coverTotal) > ratio(b.covered, b.coverTotal)
|
||||
}
|
||||
|
||||
func ratio(n, d int) float64 {
|
||||
if d == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(n) / float64(d)
|
||||
}
|
||||
|
||||
// resetTree restores the PR-head tree between attempts, dropping the previous
|
||||
// attempt's generated tests but keeping the provider config the workflow wrote.
|
||||
func resetTree(repo string) {
|
||||
_ = exec.Command("git", "-C", repo, "checkout", "--", ".").Run()
|
||||
_ = exec.Command("git", "-C", repo, "clean", "-fd", "-e", "reasonix.toml").Run()
|
||||
}
|
||||
|
||||
func goBuildAll(repo string) (bool, string) {
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = repo
|
||||
cmd.WaitDelay = 2 * time.Minute // bound the wait if `go build` hangs
|
||||
out, err := cmd.CombinedOutput()
|
||||
return err == nil, string(out)
|
||||
}
|
||||
|
||||
func buildDiffPrompt(srcFiles, pkgs []string, diffText string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are in a Go repository. This pull request changed these source files:\n")
|
||||
for _, f := range srcFiles {
|
||||
fmt.Fprintf(&b, " - %s\n", f)
|
||||
}
|
||||
b.WriteString("\nUnified diff of the change:\n```diff\n")
|
||||
b.WriteString(diffText)
|
||||
b.WriteString("\n```\n\n")
|
||||
b.WriteString("Write focused Go unit tests that exercise the NEW or CHANGED behavior in those files. ")
|
||||
b.WriteString("Add them to the appropriate *_test.go files in the same packages (")
|
||||
b.WriteString(strings.Join(pkgs, ", "))
|
||||
b.WriteString("). Do NOT modify the non-test source files — only add or extend test files. ")
|
||||
b.WriteString("Prefer small, focused edits and run `gofmt`/`go vet` on the test files as you go to avoid syntax errors. ")
|
||||
b.WriteString("Then run the package tests and iterate until they pass. When finished, list the test functions you added.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type diffReport struct {
|
||||
srcFiles, pkgs []string
|
||||
addedTestLines int
|
||||
newTests []testRef
|
||||
sourceTouched int
|
||||
testsPass bool
|
||||
pins []pinResult
|
||||
mut mutationResult
|
||||
covered, coverTotal int
|
||||
buildOK bool
|
||||
buildOut string
|
||||
failing []string
|
||||
passed bool
|
||||
profile string
|
||||
attempt, attempts int
|
||||
m runMetrics
|
||||
runErr error
|
||||
testOut string
|
||||
testDiff string
|
||||
}
|
||||
|
||||
func renderDiff(r diffReport) string {
|
||||
var b strings.Builder
|
||||
result := "❌ fail"
|
||||
if r.passed {
|
||||
result = "✅ pass"
|
||||
}
|
||||
profile := r.profile
|
||||
if profile == "" {
|
||||
profile = benchmarkProfileBaseline
|
||||
}
|
||||
fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen (%s)\n\n", profile)
|
||||
fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs))
|
||||
|
||||
pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins)
|
||||
fmt.Fprintf(&b, "| Metric | Value |\n|---|---|\n")
|
||||
fmt.Fprintf(&b, "| New test functions added | %d |\n", len(r.newTests))
|
||||
fmt.Fprintf(&b, "| Test lines added | +%d |\n", r.addedTestLines)
|
||||
fmt.Fprintf(&b, "| `go test` on affected pkgs | %s |\n", passFail(r.testsPass))
|
||||
fmt.Fprintf(&b, "| Differential (fail on pre-PR code) | %s |\n", differentialCell(r))
|
||||
if pinned > 0 {
|
||||
fmt.Fprintf(&b, "| ↳ pin by assertion / by compile only | %d / %d |\n", byAssert, pinned-byAssert)
|
||||
}
|
||||
fmt.Fprintf(&b, "| Changed-line coverage | %s |\n", coverageCell(r))
|
||||
fmt.Fprintf(&b, "| Mutation (changed funcs caught) | %s |\n", mutationCell(r))
|
||||
fmt.Fprintf(&b, "| `go build ./...` (regression) | %s |\n", passFail(r.buildOK))
|
||||
fmt.Fprintf(&b, "| Non-test source touched by agent | %d file(s) |\n", r.sourceTouched)
|
||||
fmt.Fprintf(&b, "| Cache hit | %s |\n", pct(r.m.CacheHitTokens, r.m.CacheHitTokens+r.m.CacheMissTokens))
|
||||
fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens))
|
||||
fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps)
|
||||
fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost)
|
||||
if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 {
|
||||
fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes)
|
||||
fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines)
|
||||
fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall)
|
||||
fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries)
|
||||
if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 {
|
||||
fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs)
|
||||
}
|
||||
}
|
||||
if len(r.failing) > 0 {
|
||||
fmt.Fprintf(&b, "| Failing tests | `%s` |\n", strings.Join(r.failing, "`, `"))
|
||||
}
|
||||
if r.attempts > 1 {
|
||||
status := "none passed"
|
||||
if r.passed {
|
||||
status = "passed"
|
||||
}
|
||||
fmt.Fprintf(&b, "| Attempts | %d of up to %d (%s) |\n", r.attempt, r.attempts, status)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "\n**Packages:** %s\n", strings.Join(r.pkgs, ", "))
|
||||
if r.attempts <= 1 {
|
||||
fmt.Fprintf(&b, "\n<sub>Single stochastic run — a green result is one sample, not a guarantee. Comment `/e2e diff x3` to retry up to 3×.</sub>\n")
|
||||
}
|
||||
if !r.buildOK && strings.TrimSpace(r.buildOut) != "" {
|
||||
fmt.Fprintf(&b, "\n<details><summary>go build ./... output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.buildOut, 40))
|
||||
}
|
||||
if r.sourceTouched > 0 {
|
||||
fmt.Fprintf(&b, "\n⚠️ The agent modified %d non-test source file(s); a green run may not reflect the PR's code. Review the diff.\n", r.sourceTouched)
|
||||
}
|
||||
|
||||
if len(r.pins) > 0 {
|
||||
fmt.Fprintf(&b, "\n<details><summary>Per-test differential</summary>\n\n| Test | Package | Pins the change? |\n|---|---|---|\n")
|
||||
for _, p := range r.pins {
|
||||
fmt.Fprintf(&b, "| `%s` | %s | %s |\n", p.name, p.pkg, pinCell(p))
|
||||
}
|
||||
fmt.Fprintf(&b, "\n</details>\n")
|
||||
}
|
||||
if strings.TrimSpace(r.testDiff) != "" {
|
||||
fmt.Fprintf(&b, "\n<details><summary>Generated tests (review the assertions)</summary>\n\n```diff\n%s\n```\n</details>\n", truncateFor(r.testDiff, 20000))
|
||||
}
|
||||
if !r.testsPass && strings.TrimSpace(r.testOut) != "" {
|
||||
fmt.Fprintf(&b, "\n<details><summary>go test output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.testOut, 60))
|
||||
}
|
||||
if r.runErr != nil {
|
||||
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func differentialCell(r diffReport) string {
|
||||
if !(len(r.newTests) > 0 && r.testsPass) {
|
||||
return "n/a (tests not green)"
|
||||
}
|
||||
return fmt.Sprintf("%d/%d new tests", countPins(r.pins), len(r.pins))
|
||||
}
|
||||
|
||||
func coverageCell(r diffReport) string {
|
||||
if r.coverTotal == 0 {
|
||||
return "n/a"
|
||||
}
|
||||
return fmt.Sprintf("%s (%d/%d changed lines)", pct(r.covered, r.coverTotal), r.covered, r.coverTotal)
|
||||
}
|
||||
|
||||
func mutationCell(r diffReport) string {
|
||||
if r.mut.total == 0 {
|
||||
return "n/a"
|
||||
}
|
||||
cell := fmt.Sprintf("%d/%d (%s)", r.mut.caught, r.mut.total, pct(r.mut.caught, r.mut.total))
|
||||
if len(r.mut.survivors) > 0 {
|
||||
cell += fmt.Sprintf(" · survived: `%s`", strings.Join(r.mut.survivors, "`, `"))
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func pinCell(p pinResult) string {
|
||||
switch {
|
||||
case p.pins && p.byAssertion:
|
||||
return "✅ by assertion"
|
||||
case p.pins:
|
||||
return "⚠️ by compile only"
|
||||
default:
|
||||
return "❌ no (passes on old code)"
|
||||
}
|
||||
}
|
||||
|
||||
// differentialPerTest reverts the PR's changed source to base (deleting files
|
||||
// new in the PR), runs each generated test on its own against the old code, and
|
||||
// restores the source. A test that fails on the old code pins the change.
|
||||
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult {
|
||||
for _, f := range srcFiles {
|
||||
if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil {
|
||||
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
|
||||
}
|
||||
}
|
||||
// Restore source even on panic; a tree left on `base` would mask the PR for later steps.
|
||||
restored := false
|
||||
defer func() {
|
||||
if restored {
|
||||
return
|
||||
}
|
||||
for _, f := range srcFiles {
|
||||
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
|
||||
}
|
||||
}()
|
||||
|
||||
out := make([]pinResult, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
|
||||
cmd.Dir = repo
|
||||
cmd.WaitDelay = 2 * time.Minute // bound the wait for a hung test
|
||||
raw, err := cmd.CombinedOutput()
|
||||
out = append(out, pinResult{
|
||||
testRef: r,
|
||||
pins: err != nil,
|
||||
byAssertion: strings.Contains(string(raw), "--- FAIL: "+r.name),
|
||||
})
|
||||
}
|
||||
for _, f := range srcFiles {
|
||||
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
|
||||
}
|
||||
restored = true
|
||||
return out
|
||||
}
|
||||
|
||||
// changedLineCoverage runs the affected packages with a coverage profile and
|
||||
// reports how many of the PR's changed source statement-lines the (new+existing)
|
||||
// tests actually execute. covered/total are over changed lines that fall inside
|
||||
// a coverage block; lines that aren't statements are ignored.
|
||||
func changedLineCoverage(repo, base string, pkgs, srcFiles []string) (covered, total int) {
|
||||
profile := filepath.Join(repo, ".e2e-cover.out")
|
||||
defer os.Remove(profile)
|
||||
args := append([]string{"test", "-covermode=set", "-coverprofile=" + profile, "-coverpkg=" + strings.Join(pkgs, ",")}, pkgs...)
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir = repo
|
||||
_ = cmd.Run() // a non-zero exit still writes the profile for the tests that ran
|
||||
|
||||
blocks := parseCoverProfile(repo, profile)
|
||||
for file, lines := range changedLineSet(repo, base, srcFiles) {
|
||||
fileBlocks := blocks[file]
|
||||
for ln := range lines {
|
||||
for _, blk := range fileBlocks {
|
||||
if ln >= blk.start && ln <= blk.end {
|
||||
total++
|
||||
if blk.count > 0 {
|
||||
covered++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return covered, total
|
||||
}
|
||||
|
||||
type coverBlock struct {
|
||||
start, end, count int
|
||||
}
|
||||
|
||||
// parseCoverProfile reads a Go coverage profile, keyed by repo-relative file path
|
||||
// (the profile uses module-qualified paths; we match by repo-relative suffix).
|
||||
func parseCoverProfile(repo, path string) map[string][]coverBlock {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string][]coverBlock{}
|
||||
for _, ln := range strings.Split(string(data), "\n") {
|
||||
if ln == "" || strings.HasPrefix(ln, "mode:") {
|
||||
continue
|
||||
}
|
||||
colon := strings.LastIndexByte(ln, ':')
|
||||
if colon < 0 {
|
||||
continue
|
||||
}
|
||||
modPath, rest := ln[:colon], ln[colon+1:]
|
||||
var sl, sc, el, ec, nstmt, count int
|
||||
if _, err := fmt.Sscanf(rest, "%d.%d,%d.%d %d %d", &sl, &sc, &el, &ec, &nstmt, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
rel := repoRelFromModulePath(modPath)
|
||||
out[rel] = append(out[rel], coverBlock{start: sl, end: el, count: count})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// repoRelFromModulePath turns "reasonix/internal/agent/foo.go" into
|
||||
// "internal/agent/foo.go" by dropping the first path element (the module root).
|
||||
func repoRelFromModulePath(p string) string {
|
||||
// Strip the full module prefix; a generic first-segment cut mis-strips a multi-segment module path.
|
||||
prefix := "reasonix/"
|
||||
if strings.HasPrefix(p, prefix) {
|
||||
return p[len(prefix):]
|
||||
}
|
||||
if i := strings.IndexByte(p, '/'); i >= 0 {
|
||||
return p[i+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// changedLineSet returns, per repo-relative source file, the set of new line
|
||||
// numbers the PR added or changed (from a zero-context diff).
|
||||
func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]bool {
|
||||
args := append([]string{"diff", "--unified=0", base + "...HEAD", "--"}, srcFiles...)
|
||||
diff := gitOut(repo, args...)
|
||||
out := map[string]map[int]bool{}
|
||||
file := ""
|
||||
newLine := 0
|
||||
for _, ln := range strings.Split(diff, "\n") {
|
||||
// '-' (deletion) lines are intentionally unhandled: they don't advance the
|
||||
// new-side line counter, so they fall through with no case.
|
||||
switch {
|
||||
case strings.HasPrefix(ln, "+++ b/"):
|
||||
file = strings.TrimPrefix(ln, "+++ b/")
|
||||
out[file] = map[int]bool{}
|
||||
case strings.HasPrefix(ln, "@@"):
|
||||
// @@ -a,b +c,d @@ — start collecting at new-side line c.
|
||||
// Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed.
|
||||
if plus := strings.Index(ln, "+"); plus >= 0 {
|
||||
num := ln[plus+1:]
|
||||
end := len(num)
|
||||
for i := 0; i < len(num); i++ {
|
||||
if num[i] < '0' || num[i] > '9' {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Sscanf(num[:end], "%d", &newLine)
|
||||
}
|
||||
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"):
|
||||
if file != "" {
|
||||
out[file][newLine] = true
|
||||
}
|
||||
newLine++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countPins(ps []pinResult) int {
|
||||
n := 0
|
||||
for _, p := range ps {
|
||||
if p.pins {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countAssertionPins(ps []pinResult) int {
|
||||
n := 0
|
||||
for _, p := range ps {
|
||||
if p.pins && p.byAssertion {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseNewTests reads the working-tree *_test.go diff and returns the Test/Fuzz/
|
||||
// Benchmark functions the agent added, each tagged with its package directory.
|
||||
func parseNewTests(diff string) []testRef {
|
||||
var refs []testRef
|
||||
pkg := ""
|
||||
for _, ln := range strings.Split(diff, "\n") {
|
||||
if strings.HasPrefix(ln, "+++ b/") {
|
||||
pkg = "./" + filepath.ToSlash(filepath.Dir(strings.TrimPrefix(ln, "+++ b/")))
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(ln, "+") || strings.HasPrefix(ln, "+++") {
|
||||
continue
|
||||
}
|
||||
body := strings.TrimSpace(ln[1:])
|
||||
if !strings.HasPrefix(body, "func ") {
|
||||
continue
|
||||
}
|
||||
sig := strings.TrimPrefix(body, "func ")
|
||||
// Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name.
|
||||
var name string
|
||||
if sig[0] == '(' {
|
||||
close := strings.IndexByte(sig, ')')
|
||||
if close < 0 {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimSpace(sig[close+1:])
|
||||
methodParen := strings.IndexByte(rest, '(')
|
||||
if methodParen <= 0 {
|
||||
continue
|
||||
}
|
||||
name = rest[:methodParen]
|
||||
} else {
|
||||
funcParen := strings.IndexByte(sig, '(')
|
||||
if funcParen <= 0 {
|
||||
continue
|
||||
}
|
||||
name = sig[:funcParen]
|
||||
}
|
||||
if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") {
|
||||
refs = append(refs, testRef{name: name, pkg: pkg})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func countAdded(diff string) int {
|
||||
n := 0
|
||||
for _, ln := range strings.Split(diff, "\n") {
|
||||
if strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// failingTestNames pulls the names out of `--- FAIL: TestX (…)` lines.
|
||||
func failingTestNames(out string) []string {
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if !strings.HasPrefix(ln, "--- FAIL:") {
|
||||
continue
|
||||
}
|
||||
rest := strings.Fields(strings.TrimSpace(strings.TrimPrefix(ln, "--- FAIL:")))
|
||||
if len(rest) > 0 && !seen[rest[0]] {
|
||||
seen[rest[0]] = true
|
||||
names = append(names, rest[0])
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
|
||||
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
|
||||
if err != nil {
|
||||
return false, "invalid test command: " + err.Error()
|
||||
}
|
||||
fields := test.Argv
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"go", "test"}
|
||||
}
|
||||
args := append(fields[1:], pkgs...)
|
||||
cmd := exec.Command(fields[0], args...)
|
||||
cmd.Dir = repo
|
||||
if len(test.Env) > 0 {
|
||||
cmd.Env = append(os.Environ(), test.Env...)
|
||||
}
|
||||
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
|
||||
out, err := cmd.CombinedOutput()
|
||||
return err == nil, string(out)
|
||||
}
|
||||
|
||||
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
|
||||
// when includeTests is false (we want the source under test).
|
||||
func changedGoFiles(repo, base string, includeTests bool) []string {
|
||||
return filterGo(gitOut(repo, "diff", "--name-only", base+"...HEAD", "--", "*.go"), includeTests)
|
||||
}
|
||||
|
||||
func changedGoFilesWorktree(repo string, includeTests bool) []string {
|
||||
return filterGo(gitOut(repo, "diff", "--name-only", "HEAD", "--", "*.go"), includeTests)
|
||||
}
|
||||
|
||||
func filterGo(out string, includeTests bool) []string {
|
||||
var keep []string
|
||||
for _, f := range strings.Fields(strings.ReplaceAll(out, "\n", " ")) {
|
||||
if strings.HasSuffix(f, "_test.go") && !includeTests {
|
||||
continue
|
||||
}
|
||||
keep = append(keep, f)
|
||||
}
|
||||
sort.Strings(keep)
|
||||
return keep
|
||||
}
|
||||
|
||||
func packagesOf(files []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var pkgs []string
|
||||
for _, f := range files {
|
||||
dir := "./" + filepath.ToSlash(filepath.Dir(f))
|
||||
if !seen[dir] {
|
||||
seen[dir] = true
|
||||
pkgs = append(pkgs, dir)
|
||||
}
|
||||
}
|
||||
sort.Strings(pkgs)
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func gitOut(repo string, args ...string) string {
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
out, _ := cmd.Output()
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func truncate(s string) string { return truncateFor(s, 12000) }
|
||||
|
||||
func truncateFor(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
// Back the cut up to a rune boundary so we don't split a multi-byte UTF-8 rune.
|
||||
cut := max
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "\n…(truncated)…"
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func passFail(ok bool) string {
|
||||
if ok {
|
||||
return "pass"
|
||||
}
|
||||
return "fail"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
|
||||
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnv(t *testing.T) {
|
||||
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
|
||||
t.Fatalf("REASONIX_E2E_ENV = %q", got)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
|
||||
if !ok {
|
||||
t.Fatalf("runTests failed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
|
||||
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
|
||||
if ok {
|
||||
t.Fatal("runTests accepted dynamic env assignment")
|
||||
}
|
||||
if !strings.Contains(out, "invalid test command: shell expansion") {
|
||||
t.Fatalf("output = %q, want shell expansion rejection", out)
|
||||
}
|
||||
}
|
||||
|
||||
func writeE2EFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// e2ebench runs the committed e2e task suite against a real provider and emits a
|
||||
// markdown + JSON report (accuracy, cache-hit rate, token use, cost) for a PR.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
type task struct {
|
||||
ID string
|
||||
Prompt string `toml:"prompt"`
|
||||
MaxSteps int `toml:"max_steps"`
|
||||
TimeoutSec int `toml:"timeout_sec"`
|
||||
dir string
|
||||
}
|
||||
|
||||
type runMetrics struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
CacheHitTokens int `json:"cache_hit_tokens"`
|
||||
CacheMissTokens int `json:"cache_miss_tokens"`
|
||||
Steps int `json:"steps"`
|
||||
Cost float64 `json:"cost"`
|
||||
Currency string `json:"currency"`
|
||||
Compactions int `json:"compactions"`
|
||||
|
||||
// Optional Delivery capability counters (omitempty for baseline/old metrics).
|
||||
ReadinessChecks int `json:"readiness_checks,omitempty"`
|
||||
ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
|
||||
CapabilityRoutes int `json:"capability_routes,omitempty"`
|
||||
CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
|
||||
CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
|
||||
CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"`
|
||||
CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"`
|
||||
CapabilityDeclines int `json:"capability_declines,omitempty"`
|
||||
CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"`
|
||||
CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"`
|
||||
CapabilityMCPCall int `json:"capability_mcp_call,omitempty"`
|
||||
CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"`
|
||||
CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"`
|
||||
CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"`
|
||||
}
|
||||
|
||||
type result struct {
|
||||
task
|
||||
runMetrics
|
||||
Profile string `json:"profile"`
|
||||
Passed bool
|
||||
Skipped bool
|
||||
Note string
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
}
|
||||
|
||||
mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)")
|
||||
suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks/<id>/)")
|
||||
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
|
||||
model := flag.String("model", "", "provider/model name (default: config default)")
|
||||
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
|
||||
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
|
||||
outJSON := flag.String("json", "", "write the JSON report here (optional)")
|
||||
budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)")
|
||||
// diff-mode flags
|
||||
repo := flag.String("repo", ".", "repo root (diff mode)")
|
||||
base := flag.String("base", "", "base ref to diff the PR head against (diff mode)")
|
||||
testCmd := flag.String("test-cmd", "go test", "grader command run on the affected packages (diff mode)")
|
||||
maxSteps := flag.Int("max-steps", 80, "agent tool-call cap for the diff task")
|
||||
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
|
||||
attempts := flag.Int("attempts", 1, "diff mode: retry up to N times until a run passes (stochastic agent)")
|
||||
flag.Parse()
|
||||
profile, err := normalizeBenchmarkProfile(*profileFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if *mode == "diff" {
|
||||
report := runDiff(diffOpts{
|
||||
bin: *bin, model: *model, repo: *repo, base: *base,
|
||||
testCmd: *testCmd, profile: profile, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
|
||||
})
|
||||
emit(report, *outMD, "")
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := loadTasks(*suite)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "load suite:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
dir := filepath.Join(*suite, "tasks")
|
||||
if _, statErr := os.Stat(dir); statErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "no tasks found under %s: %v\n", dir, statErr)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "no tasks found under %s (the directory exists but contains no task.toml files)\n", dir)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var results []result
|
||||
total := 0
|
||||
for _, t := range tasks {
|
||||
if *budget > 0 && total >= *budget {
|
||||
results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"})
|
||||
continue
|
||||
}
|
||||
r := runTask(*bin, *model, profile, t)
|
||||
total += r.PromptTokens + r.CompletionTokens
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
report := render(results)
|
||||
if *outMD != "" {
|
||||
if err := os.WriteFile(*outMD, []byte(report), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write report:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
fmt.Print(report)
|
||||
}
|
||||
if *outJSON != "" {
|
||||
b, err := json.MarshalIndent(results, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "marshal json:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*outJSON, b, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write json:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emit(report, outMD, _ string) {
|
||||
if outMD != "" {
|
||||
if err := os.WriteFile(outMD, []byte(report), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write report:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Print(report)
|
||||
}
|
||||
|
||||
func loadTasks(suite string) ([]task, error) {
|
||||
tasksDir := filepath.Join(suite, "tasks")
|
||||
entries, err := os.ReadDir(tasksDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tasks []task
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(tasksDir, e.Name())
|
||||
var t task
|
||||
data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", e.Name(), err)
|
||||
}
|
||||
if _, err := toml.Decode(string(data), &t); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", e.Name(), err)
|
||||
}
|
||||
t.ID = e.Name()
|
||||
t.dir = dir
|
||||
if t.TimeoutSec == 0 {
|
||||
t.TimeoutSec = 240
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// runTask copies the task's seed workdir into a temp dir, runs the agent there,
|
||||
// then drops in verify.sh and runs it as the grader. The grader is added only
|
||||
// after the run so the agent can't read the answer key.
|
||||
func runTask(bin, model, profile string, t task) result {
|
||||
r := result{task: t, Profile: profile}
|
||||
|
||||
work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-")
|
||||
if err != nil {
|
||||
r.Note = "mktemp: " + err.Error()
|
||||
return r
|
||||
}
|
||||
defer os.RemoveAll(work)
|
||||
|
||||
if seed := filepath.Join(t.dir, "workdir"); dirExists(seed) {
|
||||
if err := copyDir(seed, work); err != nil {
|
||||
r.Note = "copy seed: " + err.Error()
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
metricsPath := filepath.Join(work, ".run-metrics.json")
|
||||
args := []string{"run", "--metrics", metricsPath}
|
||||
if model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
if t.MaxSteps > 0 {
|
||||
args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps))
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, profile)
|
||||
args = append(args, t.Prompt)
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
cmd.Dir = work
|
||||
cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
|
||||
runErr := cmd.Run()
|
||||
|
||||
if m, err := readMetrics(metricsPath); err == nil {
|
||||
r.runMetrics = m
|
||||
}
|
||||
if runErr != nil {
|
||||
r.Note = "run: " + runErr.Error()
|
||||
// still grade — a non-zero exit may just be a max-steps notice
|
||||
}
|
||||
|
||||
r.Passed = grade(work, t.dir)
|
||||
return r
|
||||
}
|
||||
|
||||
func grade(work, taskDir string) bool {
|
||||
verify := filepath.Join(taskDir, "verify.sh")
|
||||
if !fileExists(verify) {
|
||||
return false
|
||||
}
|
||||
dst := filepath.Join(work, "verify.sh")
|
||||
if err := copyFile(verify, dst); err != nil {
|
||||
return false
|
||||
}
|
||||
cmd := exec.Command("bash", "verify.sh")
|
||||
cmd.Dir = work
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func render(results []result) string {
|
||||
var b strings.Builder
|
||||
passed, ran := 0, 0
|
||||
var pTok, cTok, hit, miss, compacts int
|
||||
var cost float64
|
||||
currency := ""
|
||||
for _, r := range results {
|
||||
if r.Skipped {
|
||||
continue
|
||||
}
|
||||
ran++
|
||||
if r.Passed {
|
||||
passed++
|
||||
}
|
||||
pTok += r.PromptTokens
|
||||
cTok += r.CompletionTokens
|
||||
hit += r.CacheHitTokens
|
||||
miss += r.CacheMissTokens
|
||||
compacts += r.Compactions
|
||||
cost += r.Cost
|
||||
if r.Currency != "" {
|
||||
currency = r.Currency
|
||||
}
|
||||
}
|
||||
|
||||
profile := benchmarkProfileBaseline
|
||||
if len(results) > 0 && results[0].Profile != "" {
|
||||
profile = results[0].Profile
|
||||
}
|
||||
fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark (%s)\n\n", profile)
|
||||
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
|
||||
passed, ran, pct(passed, ran), pct(hit, hit+miss),
|
||||
comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost)
|
||||
|
||||
fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Compact | Cost |\n")
|
||||
fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|--------:|-----:|\n")
|
||||
for _, r := range results {
|
||||
switch {
|
||||
case r.Skipped:
|
||||
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — |\n", r.ID)
|
||||
default:
|
||||
res := "❌ fail"
|
||||
if r.Passed {
|
||||
res = "✅ pass"
|
||||
}
|
||||
fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %d | %s%.4f |\n",
|
||||
r.ID, res, r.Steps, comma(r.PromptTokens), comma(r.CompletionTokens),
|
||||
pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens),
|
||||
r.Compactions, currencySym(r.Currency), r.Cost)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n<sub>Real provider run. Cache-hit %% is cached prompt tokens / total prompt tokens.</sub>\n")
|
||||
|
||||
notes := false
|
||||
for _, r := range results {
|
||||
if r.Note != "" {
|
||||
if !notes {
|
||||
fmt.Fprintf(&b, "\n<details><summary>Notes</summary>\n\n")
|
||||
notes = true
|
||||
}
|
||||
fmt.Fprintf(&b, "- `%s`: %s\n", r.ID, r.Note)
|
||||
}
|
||||
}
|
||||
if notes {
|
||||
fmt.Fprintf(&b, "\n</details>\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func pct(n, d int) string {
|
||||
if d == 0 {
|
||||
return "n/a"
|
||||
}
|
||||
return fmt.Sprintf("%.0f%%", 100*float64(n)/float64(d))
|
||||
}
|
||||
|
||||
func comma(n int) string {
|
||||
s := fmt.Sprint(n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var out []byte
|
||||
for i, c := range []byte(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func currencySym(c string) string {
|
||||
if c == "" {
|
||||
return ""
|
||||
}
|
||||
return c + " "
|
||||
}
|
||||
|
||||
func readMetrics(path string) (runMetrics, error) {
|
||||
var m runMetrics
|
||||
b, err := fileencoding.ReadFileUTF8(path)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
return m, json.Unmarshal(b, &m)
|
||||
}
|
||||
|
||||
func dirExists(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && !fi.IsDir()
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Skip symlinks so a seed link can't leak a file from outside the seed tree.
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(src, p)
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
return copyFile(p, target)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
// Mirror the source mode so a seed's read-only / exec bit survives the copy.
|
||||
return os.Chmod(dst, info.Mode().Perm())
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/token"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxMutants = 15
|
||||
|
||||
type mutationResult struct {
|
||||
caught, total int
|
||||
survivors []string
|
||||
}
|
||||
|
||||
// runMutation gives a behavioral signal that the differential can't for additive
|
||||
// PRs: it replaces each changed function's body with a zero-value return (which
|
||||
// compiles for any signature via *new(T)), runs only the agent's new tests for
|
||||
// that package, and records whether they catch the mutation. A caught mutant
|
||||
// means a test actually asserts that function's output; a survivor means the new
|
||||
// tests don't check it.
|
||||
func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationResult {
|
||||
changed := changedLineSet(repo, base, srcFiles)
|
||||
byPkg := map[string][]string{}
|
||||
for _, r := range refs {
|
||||
byPkg[r.pkg] = append(byPkg[r.pkg], r.name)
|
||||
}
|
||||
|
||||
var res mutationResult
|
||||
for _, file := range srcFiles {
|
||||
if res.total >= maxMutants {
|
||||
break
|
||||
}
|
||||
pkg := "./" + filepath.ToSlash(filepath.Dir(file))
|
||||
tests := byPkg[pkg]
|
||||
if len(tests) == 0 {
|
||||
continue // no new tests to attribute a catch to
|
||||
}
|
||||
runRe := "^(" + strings.Join(tests, "|") + ")$"
|
||||
|
||||
abs := filepath.Join(repo, filepath.FromSlash(file))
|
||||
srcB, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, abs, srcB, 0)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
src := string(srcB)
|
||||
for _, fd := range changedFuncs(fset, f, changed[file]) {
|
||||
if res.total >= maxMutants {
|
||||
break
|
||||
}
|
||||
res.total++
|
||||
lb := fset.Position(fd.Body.Lbrace).Offset
|
||||
rb := fset.Position(fd.Body.Rbrace).Offset
|
||||
mutated := src[:lb] + mutantBody(fset, fd.Type.Results) + src[rb+1:]
|
||||
if os.WriteFile(abs, []byte(mutated), 0o644) != nil {
|
||||
res.total--
|
||||
continue
|
||||
}
|
||||
cmd := exec.Command("go", "test", "-run", runRe, pkg)
|
||||
cmd.Dir = repo
|
||||
cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test
|
||||
// Restore source even on panic; a file left mutated would corrupt the next mutant.
|
||||
restored := false
|
||||
defer func() {
|
||||
if !restored {
|
||||
_ = os.WriteFile(abs, srcB, 0o644)
|
||||
}
|
||||
}()
|
||||
caught := cmd.Run() != nil
|
||||
_ = os.WriteFile(abs, srcB, 0o644)
|
||||
restored = true
|
||||
if caught {
|
||||
res.caught++
|
||||
} else {
|
||||
res.survivors = append(res.survivors, fd.Name.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// changedFuncs returns the funcs in f whose line range overlaps a changed line.
|
||||
// main/init and nil-named decls are skipped (no meaningful return to mutate).
|
||||
func changedFuncs(fset *token.FileSet, f *ast.File, lines map[int]bool) []*ast.FuncDecl {
|
||||
var out []*ast.FuncDecl
|
||||
for _, decl := range f.Decls {
|
||||
fd, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fd.Body == nil || fd.Name == nil {
|
||||
continue
|
||||
}
|
||||
name := fd.Name.Name
|
||||
if name == "main" || name == "init" {
|
||||
continue
|
||||
}
|
||||
start := fset.Position(fd.Pos()).Line
|
||||
end := fset.Position(fd.End()).Line
|
||||
for ln := range lines {
|
||||
if ln >= start && ln <= end {
|
||||
out = append(out, fd)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mutantBody returns a replacement body that returns the zero value for each
|
||||
// result. *new(T) is the zero value of any type T, so this compiles for every
|
||||
// signature without naming the results or knowing their concrete types.
|
||||
func mutantBody(fset *token.FileSet, results *ast.FieldList) string {
|
||||
if results == nil || len(results.List) == 0 {
|
||||
return "{\n}"
|
||||
}
|
||||
var rets []string
|
||||
for _, field := range results.List {
|
||||
t := "*new(" + printType(fset, field.Type) + ")"
|
||||
n := len(field.Names)
|
||||
if n == 0 {
|
||||
n = 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
rets = append(rets, t)
|
||||
}
|
||||
}
|
||||
return "{\n\treturn " + strings.Join(rets, ", ") + "\n}"
|
||||
}
|
||||
|
||||
func printType(fset *token.FileSet, e ast.Expr) string {
|
||||
var b strings.Builder
|
||||
_ = printer.Fprint(&b, fset, e)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
benchmarkProfileBaseline = "baseline"
|
||||
benchmarkProfileDelivery = "delivery"
|
||||
)
|
||||
|
||||
func normalizeBenchmarkProfile(profile string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(profile)) {
|
||||
case "", benchmarkProfileBaseline:
|
||||
return benchmarkProfileBaseline, nil
|
||||
case benchmarkProfileDelivery:
|
||||
return benchmarkProfileDelivery, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown benchmark profile %q (want baseline or delivery)", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func appendBenchmarkProfileArgs(args []string, profile string) []string {
|
||||
if profile == benchmarkProfileDelivery {
|
||||
return append(args, "--profile", "delivery")
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendBenchmarkProfileArgsBaselineIsByteIdentical(t *testing.T) {
|
||||
args := []string{"run", "fix the bug"}
|
||||
if got := appendBenchmarkProfileArgs(args, benchmarkProfileBaseline); !reflect.DeepEqual(got, args) {
|
||||
t.Fatalf("baseline args changed: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T) {
|
||||
args := []string{"run"}
|
||||
got := appendBenchmarkProfileArgs(args, benchmarkProfileDelivery)
|
||||
want := []string{"run", "--profile", "delivery"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("delivery args = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBenchmarkProfile(t *testing.T) {
|
||||
for _, input := range []string{"", "baseline", " BASELINE "} {
|
||||
if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline {
|
||||
t.Fatalf("normalize(%q) = %q, %v", input, got, err)
|
||||
}
|
||||
}
|
||||
if got, err := normalizeBenchmarkProfile("delivery"); err != nil || got != benchmarkProfileDelivery {
|
||||
t.Fatalf("normalize(delivery) = %q, %v", got, err)
|
||||
}
|
||||
if _, err := normalizeBenchmarkProfile("fast"); err == nil {
|
||||
t.Fatal("unknown profile should fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"aead.dev/minisign"
|
||||
|
||||
"reasonix/internal/mcpcatalog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fail("usage: mcp-catalog validate|sign [flags]")
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "validate":
|
||||
validate(os.Args[2:])
|
||||
case "sign":
|
||||
sign(os.Args[2:])
|
||||
default:
|
||||
fail("unknown command %q", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func validate(args []string) {
|
||||
flags := flag.NewFlagSet("validate", flag.ExitOnError)
|
||||
indexPath := flags.String("index", "internal/mcpcatalog/catalog-v1.json", "catalog source JSON")
|
||||
packagesDir := flags.String("packages", "", "directory containing one package tree per catalog entry ID")
|
||||
signaturePath := flags.String("signature", "", "optional minisign signature to verify")
|
||||
_ = flags.Parse(args)
|
||||
data := mustRead(*indexPath)
|
||||
index, err := mcpcatalog.Parse(data)
|
||||
if err != nil {
|
||||
fail("validate catalog: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(*signaturePath) != "" {
|
||||
if _, err := mcpcatalog.Verify(data, mustRead(*signaturePath), mcpcatalog.PublicKeys); err != nil {
|
||||
fail("verify catalog signature: %v", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(*packagesDir) != "" {
|
||||
for _, entry := range index.Entries {
|
||||
root := filepath.Join(*packagesDir, entry.ID)
|
||||
digest, err := mcpcatalog.TreeSHA256(root)
|
||||
if err != nil {
|
||||
fail("hash package %s: %v", entry.ID, err)
|
||||
}
|
||||
if !strings.EqualFold(digest, entry.PackageSHA256) {
|
||||
fail("package %s digest mismatch: got %s want %s", entry.ID, digest, entry.PackageSHA256)
|
||||
}
|
||||
manifest, err := manifestDigest(root)
|
||||
if err != nil {
|
||||
fail("hash package %s manifest: %v", entry.ID, err)
|
||||
}
|
||||
if !strings.EqualFold(manifest, entry.ManifestSHA256) {
|
||||
fail("package %s manifest digest mismatch: got %s want %s", entry.ID, manifest, entry.ManifestSHA256)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("validated MCP catalog sequence %d with %d entries\n", index.Sequence, len(index.Entries))
|
||||
}
|
||||
|
||||
func sign(args []string) {
|
||||
flags := flag.NewFlagSet("sign", flag.ExitOnError)
|
||||
indexPath := flags.String("index", "internal/mcpcatalog/catalog-v1.json", "catalog source JSON")
|
||||
outputPath := flags.String("output", "internal/mcpcatalog/catalog-v1.json.minisig", "signature output")
|
||||
_ = flags.Parse(args)
|
||||
data := mustRead(*indexPath)
|
||||
if _, err := mcpcatalog.Parse(data); err != nil {
|
||||
fail("validate catalog before signing: %v", err)
|
||||
}
|
||||
keyText := os.Getenv("MCP_CATALOG_MINISIGN_PRIVATE_KEY")
|
||||
if strings.TrimSpace(keyText) == "" {
|
||||
fail("MCP_CATALOG_MINISIGN_PRIVATE_KEY is required")
|
||||
}
|
||||
key, err := minisign.DecryptKey(os.Getenv("MCP_CATALOG_MINISIGN_PASSWORD"), []byte(keyText))
|
||||
if err != nil {
|
||||
var plain minisign.PrivateKey
|
||||
if plainErr := plain.UnmarshalText([]byte(keyText)); plainErr != nil {
|
||||
fail("parse catalog signing key: encrypted=%v plain=%v", err, plainErr)
|
||||
}
|
||||
key = plain
|
||||
}
|
||||
signature := minisign.Sign(key, data)
|
||||
if err := os.WriteFile(*outputPath, signature, 0o600); err != nil {
|
||||
fail("write signature: %v", err)
|
||||
}
|
||||
if _, err := mcpcatalog.Verify(data, signature, mcpcatalog.PublicKeys); err != nil {
|
||||
_ = os.Remove(*outputPath)
|
||||
fail("new signature does not match the app key ring: %v", err)
|
||||
}
|
||||
fmt.Printf("signed %s\n", *indexPath)
|
||||
}
|
||||
|
||||
func manifestDigest(root string) (string, error) {
|
||||
for _, rel := range []string{"reasonix-plugin.json", ".codex-plugin/plugin.json", ".claude-plugin/plugin.json"} {
|
||||
body, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err == nil {
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("plugin manifest not found")
|
||||
}
|
||||
|
||||
func mustRead(path string) []byte {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
fail("read %s: %v", path, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/provider"
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
_ "reasonix/internal/provider/openai"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func runAssist(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard assist", flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root to diagnose")
|
||||
model := fs.String("model", "", "provider or provider/model used for the one-shot plan")
|
||||
network := fs.Bool("network", true, "include provider connectivity diagnostics")
|
||||
apply := fs.Bool("apply", false, "apply the validated plan after preview and confirmation")
|
||||
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
|
||||
allowProject := fs.Bool("allow-project", false, "allow a plan to quarantine project reasonix.toml")
|
||||
jsonOut := fs.Bool("json", false, "print the plan and preview as JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
report, err := repair.Diagnose(ctx, repair.DiagnoseOptions{Root: *root, Network: *network})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
plan, err := requestRepairPlan(ctx, *root, *model, report)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
|
||||
preview, err := repair.PreviewRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
if code := printJSON(struct {
|
||||
Plan repair.RepairPlan `json:"plan"`
|
||||
Preview []repair.RepairPlanPreview `json:"preview"`
|
||||
}{plan, preview}); code != 0 {
|
||||
return code
|
||||
}
|
||||
} else {
|
||||
printPlanPreview(plan, preview)
|
||||
}
|
||||
if !*apply {
|
||||
return 0
|
||||
}
|
||||
if !*yes && !confirmPlan() {
|
||||
fmt.Println("repair plan not applied")
|
||||
return 0
|
||||
}
|
||||
result, err := repair.ApplyRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(result)
|
||||
}
|
||||
for _, action := range result.Applied {
|
||||
fmt.Println("applied:", action)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runApplyPlan(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard apply-plan", flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root")
|
||||
file := fs.String("file", "", "RepairPlan JSON file")
|
||||
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
|
||||
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
|
||||
return 2
|
||||
}
|
||||
b, err := os.ReadFile(*file)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
plan, err := repair.DecodeRepairPlan(b)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
|
||||
preview, err := repair.PreviewRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if !*jsonOut {
|
||||
printPlanPreview(plan, preview)
|
||||
}
|
||||
if !*yes && !confirmPlan() {
|
||||
fmt.Println("repair plan not applied")
|
||||
return 0
|
||||
}
|
||||
result, err := repair.ApplyRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(result)
|
||||
}
|
||||
for _, action := range result.Applied {
|
||||
fmt.Println("applied:", action)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func requestRepairPlan(ctx context.Context, root, modelRef string, report repair.DiagnosticReport) (repair.RepairPlan, error) {
|
||||
cfg, err := loadAIProviderConfig(root)
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
if strings.TrimSpace(modelRef) == "" {
|
||||
modelRef = cfg.DefaultModel
|
||||
}
|
||||
entry, ok := cfg.ResolveModel(modelRef)
|
||||
if !ok {
|
||||
return repair.RepairPlan{}, fmt.Errorf("AI assistance model %q is not configured", modelRef)
|
||||
}
|
||||
if entry.RequiresAPIKey() && entry.APIKey() == "" {
|
||||
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
|
||||
}
|
||||
p, err := provider.New(entry.Kind, provider.Config{
|
||||
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
|
||||
Extra: map[string]any{
|
||||
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
|
||||
"thinking": entry.Thinking, "effort": entry.Effort, "reasoning_protocol": entry.ReasoningProtocol,
|
||||
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
|
||||
"auth_header": entry.AuthHeader, "proxy_spec": cfg.NetworkProxySpec(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
safeReport, snapshotAliases := providerSafeReportFrom(report)
|
||||
payload, err := json.Marshal(safeReport)
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
stream, err := p.Stream(ctx, provider.Request{
|
||||
Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: repairPlanSystemPrompt},
|
||||
{Role: provider.RoleUser, Content: string(payload)},
|
||||
},
|
||||
Temperature: provider.TemperaturePtr(0),
|
||||
MaxTokens: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
var out strings.Builder
|
||||
for chunk := range stream {
|
||||
switch chunk.Type {
|
||||
case provider.ChunkText:
|
||||
out.WriteString(chunk.Text)
|
||||
case provider.ChunkError:
|
||||
if chunk.Err != nil {
|
||||
return repair.RepairPlan{}, chunk.Err
|
||||
}
|
||||
}
|
||||
}
|
||||
plan, err := repair.DecodeRepairPlan([]byte(out.String()))
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
return resolveProviderSnapshotAliases(plan, snapshotAliases)
|
||||
}
|
||||
|
||||
func loadAIProviderConfig(root string) (*config.Config, error) {
|
||||
// Recovery tooling must never rewrite user configuration, so skip the
|
||||
// on-disk legacy MCP tier migration that LoadForRoot performs.
|
||||
if cfg, err := config.LoadForRootReadOnly(root); err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
if snapshots, err := repair.ListConfigSnapshots(); err == nil {
|
||||
for _, snapshot := range snapshots {
|
||||
cfg, loadErr := config.LoadForEditReadOnlyStrict(snapshot.Path)
|
||||
if loadErr == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Built-in recovery defaults resolve the global credential file without
|
||||
// reading, migrating, or rewriting malformed user/project TOML.
|
||||
return config.LoadRecoveryDefaultsForRoot(root), nil
|
||||
}
|
||||
|
||||
const repairPlanSystemPrompt = `You are a Reasonix recovery planner. Return JSON only, matching exactly:
|
||||
{"schemaVersion":1,"summary":"...","actions":[{"type":"...","scope":"","snapshotId":"","target":"","reason":"..."}]}
|
||||
The user message is a diagnostic report whose findings carry only a severity, a machine code (e.g. config.invalid_toml, derived.invalid_json, update related codes), and a generic scope; pick actions from those codes.
|
||||
Snapshot IDs are host-issued aliases ordered newest first; pendingUpdate=true means rollback_update is available.
|
||||
Allowed actions only (return an empty actions array when no safe action applies):
|
||||
- repair_config with scope global or project
|
||||
- restore_snapshot with snapshotId
|
||||
- rebuild_derived_state with target tabs, projects, window, zoom, or all
|
||||
- rollback_update with no parameters
|
||||
Never request shell commands, credential changes, session-content edits, arbitrary paths, plugin execution, or source-code changes. Prefer the smallest reversible plan supported by the diagnostics. Do not invent snapshot IDs.`
|
||||
|
||||
// providerSafeReport is the strict allowlist DTO sent to the AI provider.
|
||||
// It deliberately carries no free-form diagnostic strings or state metadata:
|
||||
// finding messages can quote config lines, URLs, commands, and permission
|
||||
// rules, while snapshot and update metadata can be edited on disk. Only fixed
|
||||
// diagnostic vocabulary, booleans, and host-issued snapshot aliases leave the
|
||||
// machine.
|
||||
type providerSafeReport struct {
|
||||
Root string `json:"root"`
|
||||
Network bool `json:"network"`
|
||||
Snapshots []providerSafeSnapshot `json:"snapshots"`
|
||||
PendingUpdate bool `json:"pendingUpdate"`
|
||||
Findings []providerSafeFinding `json:"findings"`
|
||||
}
|
||||
|
||||
type providerSafeSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type providerSafeFinding struct {
|
||||
Severity string `json:"severity"`
|
||||
Code string `json:"code"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
func providerSafeReportFrom(report repair.DiagnosticReport) (providerSafeReport, map[string]string) {
|
||||
safe := providerSafeReport{
|
||||
Root: "<project>",
|
||||
Network: report.Network,
|
||||
Snapshots: make([]providerSafeSnapshot, 0, len(report.Snapshots)),
|
||||
PendingUpdate: report.PendingUpdate != nil,
|
||||
Findings: make([]providerSafeFinding, 0, len(report.Findings)),
|
||||
}
|
||||
snapshotAliases := make(map[string]string, len(report.Snapshots))
|
||||
for _, snapshot := range report.Snapshots {
|
||||
if strings.TrimSpace(snapshot.ID) != "" {
|
||||
alias := fmt.Sprintf("snapshot-%d", len(safe.Snapshots)+1)
|
||||
safe.Snapshots = append(safe.Snapshots, providerSafeSnapshot{ID: alias})
|
||||
snapshotAliases[alias] = snapshot.ID
|
||||
}
|
||||
}
|
||||
for _, finding := range report.Findings {
|
||||
safe.Findings = append(safe.Findings, providerSafeFinding{
|
||||
Severity: providerSafeSeverity(finding.Severity),
|
||||
Code: providerSafeDiagnosticCode(finding.Code),
|
||||
Scope: providerSafeScope(finding.Scope),
|
||||
})
|
||||
}
|
||||
return safe, snapshotAliases
|
||||
}
|
||||
|
||||
func resolveProviderSnapshotAliases(plan repair.RepairPlan, aliases map[string]string) (repair.RepairPlan, error) {
|
||||
resolved := plan
|
||||
resolved.Actions = append([]repair.RepairPlanAction(nil), plan.Actions...)
|
||||
for i := range resolved.Actions {
|
||||
action := &resolved.Actions[i]
|
||||
if action.Type != "restore_snapshot" {
|
||||
continue
|
||||
}
|
||||
id, ok := aliases[action.SnapshotID]
|
||||
if !ok {
|
||||
return repair.RepairPlan{}, fmt.Errorf("AI repair plan referenced unknown snapshot alias %q", action.SnapshotID)
|
||||
}
|
||||
action.SnapshotID = id
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func providerSafeSeverity(severity string) string {
|
||||
switch severity {
|
||||
case "error", "warning", "info":
|
||||
return severity
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func providerSafeDiagnosticCode(code string) string {
|
||||
switch code {
|
||||
case "config.invalid_toml", "config.load_failed",
|
||||
"provider.missing_name", "provider.duplicate_name", "provider.unsupported_kind",
|
||||
"provider.no_models", "provider.invalid_url", "provider.invalid_models_url",
|
||||
"provider.invalid_key_name", "provider.missing_key",
|
||||
"model.no_default", "model.invalid_default",
|
||||
"plugin.missing_name", "plugin.duplicate_name", "plugin.missing_command",
|
||||
"plugin.command_missing", "plugin.invalid_url", "plugin.invalid_type",
|
||||
"permissions.conflict", "file.permissions",
|
||||
"directory.unavailable", "directory.unreadable", "directory.not_directory", "directory.not_writable",
|
||||
"derived.invalid_json",
|
||||
"network.invalid_proxy", "network.client_failed", "network.unreachable",
|
||||
"network.authentication_failed", "network.ok", "network.unexpected_status":
|
||||
return code
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// providerSafeScope generalizes a finding scope to a closed vocabulary.
|
||||
// derived:* names survive because rebuild_derived_state needs the target and
|
||||
// the suffixes are a fixed enum; provider:*/plugin:* carry user-chosen names
|
||||
// and collapse to their category; anything unrecognized becomes "other".
|
||||
func providerSafeScope(scope string) string {
|
||||
scope = strings.TrimSpace(scope)
|
||||
switch {
|
||||
case scope == "":
|
||||
return ""
|
||||
case strings.HasPrefix(scope, "derived:"):
|
||||
switch scope[len("derived:"):] {
|
||||
case "tabs", "projects", "window", "zoom":
|
||||
return scope
|
||||
}
|
||||
return "derived:other"
|
||||
case strings.HasPrefix(scope, "provider:"):
|
||||
return "provider"
|
||||
case strings.HasPrefix(scope, "plugin:"):
|
||||
return "plugin"
|
||||
}
|
||||
switch scope {
|
||||
case "global", "project", "runtime", "network", "model", "provider", "plugin", "permissions",
|
||||
"global config", "credential file", "Reasonix home", "Reasonix state directory", "project root":
|
||||
return scope
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func printPlanPreview(plan repair.RepairPlan, previews []repair.RepairPlanPreview) {
|
||||
fmt.Println("AI repair plan:", plan.Summary)
|
||||
for _, preview := range previews {
|
||||
fmt.Printf(" %d. %s\n", preview.Index, preview.Description)
|
||||
if preview.Diff != "" {
|
||||
fmt.Println(preview.Diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func confirmPlan() bool {
|
||||
fmt.Print("Apply this repair plan? [y/N] ")
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
line = strings.ToLower(strings.TrimSpace(line))
|
||||
return line == "y" || line == "yes"
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
|
||||
// contract: the payload sent to the AI provider must carry only allowlisted
|
||||
// codes, severities, and generic scopes — never diagnostic prose, which can
|
||||
// quote config lines, MCP command lines, permission rules, URLs with
|
||||
// credentials, emails, tokens, or absolute paths.
|
||||
func TestProviderSafeReportDropsUserControlledContent(t *testing.T) {
|
||||
const metadataSecret = "snapshot-metadata-secret"
|
||||
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
|
||||
secrets := []string{
|
||||
"/Users/someone/dotfiles",
|
||||
"[email protected]",
|
||||
"sk-live-Abc123Secret",
|
||||
"npx --api-key=sk-live-Abc123Secret mcp-server",
|
||||
"bash(rm -rf *)",
|
||||
"corp-internal-llm",
|
||||
"https://user:[email protected]:8080",
|
||||
metadataSecret,
|
||||
"secret.finding.code",
|
||||
"secret-severity",
|
||||
snapshotID,
|
||||
}
|
||||
report := repair.DiagnosticReport{
|
||||
GeneratedAt: metadataSecret,
|
||||
Root: "/Users/someone/dotfiles",
|
||||
Network: true,
|
||||
Snapshots: []repair.DiagnosticSnapshot{
|
||||
{ID: snapshotID, RecordedAt: metadataSecret, Version: metadataSecret},
|
||||
{ID: metadataSecret, RecordedAt: metadataSecret, Version: metadataSecret},
|
||||
},
|
||||
PendingUpdate: &repair.DiagnosticUpdate{FromVersion: metadataSecret, ToVersion: metadataSecret},
|
||||
Findings: []repair.DiagnosticFinding{
|
||||
{Severity: "error", Code: "config.invalid_toml", Scope: "global",
|
||||
Message: "Configuration cannot be parsed: line 3: api_key = \"sk-live-Abc123Secret\" contact [email protected]"},
|
||||
{Severity: "warning", Code: "plugin.command_missing", Scope: "plugin:corp-internal-llm",
|
||||
Message: `MCP command "npx --api-key=sk-live-Abc123Secret mcp-server" was not found.`},
|
||||
{Severity: "warning", Code: "permissions.conflict", Scope: "permissions",
|
||||
Message: `Permission rule "bash(rm -rf *)" appears in allow, deny; deny takes precedence.`},
|
||||
{Severity: "error", Code: "network.invalid_proxy", Scope: "network",
|
||||
Message: "Proxy configuration is invalid: parse \"https://user:[email protected]:8080\": bad"},
|
||||
{Severity: "warning", Code: "provider.missing_key", Scope: "provider:corp-internal-llm",
|
||||
Message: "The configured API key is missing."},
|
||||
{Severity: "warning", Code: "derived.invalid_json", Scope: "derived:tabs",
|
||||
Message: "Derived desktop state desktop-tabs.json is malformed."},
|
||||
{Severity: "secret-severity", Code: "secret.finding.code", Scope: metadataSecret,
|
||||
Message: metadataSecret, Remediation: metadataSecret},
|
||||
},
|
||||
}
|
||||
|
||||
safeReport, aliases := providerSafeReportFrom(report)
|
||||
payload, err := json.Marshal(safeReport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(payload)
|
||||
for _, secret := range secrets {
|
||||
if strings.Contains(body, secret) {
|
||||
t.Fatalf("outbound payload leaked %q:\n%s", secret, body)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"root":"\u003cproject\u003e"`, // json.Marshal HTML-escapes <project>
|
||||
`"code":"config.invalid_toml"`,
|
||||
`"scope":"provider"`,
|
||||
`"scope":"plugin"`,
|
||||
`"scope":"derived:tabs"`,
|
||||
`"id":"snapshot-1"`,
|
||||
`"id":"snapshot-2"`,
|
||||
`"pendingUpdate":true`,
|
||||
`"severity":"unknown"`,
|
||||
`"code":"unknown"`,
|
||||
`"scope":"other"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("outbound payload missing %s:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if aliases["snapshot-1"] != snapshotID || aliases["snapshot-2"] != metadataSecret {
|
||||
t.Fatalf("snapshot alias map = %v", aliases)
|
||||
}
|
||||
for _, forbiddenField := range []string{"generatedAt", "recordedAt", "version", "fromVersion", "toVersion"} {
|
||||
if strings.Contains(body, `"`+forbiddenField+`"`) {
|
||||
t.Fatalf("outbound payload retained free-form field %q:\n%s", forbiddenField, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderSnapshotAliases(t *testing.T) {
|
||||
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
|
||||
plan, err := repair.DecodeRepairPlan([]byte(`{
|
||||
"schemaVersion": 1,
|
||||
"summary": "restore newest snapshot",
|
||||
"actions": [
|
||||
{"type":"repair_config","scope":"global","reason":"invalid config"},
|
||||
{"type":"restore_snapshot","snapshotId":"snapshot-1","reason":"known-good config"}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := resolveProviderSnapshotAliases(plan, map[string]string{"snapshot-1": snapshotID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resolved.Actions[1].SnapshotID; got != snapshotID {
|
||||
t.Fatalf("resolved snapshot ID = %q, want %q", got, snapshotID)
|
||||
}
|
||||
if got := plan.Actions[1].SnapshotID; got != "snapshot-1" {
|
||||
t.Fatalf("alias resolution mutated source plan: %q", got)
|
||||
}
|
||||
if _, err := resolveProviderSnapshotAliases(plan, nil); err == nil {
|
||||
t.Fatal("unknown snapshot alias was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"global": "global",
|
||||
"project": "project",
|
||||
"derived:tabs": "derived:tabs",
|
||||
"derived:zoom": "derived:zoom",
|
||||
"derived:/etc/passwd": "derived:other",
|
||||
"provider:corp-llm": "provider",
|
||||
"plugin:internal-mcp": "plugin",
|
||||
"credential file": "credential file",
|
||||
"[email protected]": "other",
|
||||
"/Users/someone/project": "other",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := providerSafeScope(in); got != want {
|
||||
t.Errorf("providerSafeScope(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// Command reasonix-guard diagnoses and repairs Reasonix without loading the
|
||||
// desktop shell. It is packaged beside the desktop application so recovery
|
||||
// remains available when Wails, WebView, or user configuration cannot start.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() { os.Exit(run(os.Args[1:])) }
|
||||
|
||||
func run(args []string) int {
|
||||
if len(args) == 0 {
|
||||
return runLaunch(nil)
|
||||
}
|
||||
switch args[0] {
|
||||
case "check":
|
||||
return runCheck(args[1:], false)
|
||||
case "repair":
|
||||
return runCheck(args[1:], true)
|
||||
case "launch":
|
||||
return runLaunch(args[1:])
|
||||
case "recover":
|
||||
return runRecover(args[1:])
|
||||
case "snapshots":
|
||||
return runSnapshots(args[1:])
|
||||
case "restore":
|
||||
return runRestore(args[1:])
|
||||
case "undo":
|
||||
return runUndo(args[1:])
|
||||
case "diagnose":
|
||||
return runDiagnose(args[1:])
|
||||
case "rebuild":
|
||||
return runRebuild(args[1:])
|
||||
case "assist":
|
||||
return runAssist(args[1:])
|
||||
case "apply-plan":
|
||||
return runApplyPlan(args[1:])
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println("reasonix-guard", version)
|
||||
return 0
|
||||
case "help", "--help", "-h":
|
||||
usage()
|
||||
return 0
|
||||
default:
|
||||
if isMacBundleLauncher() {
|
||||
return runLaunch(append([]string{"--"}, args...))
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "unknown command:", args[0])
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func runDiagnose(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard diagnose", flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root to inspect")
|
||||
network := fs.Bool("network", false, "probe provider endpoints and credentials")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
report, err := repair.Diagnose(context.Background(), repair.DiagnoseOptions{Root: *root, Network: *network})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
if code := printJSON(report); code != 0 {
|
||||
return code
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Reasonix Guard diagnostics")
|
||||
if len(report.Findings) == 0 {
|
||||
fmt.Println(" ok: no issues found")
|
||||
}
|
||||
for _, finding := range report.Findings {
|
||||
fmt.Printf(" %-7s %-32s %s\n", finding.Severity, finding.Code, finding.Message)
|
||||
}
|
||||
}
|
||||
if report.HasErrors() {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runRebuild(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard rebuild", flag.ContinueOnError)
|
||||
target := fs.String("target", "", "tabs|projects|window|zoom|all")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*target) == "" {
|
||||
return 2
|
||||
}
|
||||
applied, err := repair.RebuildDerivedState(*target)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(applied)
|
||||
}
|
||||
for _, path := range applied {
|
||||
fmt.Println("quarantined:", path)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runSnapshots(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard snapshots", flag.ContinueOnError)
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
snapshots, err := repair.ListConfigSnapshots()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(snapshots)
|
||||
}
|
||||
for _, snap := range snapshots {
|
||||
fmt.Printf("%s %s %s\n", snap.ID, snap.Version, snap.RecordedAt)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runRestore(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard restore", flag.ContinueOnError)
|
||||
snapshot := fs.String("snapshot", "", "snapshot id to restore")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*snapshot) == "" {
|
||||
return 2
|
||||
}
|
||||
tx, err := repair.RestoreConfigSnapshot(*snapshot)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("restored config snapshot; undo transaction:", tx.ID)
|
||||
return 0
|
||||
}
|
||||
|
||||
func runUndo(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard undo", flag.ContinueOnError)
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
tx, err := repair.UndoLastRepair()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(tx)
|
||||
}
|
||||
fmt.Println("undid repair:", tx.ID)
|
||||
return 0
|
||||
}
|
||||
|
||||
func printJSON(v any) int {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runCheck(args []string, apply bool) int {
|
||||
name := "reasonix-guard check"
|
||||
if apply {
|
||||
name = "reasonix-guard repair"
|
||||
}
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root to inspect")
|
||||
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
report, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: apply, IncludeProject: *includeProject})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(report); err != nil {
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Reasonix Guard")
|
||||
for _, check := range report.Checks {
|
||||
status := "ok"
|
||||
if !check.Exists {
|
||||
status = "missing"
|
||||
} else if !check.Valid {
|
||||
status = "invalid"
|
||||
}
|
||||
fmt.Printf(" %-8s %-8s %s\n", check.Scope, status, check.Path)
|
||||
if check.Error != "" {
|
||||
fmt.Println(" ", check.Error)
|
||||
}
|
||||
}
|
||||
for _, action := range report.Applied {
|
||||
fmt.Println(" applied:", action)
|
||||
}
|
||||
}
|
||||
for _, check := range report.Checks {
|
||||
if check.Exists && !check.Valid {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// failedInstallBlocksLaunch decides whether startup must stop after a failed
|
||||
// RecoverFailedInstall. The failure marker means the installer may have left
|
||||
// the release unit partially replaced, so the binaries cannot be presumed
|
||||
// coherent until a rollback completes: any error with the restore unfinished
|
||||
// fails closed. MixedInstall alone is not the signal — a rollback that failed
|
||||
// before touching anything (e.g. while staging) reports MixedInstall=false yet
|
||||
// leaves the installer's half-written unit in place. Only a completed restore
|
||||
// (RolledBack, with just the marker cleanup failing) is safe to launch.
|
||||
func failedInstallBlocksLaunch(result repair.UpdateRollbackResult, err error) bool {
|
||||
return err != nil && !result.RolledBack
|
||||
}
|
||||
|
||||
func runLaunch(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard launch", flag.ContinueOnError)
|
||||
app := fs.String("app", "", "desktop executable path")
|
||||
safeMode := fs.Bool("safe-mode", false, "force Safe Mode")
|
||||
detach := fs.Bool("detach", packagedDetachedLauncher(), "start the desktop and exit the launcher")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
// An update helper that watched the installer fail records a marker (it
|
||||
// cannot roll back itself from outside the install directory). Restore the
|
||||
// previous release unit immediately instead of waiting for a crash loop.
|
||||
if result, failure, err := repair.RecoverFailedInstall(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "update rollback after failed install failed:", err)
|
||||
if failedInstallBlocksLaunch(result, err) {
|
||||
fmt.Fprintln(os.Stderr, "error: the failed installer may have left a mix of two releases and the rollback did not complete; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
|
||||
return 1
|
||||
}
|
||||
} else if failure != nil && result.RolledBack {
|
||||
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
|
||||
}
|
||||
tracker := repair.NewStartupTracker("")
|
||||
useSafeMode := *safeMode
|
||||
if !*safeMode && tracker.SafeModeRecommended() {
|
||||
if result, err := repair.RollbackPendingUpdate(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "update rollback failed:", err)
|
||||
if result.MixedInstall {
|
||||
fmt.Fprintln(os.Stderr, "error: the installation now mixes two releases; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
|
||||
return 1
|
||||
}
|
||||
// The compensated install is still a coherent (new-version) release
|
||||
// unit, so a Safe Mode boot is a sound fallback while the pending
|
||||
// transaction stays on disk for the next rollback attempt.
|
||||
useSafeMode = true
|
||||
} else if result.RolledBack {
|
||||
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after %s failed to start.\n", result.ToVersion, result.FromVersion)
|
||||
_ = tracker.MarkClean()
|
||||
} else {
|
||||
switch nativeRecoveryChoice() {
|
||||
case recoveryRepair:
|
||||
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: ".", Apply: true}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "repair failed:", err)
|
||||
}
|
||||
useSafeMode = true
|
||||
case recoverySafeMode:
|
||||
useSafeMode = true
|
||||
case recoveryQuit:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
path := strings.TrimSpace(*app)
|
||||
if path == "" {
|
||||
path = siblingDesktopExecutable()
|
||||
}
|
||||
if path == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: cannot locate reasonix-desktop; pass --app PATH")
|
||||
return 1
|
||||
}
|
||||
childArgs := append([]string(nil), fs.Args()...)
|
||||
if useSafeMode {
|
||||
childArgs = append([]string{"--safe-mode"}, childArgs...)
|
||||
}
|
||||
cmd := exec.Command(path, childArgs...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if *detach {
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exit, ok := err.(*exec.ExitError); ok {
|
||||
return exit.ExitCode()
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func packagedDetachedLauncher() bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(exe))
|
||||
return name == "reasonix-launcher.exe" || name == "reasonix.exe"
|
||||
}
|
||||
|
||||
func runRecover(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard recover", flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root to inspect")
|
||||
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
|
||||
app := fs.String("app", "", "desktop executable path")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
switch nativeRecoveryChoice() {
|
||||
case recoveryRepair:
|
||||
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: true, IncludeProject: *includeProject}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "repair failed:", err)
|
||||
return 1
|
||||
}
|
||||
launch := []string{"--safe-mode"}
|
||||
if strings.TrimSpace(*app) != "" {
|
||||
launch = append(launch, "--app", *app)
|
||||
}
|
||||
return runLaunch(launch)
|
||||
case recoverySafeMode:
|
||||
launch := []string{"--safe-mode"}
|
||||
if strings.TrimSpace(*app) != "" {
|
||||
launch = append(launch, "--app", *app)
|
||||
}
|
||||
return runLaunch(launch)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func siblingDesktopExecutable() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := "reasonix-desktop"
|
||||
if runtime.GOOS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
candidates := []string{filepath.Join(filepath.Dir(exe), name)}
|
||||
if runtime.GOOS == "darwin" {
|
||||
candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "MacOS", name)))
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isMacBundleLauncher() bool {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return false
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
return err == nil && strings.Contains(filepath.ToSlash(exe), ".app/Contents/MacOS/")
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Println("usage: reasonix-guard <check|repair|diagnose|rebuild|assist|apply-plan|launch|recover|snapshots|restore|undo> [options]")
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func TestCheckReportsInvalidProjectConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[broken\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := run([]string{"check", "--root", root, "--json"}); code != 1 {
|
||||
t.Fatalf("exit code = %d, want 1", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedInstallBlocksLaunch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result repair.UpdateRollbackResult
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "no error never blocks", result: repair.UpdateRollbackResult{}, err: nil, want: false},
|
||||
{name: "incomplete rollback fails closed", result: repair.UpdateRollbackResult{}, err: errors.New("stage failed"), want: true},
|
||||
{name: "uncompensated rollback fails closed", result: repair.UpdateRollbackResult{MixedInstall: true}, err: errors.New("restore failed"), want: true},
|
||||
{name: "completed restore with marker cleanup error launches", result: repair.UpdateRollbackResult{RolledBack: true}, err: errors.New("remove marker: permission denied"), want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := failedInstallBlocksLaunch(tc.result, tc.err); got != tc.want {
|
||||
t.Errorf("%s: failedInstallBlocksLaunch = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
type recoveryChoice int
|
||||
|
||||
const (
|
||||
recoveryQuit recoveryChoice = iota
|
||||
recoverySafeMode
|
||||
recoveryRepair
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice {
|
||||
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
|
||||
out, err := exec.Command("osascript", "-e", script).Output()
|
||||
if err != nil {
|
||||
return recoverySafeMode
|
||||
}
|
||||
switch strings.TrimSpace(string(out)) {
|
||||
case "Repair and Start":
|
||||
return recoveryRepair
|
||||
case "Safe Mode":
|
||||
return recoverySafeMode
|
||||
default:
|
||||
return recoveryQuit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice {
|
||||
if path, err := exec.LookPath("zenity"); err == nil {
|
||||
cmd := exec.Command(path, "--question", "--title=Reasonix Recovery",
|
||||
"--text=Reasonix failed to start repeatedly.",
|
||||
"--ok-label=Repair and Start", "--cancel-label=Safe Mode", "--extra-button=Quit")
|
||||
out, err := cmd.Output()
|
||||
if strings.TrimSpace(string(out)) == "Quit" {
|
||||
return recoveryQuit
|
||||
}
|
||||
if err == nil {
|
||||
return recoveryRepair
|
||||
}
|
||||
return recoverySafeMode
|
||||
}
|
||||
if path, err := exec.LookPath("kdialog"); err == nil {
|
||||
err := exec.Command(path, "--warningyesnocancel", "Reasonix failed to start repeatedly. Repair configuration and start?", "--title", "Reasonix Recovery").Run()
|
||||
if err == nil {
|
||||
return recoveryRepair
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 {
|
||||
return recoveryQuit
|
||||
}
|
||||
}
|
||||
return recoverySafeMode
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !darwin && !windows && !linux
|
||||
|
||||
package main
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice {
|
||||
script := `Add-Type -AssemblyName PresentationFramework; $r=[System.Windows.MessageBox]::Show('Reasonix failed to start repeatedly. Yes: repair configuration and start. No: start in Safe Mode. Cancel: quit.','Reasonix Recovery','YesNoCancel','Warning'); Write-Output $r`
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return recoverySafeMode
|
||||
}
|
||||
switch strings.TrimSpace(string(out)) {
|
||||
case "Yes":
|
||||
return recoveryRepair
|
||||
case "No":
|
||||
return recoverySafeMode
|
||||
default:
|
||||
return recoveryQuit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio
|
||||
// server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to
|
||||
// document the contract end-to-end (the protocol the internal/plugin client
|
||||
// drives) and to give users a working example to copy.
|
||||
//
|
||||
// Wire it up in reasonix.toml:
|
||||
//
|
||||
// [[plugins]]
|
||||
// name = "example"
|
||||
// command = "reasonix-plugin-example"
|
||||
//
|
||||
// Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount",
|
||||
// its prompt as the "/mcp__example__review" slash command, and its resource as
|
||||
// the "@example:doc://style-guide" reference.
|
||||
//
|
||||
// Protocol, one JSON object per line:
|
||||
// - initialize → {protocolVersion, capabilities, serverInfo}
|
||||
// - notifications/initialized (notification, no id) → ignored
|
||||
// - tools/list → {tools: [{name, description, inputSchema, annotations}]}
|
||||
// - tools/call {name, arguments} → {content: [{type:"text", text}], isError}
|
||||
// - prompts/list → {prompts: [{name, description, arguments}]}
|
||||
// - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]}
|
||||
// - resources/list → {resources: [{uri, name, description, mimeType}]}
|
||||
// - resources/read {uri} → {contents: [{uri, mimeType, text}]}
|
||||
//
|
||||
// Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is
|
||||
// reserved for JSON-RPC so it must never carry stray prose.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// version is overridable via -ldflags "-X main.version=...". Reported in
|
||||
// initialize's serverInfo so reasonix (and humans) can see which build is running.
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
log.SetPrefix("reasonix-plugin-example: ")
|
||||
log.SetFlags(0)
|
||||
if err := serve(os.Stdin, os.Stdout); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSON-RPC framing ---
|
||||
|
||||
type request struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
const (
|
||||
protocolVersion = "2024-11-05"
|
||||
codeMethodNotFound = -32601
|
||||
codeInvalidParams = -32602
|
||||
)
|
||||
|
||||
// serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the
|
||||
// pipe / is shutting down). Each line is one JSON-RPC message.
|
||||
func serve(in *os.File, out *os.File) error {
|
||||
r := bufio.NewReader(in)
|
||||
w := bufio.NewWriter(out)
|
||||
defer w.Flush()
|
||||
|
||||
for {
|
||||
line, err := r.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
if rerr := handleLine(line, w); rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if ferr := w.Flush(); ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil // EOF or pipe closed: clean shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleLine(line []byte, w *bufio.Writer) error {
|
||||
line = trimSpace(line)
|
||||
if len(line) == 0 {
|
||||
return nil
|
||||
}
|
||||
var req request
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
log.Printf("skipping unparseable line: %v", err)
|
||||
return nil
|
||||
}
|
||||
if req.ID == nil {
|
||||
return nil // notification (e.g. notifications/initialized): no reply
|
||||
}
|
||||
|
||||
resp := response{JSONRPC: "2.0", ID: *req.ID}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
resp.Result = map[string]any{
|
||||
"protocolVersion": protocolVersion,
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]any{},
|
||||
"prompts": map[string]any{},
|
||||
"resources": map[string]any{},
|
||||
},
|
||||
"serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version},
|
||||
}
|
||||
case "tools/list":
|
||||
resp.Result = map[string]any{"tools": toolList()}
|
||||
case "tools/call":
|
||||
resp.Result, resp.Error = callTool(req.Params)
|
||||
case "prompts/list":
|
||||
resp.Result = map[string]any{"prompts": promptList()}
|
||||
case "prompts/get":
|
||||
resp.Result, resp.Error = getPrompt(req.Params)
|
||||
case "resources/list":
|
||||
resp.Result = map[string]any{"resources": resourceList()}
|
||||
case "resources/read":
|
||||
resp.Result, resp.Error = readResource(req.Params)
|
||||
default:
|
||||
resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal response: %w", err)
|
||||
}
|
||||
if _, err := w.Write(append(b, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- tools ---
|
||||
|
||||
// toolDef is one exposed tool: its advertised metadata plus the handler. run
|
||||
// returns the text result, or an error which becomes an isError tool result the
|
||||
// model can read and adapt to.
|
||||
type toolDef struct {
|
||||
name string
|
||||
description string
|
||||
schema map[string]any
|
||||
readOnly bool
|
||||
run func(args map[string]any) (string, error)
|
||||
}
|
||||
|
||||
// tools is the registry. Both demo tools are read-only and declare it via the
|
||||
// readOnlyHint annotation, so reasonix runs them in parallel batches and (with the
|
||||
// permission layer) auto-allows them without prompting.
|
||||
var tools = []toolDef{
|
||||
{
|
||||
name: "echo",
|
||||
description: "Echo the given text back. The simplest possible proof the plugin round-trip works.",
|
||||
readOnly: true,
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to echo back"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
run: func(args map[string]any) (string, error) {
|
||||
text, ok := args["text"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("argument 'text' must be a string")
|
||||
}
|
||||
return text, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wordcount",
|
||||
description: "Count the lines, words, and bytes of the given text (like wc).",
|
||||
readOnly: true,
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to measure"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
run: func(args map[string]any) (string, error) {
|
||||
text, ok := args["text"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("argument 'text' must be a string")
|
||||
}
|
||||
return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d",
|
||||
countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func toolList() []map[string]any {
|
||||
out := make([]map[string]any, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, map[string]any{
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"inputSchema": t.schema,
|
||||
"annotations": map[string]any{
|
||||
"readOnlyHint": t.readOnly,
|
||||
"title": t.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// callTool dispatches a tools/call. A handler error is reported as an isError
|
||||
// content result (in-band, so the model sees it) rather than a JSON-RPC error;
|
||||
// JSON-RPC errors are reserved for protocol-level faults (bad params, unknown
|
||||
// tool).
|
||||
func callTool(params json.RawMessage) (any, *rpcError) {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
|
||||
}
|
||||
for _, t := range tools {
|
||||
if t.name != p.Name {
|
||||
continue
|
||||
}
|
||||
text, err := t.run(p.Arguments)
|
||||
if err != nil {
|
||||
return textResult(err.Error(), true), nil
|
||||
}
|
||||
return textResult(text, false), nil
|
||||
}
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name}
|
||||
}
|
||||
|
||||
func textResult(text string, isError bool) map[string]any {
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": text}},
|
||||
"isError": isError,
|
||||
}
|
||||
}
|
||||
|
||||
// --- prompts ---
|
||||
|
||||
// promptList advertises the server's prompts. They surface in reasonix as
|
||||
// /mcp__example__<name> slash commands.
|
||||
func promptList() []map[string]any {
|
||||
return []map[string]any{{
|
||||
"name": "review",
|
||||
"description": "Draft a focused code-review request for a file.",
|
||||
"arguments": []map[string]any{
|
||||
{"name": "path", "description": "File to review", "required": true},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// getPrompt renders a prompt into MCP messages. The returned text becomes the
|
||||
// next user turn in reasonix, so it's phrased as an instruction to the model.
|
||||
func getPrompt(params json.RawMessage) (any, *rpcError) {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]string `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
|
||||
}
|
||||
if p.Name != "review" {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name}
|
||||
}
|
||||
path := p.Arguments["path"]
|
||||
if path == "" {
|
||||
path = "the current file"
|
||||
}
|
||||
text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path)
|
||||
return map[string]any{
|
||||
"description": "Code review request",
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": map[string]any{"type": "text", "text": text}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- resources ---
|
||||
|
||||
// resourceContents is the demo resource store, keyed by uri.
|
||||
var resourceContents = map[string]string{
|
||||
"doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.",
|
||||
}
|
||||
|
||||
func resourceList() []map[string]any {
|
||||
return []map[string]any{{
|
||||
"uri": "doc://style-guide",
|
||||
"name": "Style guide",
|
||||
"description": "The project's coding style notes.",
|
||||
"mimeType": "text/plain",
|
||||
}}
|
||||
}
|
||||
|
||||
func readResource(params json.RawMessage) (any, *rpcError) {
|
||||
var p struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
|
||||
}
|
||||
text, ok := resourceContents[p.URI]
|
||||
if !ok {
|
||||
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI}
|
||||
}
|
||||
return map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"uri": p.URI, "mimeType": "text/plain", "text": text},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// countLines counts newline-separated lines, counting a final unterminated line.
|
||||
func countLines(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n := strings.Count(s, "\n")
|
||||
if !strings.HasSuffix(s, "\n") {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// trimSpace trims leading/trailing ASCII whitespace without pulling in bytes.
|
||||
func trimSpace(b []byte) []byte {
|
||||
return []byte(strings.TrimSpace(string(b)))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Command reasonix is a config- and plugin-driven coding agent CLI.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"reasonix/internal/cli"
|
||||
|
||||
// Blank imports wire compile-time built-ins into their registries.
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
_ "reasonix/internal/provider/openai"
|
||||
_ "reasonix/internal/tool/builtin"
|
||||
)
|
||||
|
||||
// version is injected at build time via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
os.Exit(cli.Run(os.Args[1:], version))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Wails build assets/output — mostly auto-generated (plists, manifests, compiled
|
||||
# binary); ignore them but keep the committed designed app icon source and PNG.
|
||||
/build/*
|
||||
!/build/appicon.png
|
||||
!/build/appicon.svg
|
||||
|
||||
# Commit only our customized per-user NSIS installer template. wails_tools.nsh and
|
||||
# everything else under build/ are regenerated by `wails build`, so stay ignored.
|
||||
# (Each `!` un-ignore needs its parent dir un-ignored first.)
|
||||
!/build/windows
|
||||
/build/windows/*
|
||||
!/build/windows/installer
|
||||
/build/windows/installer/*
|
||||
!/build/windows/installer/project.nsi
|
||||
|
||||
# Commit our hand-written hardened-runtime entitlements (scripts/desktop-build.sh
|
||||
# passes it to codesign for Developer ID signing). Unlike Info.plist it is NOT
|
||||
# regenerated by `wails build`, so it must live in git. Parent dir un-ignored first.
|
||||
!/build/darwin
|
||||
/build/darwin/*
|
||||
!/build/darwin/entitlements.plist
|
||||
!/build/darwin/icon.icns
|
||||
|
||||
# Commit the hand-written Linux .deb packaging inputs and app icon assets.
|
||||
# `wails build` does not generate these, so they must live in git. The .deb is built
|
||||
# by scripts/desktop-build.sh's linux branch via goreleaser/nfpm. Parent un-ignored first.
|
||||
!/build/linux
|
||||
/build/linux/*
|
||||
!/build/linux/nfpm.yaml
|
||||
!/build/linux/reasonix.desktop
|
||||
!/build/linux/icons
|
||||
!/build/linux/icons/**
|
||||
|
||||
# Go test binaries
|
||||
*.test
|
||||
|
||||
# Local config symlinks for `wails dev` (point at the repo-root reasonix.toml/.env
|
||||
# so the desktop app resolves the same config/keys the CLI does). Never commit.
|
||||
/reasonix.toml
|
||||
/.env
|
||||
|
||||
# `go build` binary produced in-tree (module reasonix/desktop → "desktop")
|
||||
/desktop
|
||||
# Wails dependency-change cache
|
||||
/frontend/package.json.md5
|
||||
@@ -0,0 +1,281 @@
|
||||
# Reasonix Desktop (Wails shell)
|
||||
|
||||
A native desktop window around the Reasonix Go kernel. The same
|
||||
transport-agnostic `control.Controller` that backs the chat TUI and the HTTP/SSE
|
||||
server is bound **directly** to a React webview — Go methods in, typed events
|
||||
out, no HTTP hop.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ webview (React + TS, Vite) │
|
||||
│ bridge.ts ──calls──▶ window.go.main.App.{Submit,Cancel,…} │
|
||||
│ bridge.ts ◀─events── window.runtime.EventsOn("agent:event")│
|
||||
└───────────────▲───────────────────────────┬─────────────────┘
|
||||
bound methods runtime.EventsEmit
|
||||
┌───────────────┴───────────────────────────▼─────────────────┐
|
||||
│ desktop/app.go App (bound) + eventSink (event.Sink) │
|
||||
│ desktop/main.go Wails options, window, embed frontend/dist │
|
||||
└───────────────▲───────────────────────────┬─────────────────┘
|
||||
commands │ │ typed event stream
|
||||
┌───────────────┴────────────────────────────▼────────────────┐
|
||||
│ internal/boot.Build → internal/control.Controller (kernel) │
|
||||
│ (same assembly the CLI uses: providers, tools, gate, …) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Why a nested module
|
||||
|
||||
`desktop/` is its own Go module (`module reasonix/desktop`, `replace reasonix =>
|
||||
../`). That keeps the CGO + WebKit desktop build entirely separate from the CLI's
|
||||
`CGO_ENABLED=0` single-static-binary guarantee: the parent module's `go build /
|
||||
vet / test ./...` skip this directory, while the import path stays under
|
||||
`reasonix/` so it can still import the `reasonix/internal/*` kernel.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go (matches the parent module).
|
||||
- Node + **pnpm** (`npm i -g pnpm`).
|
||||
- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
- Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2**
|
||||
runtime; Linux needs `libgtk-3-dev` plus WebKitGTK. The default build links
|
||||
against **WebKitGTK 4.0**; distros that only ship **4.1** (Fedora 40+, Ubuntu
|
||||
24.04+, Arch) build with `-tags webkit2_41` — see [Build](#build). Run
|
||||
`wails doctor` to verify.
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
wails dev # hot-reloads Go + frontend (Vite dev server)
|
||||
```
|
||||
|
||||
Frontend-only iteration without the Go side:
|
||||
|
||||
```sh
|
||||
cd desktop/frontend
|
||||
pnpm install
|
||||
pnpm dev # opens in a plain browser; bridge.ts uses the dev mock
|
||||
```
|
||||
|
||||
In a plain browser the native bindings are absent, so `bridge.ts` falls back to a
|
||||
**mock** that streams a canned turn (text + one `edit_file` tool call) through the
|
||||
exact same event contract — so layout, streaming, markdown, tool cards, and the
|
||||
diff seam can all be built without rebuilding Go.
|
||||
|
||||
## Test
|
||||
|
||||
The desktop package is a nested Go module, so parent `go test ./...` does not run
|
||||
it. Use the full lane before merging desktop changes, and the short lane for fast
|
||||
local feedback:
|
||||
|
||||
```sh
|
||||
make desktop-test # cd desktop && go test .
|
||||
make desktop-test-short # skips slow desktop integration/e2e checks
|
||||
```
|
||||
|
||||
To find the next bottleneck, rank individual test cases from the JSON stream:
|
||||
|
||||
```sh
|
||||
make desktop-test-times
|
||||
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
```
|
||||
|
||||
### Frontend UI review checklist
|
||||
|
||||
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
|
||||
component code and the CSS positioning contract:
|
||||
|
||||
- If a component uses `createPortal` plus `getBoundingClientRect()`, it must
|
||||
handle scrollable ancestors, window resize, and `visualViewport` changes.
|
||||
- Add a focused regression test when changing shared positioning primitives such
|
||||
as `AnchoredPopover`, not only the specific menu that exposed the bug.
|
||||
- Exercise at least one scrollable container path, such as Settings content, when
|
||||
manually checking dropdown or popover changes.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
wails build # → build/bin/Reasonix(.app/.exe)
|
||||
```
|
||||
|
||||
**Linux on WebKitGTK 4.1 only** (Fedora 40+, Ubuntu 24.04+, Arch — no
|
||||
`webkit2gtk-4.0` package): pass the Wails build tag so cgo links against 4.1.
|
||||
|
||||
```sh
|
||||
wails build -tags webkit2_41
|
||||
wails dev -tags webkit2_41 # same tag for hot-reload
|
||||
```
|
||||
|
||||
Fedora deps: `sudo dnf install webkit2gtk4.1-devel gtk3-devel`.
|
||||
|
||||
`frontend/dist` is generated by the build (it's git-ignored except for a
|
||||
`.gitkeep` that keeps the Go `//go:embed all:frontend/dist` compilable on a fresh
|
||||
checkout). A bare `go build` without a prior `pnpm build` produces a blank window.
|
||||
|
||||
## Releases & auto-update
|
||||
|
||||
Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
|
||||
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
|
||||
which builds on a native runner per platform (Wails can't cross-compile a
|
||||
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
|
||||
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
|
||||
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
|
||||
current desktop manifest to the matching CLI release for old clients that still
|
||||
ask GitHub's repository-wide `latest` release for it.
|
||||
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
|
||||
|
||||
```sh
|
||||
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
|
||||
```
|
||||
|
||||
The app checks `latest.json` on startup (R2 first, then the
|
||||
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
|
||||
newer version is published; **Settings → Software update** has a manual check.
|
||||
The gateway resolves only the desktop `desktop-v*` release line and never uses
|
||||
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
|
||||
not depend on homepage badge semantics. Self-update behavior by platform:
|
||||
|
||||
- **Linux / Windows** — download, verify the minisign signature, then update in
|
||||
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
|
||||
installer (no admin rights needed).
|
||||
- **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an
|
||||
in-place swap would be blocked by Gatekeeper; the banner links to the download
|
||||
page for a manual update instead.
|
||||
|
||||
### Unsigned builds — first launch
|
||||
|
||||
There are no Apple/Windows code-signing certificates yet, so a downloaded build
|
||||
trips the OS gatekeepers on first run:
|
||||
|
||||
- **macOS** — open `Reasonix-darwin-universal.dmg` and drag Reasonix into
|
||||
Applications. Gatekeeper may then report the app "is damaged" or is from an
|
||||
unidentified developer; clear the quarantine attribute and open it:
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine /Applications/Reasonix.app
|
||||
```
|
||||
- **Windows** — SmartScreen shows "Windows protected your PC". Click *More info →
|
||||
Run anyway*.
|
||||
|
||||
When Developer ID / Authenticode certificates are added, the release workflow's
|
||||
`HAS_APPLE_CERT` gate flips to the signed path and these steps go away.
|
||||
|
||||
### Verifying a download
|
||||
|
||||
Artifacts are signed with minisign (public key ID `AF12CA46F4A9EBB0`). The `.minisig`
|
||||
signature sits next to each artifact in the release; verify with the
|
||||
[minisign](https://jedisct1.github.io/minisign/) CLI:
|
||||
|
||||
```sh
|
||||
minisign -Vm Reasonix-darwin-arm64.zip \
|
||||
-P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq
|
||||
```
|
||||
|
||||
## Editor seam (Monaco / CodeMirror)
|
||||
|
||||
Code and diff rendering go through two components with stable prop contracts and a
|
||||
lazy boundary, so a heavy editor stays out of the initial bundle and dropping one
|
||||
in is a one-line change — no consumer touches:
|
||||
|
||||
| Component | Props | Default impl | Upgrade |
|
||||
|---|---|---|---|
|
||||
| `components/CodeViewer.tsx` | `EditorProps` | `editors/PlainCode.tsx` (`<pre>`) | swap the lazy import for `editors/MonacoCode` or `editors/CodeMirrorCode` |
|
||||
| `components/DiffView.tsx` | `DiffProps` | `editors/PlainDiff.tsx` (LCS line diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` |
|
||||
|
||||
```sh
|
||||
# Monaco
|
||||
pnpm add @monaco-editor/react monaco-editor
|
||||
# or CodeMirror 6
|
||||
pnpm add @uiw/react-codemirror @codemirror/lang-javascript @codemirror/merge
|
||||
```
|
||||
|
||||
Then add `editors/MonacoCode.tsx` (default-export a component taking
|
||||
`EditorProps`) and point `CodeViewer.tsx`'s `lazy(() => import(...))` at it.
|
||||
`ToolCard` already routes `edit_file` calls' `old_string`/`new_string` through
|
||||
`DiffView`, and `Markdown` routes fenced code blocks through `CodeViewer`, so
|
||||
both seams light up everywhere at once.
|
||||
|
||||
Markdown itself is currently minimal (fenced code + plain text). Upgrade path:
|
||||
`pnpm add react-markdown remark-gfm` and render in `components/Markdown.tsx`,
|
||||
keeping fenced code delegated to `CodeViewer`.
|
||||
|
||||
## Multi-platform adaptation
|
||||
|
||||
Wails is the right shell for a Go kernel (no sidecar), but a Go+webview stack uses
|
||||
the **native** webview per OS, so the rough edges are platform-specific. What's
|
||||
handled here, and what to reach for if a target misbehaves:
|
||||
|
||||
- **Linux / WebKitGTK** is the one real pain point — rendering varies by distro &
|
||||
GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node
|
||||
is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions
|
||||
that cannot access `/dev/dri`. If artifacts persist, launch with
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release;
|
||||
the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there).
|
||||
- **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can
|
||||
crash at startup (`Error 71: Protocol error`) due to an upstream WebKit
|
||||
explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179).
|
||||
Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects
|
||||
Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`.
|
||||
Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance)
|
||||
or `GDK_BACKEND=x11` (forces XWayland).
|
||||
- **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark
|
||||
setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
|
||||
WebView2 GPU acceleration by default to smoke-test blank-window reports; set
|
||||
`REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or
|
||||
off. The WebView2 shell always uses a direct connection for embedded assets
|
||||
and loopback remote-workspace pages; provider and other outbound traffic keeps
|
||||
using Reasonix's own proxy configuration. Remote Markdown images are fetched
|
||||
by the Go backend with the same proxy settings and re-served from the local
|
||||
asset origin, so WebView2 never bypasses the configured proxy for them. Image
|
||||
hosts must resolve locally to public addresses; direct, HTTP(S)-proxy, and
|
||||
SOCKS-proxy connections are pinned to those vetted IPs while preserving the
|
||||
original Host and TLS SNI. If the DOM is still not ready after 15 seconds, the
|
||||
hidden startup window is shown with a native recovery prompt.
|
||||
- **macOS / WebKit** — inset/hidden title bar (`TitleBarHiddenInset`); the CSS
|
||||
marks the top bar as an OS drag region (`--wails-draggable: drag`) and leaves
|
||||
room for the traffic lights.
|
||||
- **Theming** — colors are CSS variables gated on `prefers-color-scheme`, which all
|
||||
three webviews honor, so the UI follows the OS theme without native glue.
|
||||
- **Fonts / offline** — system font stack only; no web-font fetches, so first paint
|
||||
is instant and identical offline.
|
||||
- **First paint** — the window background is set to the dark shell color so there's
|
||||
no white flash before CSS loads (most visible on WebKitGTK).
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
desktop/
|
||||
main.go Wails options, window, embed frontend/dist
|
||||
app.go App (bound command surface) + eventSink (event.Sink → webview)
|
||||
wire.go event.Event → JSON wire form (mirrors internal/serve/wire.go)
|
||||
wails.json Wails project config (pnpm install/build/dev)
|
||||
frontend/
|
||||
src/
|
||||
lib/
|
||||
types.ts wire contract (mirrors wire.go)
|
||||
bridge.ts window.go/window.runtime wrapper + browser dev mock
|
||||
useController.ts event-stream reducer + command surface (the hook)
|
||||
components/
|
||||
Transcript, Message, ToolCard, Composer, ApprovalModal, ContextGauge,
|
||||
Markdown, CodeViewer, DiffView
|
||||
editors/ PlainCode, PlainDiff ← editor seam impls (swap targets)
|
||||
```
|
||||
|
||||
## Telemetry
|
||||
|
||||
The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
|
||||
a random install id (generated locally, tied to nothing), app version, OS,
|
||||
arch, and OS version. It exists solely to count active installs. It never
|
||||
includes conversations, API keys, file contents, or paths.
|
||||
|
||||
Opt out any time: Settings > Updates > "Anonymous usage ping", or set
|
||||
`telemetry = false` under `[desktop]` in the global config. Dev builds
|
||||
never ping. Crash and performance-pressure reports are separate and only
|
||||
ever sent when the user clicks "Send report" on the diagnostic UI.
|
||||
|
||||
Aggregate quality metrics are also enabled by default and can be disabled from
|
||||
Settings > Updates > "Share aggregate quality metrics", or by setting
|
||||
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
|
||||
counts and preference buckets; they never include conversations, prompts, keys,
|
||||
paths, base URLs, or file contents.
|
||||
+9999
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,674 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
type stubProvider struct{}
|
||||
|
||||
func (stubProvider) Name() string { return "stub" }
|
||||
|
||||
func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk, 1)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func controllerWithContent(t *testing.T, path string) *control.Controller {
|
||||
t.Helper()
|
||||
sess := agent.NewSession("system")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("session file %q never contained %q", path, want)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
tab.saveMu.Lock()
|
||||
idle := !tab.saving && !tab.saveAgain
|
||||
tab.saveMu.Unlock()
|
||||
if idle {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("autosave loop did not become idle")
|
||||
}
|
||||
|
||||
func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
|
||||
t.Helper()
|
||||
ctrl := controllerWithContent(t, path)
|
||||
tab := &WorkspaceTab{
|
||||
ID: "test_tab",
|
||||
Ctrl: ctrl,
|
||||
Scope: "global",
|
||||
WorkspaceRoot: "",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
|
||||
a := &App{
|
||||
tabs: map[string]*WorkspaceTab{"test_tab": tab},
|
||||
activeTabID: "test_tab",
|
||||
}
|
||||
tab.sink.app = a
|
||||
return a, tab
|
||||
}
|
||||
|
||||
// TestTurnDonePersistsSession proves a completed turn is written to disk without
|
||||
// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
|
||||
// nil sink ctx (no webview) must not disable persistence.
|
||||
func TestTurnDonePersistsSession(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
_, tab := appWithTab(t, path)
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
waitForFile(t, path, "remember this turn")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
|
||||
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
|
||||
// per-token event storm doesn't thrash the disk.
|
||||
func TestNonTurnDoneDoesNotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("a non-TurnDone event wrote the session file (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduleSnapshotCoalesces hammers the scheduler concurrently to prove the
|
||||
// single-flight loop neither panics nor drops the final write.
|
||||
func TestScheduleSnapshotCoalesces(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
waitForFile(t, path, "acknowledged")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
|
||||
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures := tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures == 0 {
|
||||
t.Fatal("autosave failure should be recorded and retried")
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil || !info.IsDir() {
|
||||
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove blocked dir: %v", err)
|
||||
}
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForFile(t, path, "remember this turn")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures = tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures != 0 {
|
||||
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
originalTopic := "topic_original"
|
||||
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
|
||||
t.Fatalf("set original topic title: %v", err)
|
||||
}
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
|
||||
Scope: "global",
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
Preview: "first",
|
||||
Turns: 2,
|
||||
SchemaVersion: agent.BranchMetaCountsVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBranchMeta original: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
recoveryPath := tab.Ctrl.SessionPath()
|
||||
if recoveryPath == "" || recoveryPath == originalPath {
|
||||
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
|
||||
}
|
||||
if tab.SessionPath != recoveryPath {
|
||||
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
|
||||
}
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 || saved.Tabs[0].ID != tab.ID {
|
||||
t.Fatalf("saved tabs = %+v, want recovered tab %q", saved.Tabs, tab.ID)
|
||||
}
|
||||
if got := saved.Tabs[0].SessionPath; got != recoveryPath {
|
||||
t.Fatalf("saved tab session path = %q, want recovery path %q", got, recoveryPath)
|
||||
}
|
||||
if tab.TopicID != originalTopic {
|
||||
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
|
||||
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
|
||||
}
|
||||
tabMeta := app.tabMeta(tab, true)
|
||||
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
foundOriginal := false
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.Recovered {
|
||||
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
|
||||
}
|
||||
if node.TopicID == originalTopic {
|
||||
foundOriginal = true
|
||||
}
|
||||
walk(node.Children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
if !foundOriginal {
|
||||
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
|
||||
OriginalPath: originalPath,
|
||||
BranchMeta: agent.BranchMeta{
|
||||
Name: agent.RecoveryBranchDefaultName,
|
||||
Scope: "global",
|
||||
TopicID: "topic_recovery",
|
||||
TopicTitle: "Recovery",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(recovery.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
runtimeEvents := make(chan runtimeEventEnvelope, 4)
|
||||
app := &App{
|
||||
ctx: context.Background(),
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
|
||||
runtimeEvents <- runtimeEventEnvelope{
|
||||
ctx: ctx,
|
||||
name: name,
|
||||
payload: append([]interface{}(nil), payload...),
|
||||
}
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: "topic_original",
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
app.mu.Lock()
|
||||
app.saveTabsLocked()
|
||||
app.mu.Unlock()
|
||||
|
||||
err = tab.Ctrl.Snapshot()
|
||||
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
if got := tab.Ctrl.SessionPath(); got != originalPath {
|
||||
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
|
||||
}
|
||||
if tab.SessionPath != originalPath {
|
||||
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
|
||||
}
|
||||
if tab.TopicID != "topic_original" {
|
||||
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
|
||||
}
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 || saved.Tabs[0].SessionPath != originalPath {
|
||||
t.Fatalf("saved tabs after failed recovery = %+v, want original path %q", saved.Tabs, originalPath)
|
||||
}
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for {
|
||||
select {
|
||||
case emitted := <-runtimeEvents:
|
||||
if emitted.name != "session:recovery-failed" {
|
||||
continue
|
||||
}
|
||||
if len(emitted.payload) != 1 {
|
||||
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
|
||||
}
|
||||
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
|
||||
}
|
||||
if failed.Reason != "lease_held" {
|
||||
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("session:recovery-failed event was not emitted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, _ := appWithTab(t, path)
|
||||
a.tabs["target_tab"] = &WorkspaceTab{
|
||||
ID: "target_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
a.tabOrder = []string{"test_tab", "target_tab"}
|
||||
|
||||
err := a.SetActiveTab("target_tab")
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
|
||||
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if a.activeTabID != "test_tab" {
|
||||
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
target := filepath.Join(t.TempDir(), "target.jsonl")
|
||||
sess := agent.NewSession("system")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
|
||||
if err := sess.Save(target); err != nil {
|
||||
t.Fatalf("save target: %v", err)
|
||||
}
|
||||
loaded, err := agent.LoadSession(target)
|
||||
if err != nil {
|
||||
t.Fatalf("load target: %v", err)
|
||||
}
|
||||
|
||||
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
|
||||
t.Fatalf("rebind error = %v, want persistence failure", err)
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
|
||||
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
|
||||
// longer write the session file — even when it is in flight at the moment the
|
||||
// tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
|
||||
// captured session path, so its Snapshot() call landed after DeleteSession
|
||||
// trashed the file, "resurrecting" it.
|
||||
func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
|
||||
doomed, doomedTab := appWithTab(t, path)
|
||||
// CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
|
||||
doomed.tabs["survivor_tab"] = survivor
|
||||
doomed.activeTabID = "test_tab"
|
||||
|
||||
// Write the session file once via the autosave loop, then wait for idle so
|
||||
// the next TurnDone reliably kicks off a fresh loop.
|
||||
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForFile(t, path, "acknowledged")
|
||||
waitForAutosaveIdle(t, doomedTab)
|
||||
|
||||
// Kick the autosave loop and close the tab in close succession. The loop
|
||||
// will be in flight when CloseTab runs — exactly the #4384 window.
|
||||
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
if err := doomed.CloseTab("test_tab"); err != nil {
|
||||
t.Fatalf("CloseTab: %v", err)
|
||||
}
|
||||
|
||||
// CloseTab must have returned only after the autosave loop finished. Remove
|
||||
// the file the way DeleteSession would (move to trash is just a remove here
|
||||
// since we only care that nothing rewrites the original path).
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("remove session file: %v", err)
|
||||
}
|
||||
|
||||
// Give any would-be resurrection a chance to strike. If the autosave loop
|
||||
// were still alive (the bug), the file reappears here.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
|
||||
}
|
||||
|
||||
// And the controller's session path must be cleared so no future Snapshot
|
||||
// can write either.
|
||||
if got := doomedTab.Ctrl.SessionPath(); got != "" {
|
||||
t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
|
||||
a.tabs[survivor.ID] = survivor
|
||||
a.tabOrder = []string{tab.ID, survivor.ID}
|
||||
|
||||
err := a.CloseTab(tab.ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
|
||||
t.Fatalf("CloseTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if _, ok := a.tabs[tab.ID]; !ok {
|
||||
t.Fatal("tab was removed even though its session could not be saved")
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
|
||||
// closing/drain logic is per-tab and must not leak to other tabs.
|
||||
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
|
||||
doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
|
||||
survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")
|
||||
|
||||
a, _ := appWithTab(t, doomedPath)
|
||||
survivorCtrl := controllerWithContent(t, survivorPath)
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor_tab",
|
||||
Ctrl: survivorCtrl,
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
|
||||
a.tabs["survivor_tab"] = survivor
|
||||
a.activeTabID = "test_tab"
|
||||
|
||||
survivor.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForFile(t, survivorPath, "acknowledged")
|
||||
waitForAutosaveIdle(t, survivor)
|
||||
|
||||
if err := a.CloseTab("test_tab"); err != nil {
|
||||
t.Fatalf("CloseTab: %v", err)
|
||||
}
|
||||
|
||||
if got := survivor.Ctrl.SessionPath(); got != survivorPath {
|
||||
t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
|
||||
}
|
||||
if survivor.closing {
|
||||
t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "delete-open.jsonl")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
tab := &WorkspaceTab{
|
||||
ID: "delete_open",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
Ctrl: ctrl,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"delete_open": tab},
|
||||
activeTabID: "delete_open",
|
||||
}
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteSession(path); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
}
|
||||
|
||||
if got := ctrl.SessionPath(); got != "" {
|
||||
t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("session should be in trash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_clear_removed_runtime"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trash-open-topic.jsonl")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
|
||||
CreatedAt: time.Now().Add(-time.Minute),
|
||||
UpdatedAt: time.Now(),
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Clear removed runtime",
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "trash_open",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Clear removed runtime",
|
||||
Ready: true,
|
||||
Ctrl: ctrl,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
|
||||
tabOrder: []string{"trash_open", "survivor"},
|
||||
activeTabID: "trash_open",
|
||||
}
|
||||
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("TrashTopic: %v", err)
|
||||
}
|
||||
|
||||
if got := ctrl.SessionPath(); got != "" {
|
||||
t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("topic session should be in trash: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppPlatformReturnsRuntimeGOOS(t *testing.T) {
|
||||
app := NewApp()
|
||||
|
||||
if got := app.Platform(); got != runtime.GOOS {
|
||||
t.Fatalf("Platform() = %q, want %q", got, runtime.GOOS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func carryingController(carried []provider.Message, path string) *control.Controller {
|
||||
sess := &agent.Session{}
|
||||
sess.Replace(carried)
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
|
||||
}
|
||||
|
||||
// TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
|
||||
// config change rebuilds the controller and carries the conversation forward. Each
|
||||
// rebuild must keep writing to the same file, so a run of them leaves exactly one
|
||||
// history entry — not a new identical duplicate per rebuild.
|
||||
func TestCarriedRebuildsKeepOneSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := agent.NewSessionPath(dir, "model-a")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
prevPath := ctrl.SessionPath()
|
||||
carried := ctrl.History()
|
||||
ctrl.Close()
|
||||
|
||||
newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
|
||||
ctrl = carryingController(carried, newPath)
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctrl.Close()
|
||||
|
||||
infos, err := agent.ListSessions(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(infos) != 1 {
|
||||
paths := make([]string, len(infos))
|
||||
for i, s := range infos {
|
||||
paths[i] = filepath.Base(s.Path)
|
||||
}
|
||||
t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
|
||||
|
||||
func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.SessionPath == "" {
|
||||
t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
|
||||
}
|
||||
if _, err := os.Stat(first.SessionPath); err != nil {
|
||||
t.Fatalf("pre-created blank session should exist: %v", err)
|
||||
}
|
||||
second, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
|
||||
}
|
||||
if tabs := app.ListTabs(); len(tabs) != 1 {
|
||||
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
globalRoot := globalWorkspaceRoot()
|
||||
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
|
||||
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
app.tabs["blank"] = &WorkspaceTab{
|
||||
ID: "blank",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalRoot,
|
||||
TopicID: topic.ID,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
SessionPath: sessionPath,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabOrder = []string{"blank"}
|
||||
app.activeTabID = "blank"
|
||||
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.ID != "blank" {
|
||||
t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
globalRoot := globalWorkspaceRoot()
|
||||
dir := desktopSessionDir(globalRoot)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
stubPath := filepath.Join(dir, "empty-stub.jsonl")
|
||||
if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write empty stub: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
|
||||
CreatedAt: now.Add(-time.Minute),
|
||||
UpdatedAt: now,
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalRoot,
|
||||
TopicID: topic.ID,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID != topic.ID {
|
||||
t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
before := time.Now().UnixMilli()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
after := time.Now().UnixMilli()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
|
||||
createdAt := loadTopicCreatedAt("", meta.TopicID)
|
||||
if createdAt < before || createdAt > after {
|
||||
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
|
||||
}
|
||||
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
|
||||
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
const topicID = "topic_20260704-104018_deadbeef"
|
||||
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
if got := loadTopicCreatedAt("", topicID); got != 0 {
|
||||
t.Fatalf("createdAt before reuse = %d, want 0", got)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID != topicID {
|
||||
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
|
||||
}
|
||||
|
||||
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
|
||||
if got := loadTopicCreatedAt("", topicID); got != expected {
|
||||
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab reuses an already-open project-scoped blank tab.
|
||||
|
||||
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
|
||||
}
|
||||
if tabs := app.ListTabs(); len(tabs) != 1 {
|
||||
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
tabA := waitForTabReady(t, app, first.ID)
|
||||
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
|
||||
}
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
if second.ID == first.ID {
|
||||
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
|
||||
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
|
||||
}
|
||||
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("create project A empty session: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "blank-project-b",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectB,
|
||||
SessionPath: pathA,
|
||||
}
|
||||
|
||||
if blankTabSessionPathHasNoContent(tab) {
|
||||
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
forked, err := app.Fork(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if forked.ID == "" || forked.ID == second.ID {
|
||||
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
|
||||
}
|
||||
forkTab := waitForTabReady(t, app, forked.ID)
|
||||
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(forkTab.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
if err := app.Rewind(turn, "conversation"); err != nil {
|
||||
t.Fatalf("Rewind: %v", err)
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
|
||||
t.Helper()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
t.Fatal("tab controller is required")
|
||||
}
|
||||
sys := systemPromptFrom(tab.Ctrl.History())
|
||||
if strings.TrimSpace(sys) == "" {
|
||||
t.Fatal("tab controller did not expose a system prompt")
|
||||
}
|
||||
sessionDir := tab.Ctrl.SessionDir()
|
||||
sessionPath := tab.Ctrl.SessionPath()
|
||||
workspaceRoot := tab.Ctrl.WorkspaceRoot()
|
||||
label := tab.Ctrl.Label()
|
||||
tab.Ctrl.Close()
|
||||
|
||||
sess := agent.NewSession(sys)
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{
|
||||
Runner: ag,
|
||||
Executor: ag,
|
||||
SessionDir: sessionDir,
|
||||
SessionPath: sessionPath,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Label: label,
|
||||
SystemPrompt: sys,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
tab.Ctrl = ctrl
|
||||
app.bindControllerDisplayRecorder(ctrl)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
|
||||
t.Helper()
|
||||
ctrl.SubmitUserTurn(input, input)
|
||||
waitNotRunning(t, ctrl)
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
checkpoints := ctrl.Checkpoints()
|
||||
if len(checkpoints) > 0 {
|
||||
return checkpoints[len(checkpoints)-1].Turn
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("controller did not record a checkpoint")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
|
||||
t.Fatalf("set stale auto title: %v", err)
|
||||
}
|
||||
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
meta, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if got := meta.TopicTitle; got != defaultTopicTitle {
|
||||
t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
||||
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
|
||||
t.Fatalf("title source = %q, want auto", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "Manual title")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
meta, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if got := meta.TopicTitle; got != "Manual title" {
|
||||
t.Fatalf("reused manual topic title = %q, want Manual title", got)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
|
||||
t.Fatalf("stored title = %q, want Manual title", got)
|
||||
}
|
||||
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
||||
t.Fatalf("title source = %q, want manual", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
|
||||
t.Fatalf("set stale auto title: %v", err)
|
||||
}
|
||||
activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
|
||||
reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
|
||||
app.tabs[activeTab.ID] = activeTab
|
||||
app.tabs[reusableTab.ID] = reusableTab
|
||||
app.tabOrder = []string{activeTab.ID, reusableTab.ID}
|
||||
app.activeTabID = activeTab.ID
|
||||
|
||||
titlePath := topicTitlesPath(projectRoot)
|
||||
if err := os.Remove(titlePath); err != nil {
|
||||
t.Fatalf("remove title file: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(titlePath, 0o755); err != nil {
|
||||
t.Fatalf("replace title file with directory: %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
|
||||
t.Fatal("EnsureBlankTab succeeded, want title reset error")
|
||||
}
|
||||
if got := app.activeTabID; got != activeTab.ID {
|
||||
t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab picks up an existing blank topic created in the sidebar
|
||||
// instead of creating a fresh topic, for global scope.
|
||||
|
||||
func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.TopicID != topic.ID {
|
||||
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
|
||||
}
|
||||
if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
|
||||
t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab picks up an existing blank topic created in the sidebar
|
||||
// instead of creating a fresh topic, for project scope.
|
||||
|
||||
func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.TopicID != topic.ID {
|
||||
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
|
||||
}
|
||||
var topics []string
|
||||
for _, project := range loadProjectsFile().Projects {
|
||||
if project.Root == projectRoot {
|
||||
topics = project.Topics
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(topics) != 1 {
|
||||
t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := robustTempDir(t)
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic: %v", err)
|
||||
}
|
||||
dir := desktopSessionDir(projectRoot)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
|
||||
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
|
||||
t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID == topic.ID {
|
||||
t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
|
||||
}
|
||||
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
|
||||
t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
|
||||
// into ensureTopicIndexed, whose intentional prepend clears the delete
|
||||
// tombstone and resurrects the topic the user removed.
|
||||
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
// Race product on disk: deleted topic whose default title lingered in the
|
||||
// global title map (title-only, absent from GlobalTopics, no sessions).
|
||||
tombstonedID := "topic_tombstone_blank"
|
||||
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
|
||||
t.Fatalf("set lingering title: %v", err)
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
|
||||
meta, err := NewApp().EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID == tombstonedID {
|
||||
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
|
||||
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
|
||||
}
|
||||
if containsDesktopString(f.GlobalTopics, tombstonedID) {
|
||||
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
|
||||
}
|
||||
}
|
||||
|
||||
// NewSession skips the snapshot when the current tab has no real conversation content.
|
||||
|
||||
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
path := agent.NewSessionPath(dir, "model-a")
|
||||
ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "model-a")
|
||||
|
||||
if err := app.NewSession(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ctrl.SessionPath(); got != path {
|
||||
t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
oldTopicID := "topic_old"
|
||||
oldTopicTitle := "Old topic"
|
||||
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
|
||||
sess := &agent.Session{}
|
||||
sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "model-a")
|
||||
tab := app.tabs["test"]
|
||||
tab.Scope = "project"
|
||||
tab.WorkspaceRoot = projectRoot
|
||||
tab.TopicID = oldTopicID
|
||||
tab.TopicTitle = oldTopicTitle
|
||||
tab.SessionPath = oldPath
|
||||
app.projectTreeChangedHook = func() {}
|
||||
|
||||
if err := app.NewSession(); err != nil {
|
||||
t.Fatalf("NewSession: %v", err)
|
||||
}
|
||||
if got := tab.TopicID; got == "" || got == oldTopicID {
|
||||
t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
|
||||
}
|
||||
if got := tab.TopicTitle; got != defaultTopicTitle {
|
||||
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
newPath := ctrl.SessionPath()
|
||||
if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
|
||||
t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write new session: %v", err)
|
||||
}
|
||||
if !app.maybeAutoTitleTopic(tab) {
|
||||
t.Fatalf("new session should auto-title its fresh topic")
|
||||
}
|
||||
|
||||
oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load old meta: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
|
||||
t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
|
||||
}
|
||||
newMeta, ok, err := agent.LoadBranchMeta(newPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load new meta: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
|
||||
t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := agent.NewSessionPath(dir, "model-a")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
app := NewApp()
|
||||
app.projectTreeChangedHook = func() {}
|
||||
app.setTestCtrl(ctrl, "model-a")
|
||||
tab := app.tabs["test"]
|
||||
tab.TopicID = "topic_old"
|
||||
tab.TopicTitle = "Old topic"
|
||||
|
||||
// Block desktopConfigDir-backed topic-index writes without affecting the
|
||||
// session directory, which exercises the post-NewSession repair failure path.
|
||||
if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
|
||||
t.Fatalf("mkdir desktop config parent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
|
||||
t.Fatalf("block desktop config dir: %v", err)
|
||||
}
|
||||
|
||||
if err := app.NewSession(); err != nil {
|
||||
t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
|
||||
}
|
||||
if got := tab.TopicID; got == "" || got == "topic_old" {
|
||||
t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
|
||||
}
|
||||
if got := tab.TopicTitle; got != defaultTopicTitle {
|
||||
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
|
||||
t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
|
||||
}
|
||||
}
|
||||
+10274
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppIconPNGUsesBlueFullCanvasRoundedBackground(t *testing.T) {
|
||||
f, err := os.Open("build/appicon.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertFullCanvasRoundedIcon(t, img, 1024)
|
||||
}
|
||||
|
||||
func TestWindowsICOUsesBlueFullCanvasRoundedBackground(t *testing.T) {
|
||||
for _, size := range []int{16, 24, 32, 48, 64, 256} {
|
||||
t.Run(fmt.Sprintf("%dx%d", size, size), func(t *testing.T) {
|
||||
img := decodeICOImage(t, "build/windows/icon.ico", size)
|
||||
assertFullCanvasRoundedIcon(t, img, size)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) {
|
||||
t.Helper()
|
||||
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() != size || bounds.Dy() != size {
|
||||
t.Fatalf("app icon must be square, got %dx%d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
|
||||
corners := []struct {
|
||||
name string
|
||||
x int
|
||||
y int
|
||||
}{
|
||||
{"top-left", bounds.Min.X, bounds.Min.Y},
|
||||
{"top-right", bounds.Max.X - 1, bounds.Min.Y},
|
||||
{"bottom-left", bounds.Min.X, bounds.Max.Y - 1},
|
||||
{"bottom-right", bounds.Max.X - 1, bounds.Max.Y - 1},
|
||||
}
|
||||
for _, corner := range corners {
|
||||
_, _, _, a := img.At(corner.x, corner.y).RGBA()
|
||||
if a > 0xff {
|
||||
t.Fatalf("%s corner must be transparent, alpha=%d", corner.name, a)
|
||||
}
|
||||
}
|
||||
|
||||
_, _, _, centerAlpha := img.At(bounds.Min.X+bounds.Dx()/2, bounds.Min.Y+bounds.Dy()/2).RGBA()
|
||||
if centerAlpha == 0 {
|
||||
t.Fatal("app icon center must contain visible artwork")
|
||||
}
|
||||
|
||||
edgePoints := []struct {
|
||||
name string
|
||||
x int
|
||||
y int
|
||||
}{
|
||||
{"top", bounds.Min.X + bounds.Dx()/2, bounds.Min.Y},
|
||||
{"right", bounds.Max.X - 1, bounds.Min.Y + bounds.Dy()/2},
|
||||
{"bottom", bounds.Min.X + bounds.Dx()/2, bounds.Max.Y - 1},
|
||||
{"left", bounds.Min.X, bounds.Min.Y + bounds.Dy()/2},
|
||||
}
|
||||
for _, point := range edgePoints {
|
||||
_, _, _, a := img.At(point.x, point.y).RGBA()
|
||||
if a == 0 {
|
||||
t.Fatalf("%s edge must contain visible rounded-rect background", point.name)
|
||||
}
|
||||
assertReasonixBlue(t, point.name, img.At(point.x, point.y))
|
||||
}
|
||||
}
|
||||
|
||||
func assertReasonixBlue(t *testing.T, name string, colorValue color.Color) {
|
||||
t.Helper()
|
||||
|
||||
r16, g16, b16, _ := colorValue.RGBA()
|
||||
r, g, b := uint8(r16>>8), uint8(g16>>8), uint8(b16>>8)
|
||||
if !near(r, 0x01, 2) || !near(g, 0x53, 2) || !near(b, 0xe5, 2) {
|
||||
t.Fatalf("%s edge must use Reasonix blue background, got #%02x%02x%02x", name, r, g, b)
|
||||
}
|
||||
}
|
||||
|
||||
func near(got, want uint8, tolerance uint8) bool {
|
||||
if got > want {
|
||||
return got-want <= tolerance
|
||||
}
|
||||
return want-got <= tolerance
|
||||
}
|
||||
|
||||
func decodeICOImage(t *testing.T, path string, size int) image.Image {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := bytes.NewReader(data)
|
||||
|
||||
var header struct {
|
||||
Reserved uint16
|
||||
Type uint16
|
||||
Count uint16
|
||||
}
|
||||
if err := binary.Read(r, binary.LittleEndian, &header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if header.Reserved != 0 || header.Type != 1 {
|
||||
t.Fatalf("invalid ICO header: reserved=%d type=%d", header.Reserved, header.Type)
|
||||
}
|
||||
|
||||
type iconEntry struct {
|
||||
Width uint8
|
||||
Height uint8
|
||||
ColorCount uint8
|
||||
Reserved uint8
|
||||
Planes uint16
|
||||
BitCount uint16
|
||||
BytesInRes uint32
|
||||
ImageOffset uint32
|
||||
}
|
||||
|
||||
entries := make([]iconEntry, header.Count)
|
||||
for i := range entries {
|
||||
if err := binary.Read(r, binary.LittleEndian, &entries[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
expectedSizes := map[int]bool{16: false, 24: false, 32: false, 48: false, 64: false, 256: false}
|
||||
targetIndex := -1
|
||||
for i, entry := range entries {
|
||||
width := int(entry.Width)
|
||||
height := int(entry.Height)
|
||||
if width == 0 {
|
||||
width = 256
|
||||
}
|
||||
if height == 0 {
|
||||
height = 256
|
||||
}
|
||||
if width != height {
|
||||
t.Fatalf("ICO image must be square, got %dx%d", width, height)
|
||||
}
|
||||
if _, ok := expectedSizes[width]; ok {
|
||||
expectedSizes[width] = true
|
||||
}
|
||||
if width == size {
|
||||
targetIndex = i
|
||||
}
|
||||
}
|
||||
for expectedSize, found := range expectedSizes {
|
||||
if !found {
|
||||
t.Fatalf("ICO is missing %dx%d image", expectedSize, expectedSize)
|
||||
}
|
||||
}
|
||||
if targetIndex < 0 {
|
||||
t.Fatalf("ICO is missing %dx%d image", size, size)
|
||||
}
|
||||
|
||||
entry := entries[targetIndex]
|
||||
end := int(entry.ImageOffset + entry.BytesInRes)
|
||||
if end > len(data) {
|
||||
t.Fatalf("ICO image offset exceeds file size: offset=%d size=%d file=%d", entry.ImageOffset, entry.BytesInRes, len(data))
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(data[entry.ImageOffset:end]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
|
||||
func TestWorkspaceRelativeIn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
|
||||
t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
|
||||
}
|
||||
if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
|
||||
t.Fatal("a path above the workspace must not resolve as in-tree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImageExt(t *testing.T) {
|
||||
for _, p := range []string{"a.png", "A.PNG", "b.jpeg", "c.webp"} {
|
||||
if !isImageExt(p) {
|
||||
t.Errorf("%q should be an image extension", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"notes.pdf", "main.go", "noext"} {
|
||||
if isImageExt(p) {
|
||||
t.Errorf("%q should not be an image extension", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectRoot := t.TempDir()
|
||||
if err := os.Chdir(projectRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectRoot, _ = os.Getwd()
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: projectRoot},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
|
||||
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
|
||||
if err != nil {
|
||||
t.Fatalf("SavePastedImage: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
|
||||
t.Fatalf("pasted image should be saved under active workspace: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
|
||||
t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
|
||||
}
|
||||
preview, err := app.AttachmentDataURL(got)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachmentDataURL: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(preview, "data:image/png;base64,") {
|
||||
t.Fatalf("preview = %q, want png data URL", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectA := t.TempDir()
|
||||
projectB := t.TempDir()
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
sessionDirA := desktopSessionDir(projectA)
|
||||
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
||||
t.Fatalf("mkdir project A sessions: %v", err)
|
||||
}
|
||||
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
|
||||
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
|
||||
if err != nil {
|
||||
t.Fatalf("SavePastedImage: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
|
||||
t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
|
||||
t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
|
||||
}
|
||||
if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectRoot := t.TempDir()
|
||||
if err := os.Chdir(projectRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectRoot, _ = os.Getwd()
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: projectRoot},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(projectRoot, "sub", "notes.txt")
|
||||
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := app.AttachDropped(target)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
|
||||
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectRoot := t.TempDir()
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: projectRoot},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "shot.png")
|
||||
if err := os.WriteFile(outside, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := app.AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
|
||||
t.Fatalf("got %+v, want png attachment", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
|
||||
t.Fatalf("dropped image should be saved under active workspace: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
|
||||
t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
|
||||
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
if err := os.MkdirAll(filepath.Join(cwd, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(cwd, "sub", "notes.txt")
|
||||
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := (&App{}).AttachDropped(target)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
|
||||
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
outside := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(outside, []byte("%PDF body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := (&App{}).AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
|
||||
t.Fatalf("got %+v, want copied pdf attachment", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
|
||||
if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
|
||||
t.Fatalf("got %+v, want png attachment", got)
|
||||
}
|
||||
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
|
||||
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
workspace := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "Folder With Spaces")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedOutside := outside
|
||||
if resolved, err := filepath.EvalSymlinks(outside); err == nil {
|
||||
expectedOutside = resolved
|
||||
}
|
||||
expectedDisplayPath := filepath.ToSlash(expectedOutside)
|
||||
if err := os.Chdir(workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctrl := control.New(control.Options{WorkspaceRoot: workspace})
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
|
||||
got, err := app.AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "workspace" || !got.IsDir {
|
||||
t.Fatalf("got %+v, want workspace directory ref", got)
|
||||
}
|
||||
if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
|
||||
t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
|
||||
}
|
||||
if got.DisplayPath != expectedDisplayPath {
|
||||
t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
|
||||
}
|
||||
|
||||
block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("ResolveScopedRefs errors = %v", errs)
|
||||
}
|
||||
if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
|
||||
!strings.Contains(block, "sub/") ||
|
||||
!strings.Contains(block, "sub/notes.txt") {
|
||||
t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "test/test-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"test"}
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
projectA := t.TempDir()
|
||||
projectB := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "External")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
sessionDirA := desktopSessionDir(projectA)
|
||||
sessionDirB := desktopSessionDir(projectB)
|
||||
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
||||
t.Fatalf("mkdir project A sessions: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
|
||||
t.Fatalf("mkdir project B sessions: %v", err)
|
||||
}
|
||||
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
|
||||
sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
|
||||
oldCtrl := control.New(control.Options{
|
||||
SessionDir: sessionDirB,
|
||||
SessionPath: sessionPathB,
|
||||
WorkspaceRoot: projectB,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
app := NewApp()
|
||||
app.readyHook = func() {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "project",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectB,
|
||||
TopicID: "topic_external_ref",
|
||||
TopicTitle: "External ref",
|
||||
SessionPath: sessionPathA,
|
||||
Ready: true,
|
||||
model: "test/test-model",
|
||||
Ctrl: oldCtrl,
|
||||
sink: &tabEventSink{tabID: "project", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
})
|
||||
|
||||
got, err := app.AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if tab.Ctrl == oldCtrl {
|
||||
t.Fatal("stale controller was reused for external folder ref")
|
||||
}
|
||||
if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
|
||||
}
|
||||
resolver, ok := tab.Ctrl.(interface {
|
||||
ResolveScopedRefs(context.Context, string) (string, []string)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
|
||||
}
|
||||
block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("ResolveScopedRefs errors = %v", errs)
|
||||
}
|
||||
if !strings.Contains(block, "sub/notes.txt") {
|
||||
t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
|
||||
failure := errors.New("disk unhappy")
|
||||
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
first := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if first.IsZero() {
|
||||
t.Fatal("first autosave warning did not record its timestamp")
|
||||
}
|
||||
|
||||
// Within the window: suppressed, timestamp untouched.
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
second := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !second.Equal(first) {
|
||||
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
|
||||
}
|
||||
|
||||
// Window expired: warns again.
|
||||
aged := time.Now().Add(-autosaveWarnInterval - time.Second)
|
||||
tab.saveMu.Lock()
|
||||
tab.lastAutosaveWarnAt = aged
|
||||
tab.saveMu.Unlock()
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
third := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !third.After(aged) {
|
||||
t.Fatal("expired window did not re-arm the autosave warning")
|
||||
}
|
||||
|
||||
// Explicit user actions are never debounced (state untouched).
|
||||
app.reportTabSnapshotError(tab, "changing model", failure)
|
||||
tab.saveMu.Lock()
|
||||
fourth := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !fourth.Equal(third) {
|
||||
t.Fatal("action-save warning must not consume the autosave debounce window")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 2146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user