mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 10:54:59 +00:00
Compare commits
609 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
350faa9ff5 | ||
|
|
dfae7911cc | ||
|
|
3851fea93e | ||
|
|
871101addc | ||
|
|
e04abf96a6 | ||
|
|
eee4dd9a2a | ||
|
|
1cdcd6ec60 | ||
|
|
6ab3feb35f | ||
|
|
0d9118ada2 | ||
|
|
7abe012f45 | ||
|
|
93ea9577d2 | ||
|
|
7cc8f253c7 | ||
|
|
49e25838e1 | ||
|
|
415f4077b9 | ||
|
|
c9db917422 | ||
|
|
d5428baec0 | ||
|
|
25be48454b | ||
|
|
2fa9b2ea89 | ||
|
|
3a022ddc44 | ||
|
|
dcdcb0f65d | ||
|
|
5493c022cd | ||
|
|
6e3b8bfe79 | ||
|
|
619d1bae16 | ||
|
|
87d203d2f5 | ||
|
|
ce9bc5c750 | ||
|
|
72ace38440 | ||
|
|
97ac04b968 | ||
|
|
2cbfd3b215 | ||
|
|
8ca792ad87 | ||
|
|
8a294be775 | ||
|
|
392e65d551 | ||
|
|
a38d42c005 | ||
|
|
36e079e83c | ||
|
|
c6fca49cf7 | ||
|
|
3bcf48fb5f | ||
|
|
454850db9e | ||
|
|
7fd9fd1360 | ||
|
|
f5e361ab8f | ||
|
|
06e1343958 | ||
|
|
0c1f5b35f1 | ||
|
|
0c0764c449 | ||
|
|
5a1e0d0d1a | ||
|
|
06d7fa61f8 | ||
|
|
14b6d591a5 | ||
|
|
2b28b51a73 | ||
|
|
2912e116c0 | ||
|
|
91623dba84 | ||
|
|
542d6dd760 | ||
|
|
2a581a451a | ||
|
|
93e946693d | ||
|
|
bd03e2e17c | ||
|
|
b1367aa33c | ||
|
|
9c533c4345 | ||
|
|
f3abf3642e | ||
|
|
61bf2163b5 | ||
|
|
e24b75cf3b | ||
|
|
1faff783ff | ||
|
|
eb12b67921 | ||
|
|
e7ae87f6d2 | ||
|
|
77a67f3e2d | ||
|
|
2cfb89396d | ||
|
|
ab61456774 | ||
|
|
6e84033f2e | ||
|
|
3917456bf6 | ||
|
|
da78f22fd6 | ||
|
|
e5aba49e61 | ||
|
|
0267be8dbe | ||
|
|
5c0c124860 | ||
|
|
d8b1872ba8 | ||
|
|
4401a88025 | ||
|
|
dc51cb629d | ||
|
|
53743869fc | ||
|
|
38845fab6e | ||
|
|
67915a5cee | ||
|
|
bfa8d9a686 | ||
|
|
b5483bea18 | ||
|
|
f1bbfffd11 | ||
|
|
f217f3fad8 | ||
|
|
9e46e0d10b | ||
|
|
baf599e007 | ||
|
|
2a1c987cc0 | ||
|
|
292652b0f4 | ||
|
|
b0fbecfe7d | ||
|
|
ab635669a4 | ||
|
|
276eeaac0e | ||
|
|
e858daf777 | ||
|
|
3e7ddaf758 | ||
|
|
407ffa5b9f | ||
|
|
2578fde055 | ||
|
|
7b61b9a778 | ||
|
|
7150ecc739 | ||
|
|
d8f1e0abf3 | ||
|
|
0338570cde | ||
|
|
71bfd482b7 | ||
|
|
aeff2aa278 | ||
|
|
e4d81b5cda | ||
|
|
528b348c59 | ||
|
|
886d349966 | ||
|
|
c38c94a43c | ||
|
|
d1625de22d | ||
|
|
c177b45cff | ||
|
|
472d1fc5a3 | ||
|
|
8f4955cb53 | ||
|
|
0876dea7eb | ||
|
|
e3fd3163df | ||
|
|
b6a77ec226 | ||
|
|
1e6869613b | ||
|
|
aa17223a06 | ||
|
|
8219fefa8d | ||
|
|
43d1b74c7f | ||
|
|
b19d161a03 | ||
|
|
ac7d574a23 | ||
|
|
17055b2cd3 | ||
|
|
7139ad73bd | ||
|
|
76dbfe8e2f | ||
|
|
3450f6034d | ||
|
|
c814bf7390 | ||
|
|
a7bae41805 | ||
|
|
b7ae9ecda9 | ||
|
|
4bf1b13299 | ||
|
|
e69bfe8c14 | ||
|
|
e15bd320e8 | ||
|
|
eb83f92be8 | ||
|
|
75943a0af7 | ||
|
|
bdca535027 | ||
|
|
ddde62e969 | ||
|
|
57dbd930bb | ||
|
|
8580e6202a | ||
|
|
49b03e373e | ||
|
|
baec6e8137 | ||
|
|
f860184f62 | ||
|
|
8b4a5d0e19 | ||
|
|
4a2ecc5bd1 | ||
|
|
b74c4db259 | ||
|
|
9cf0ccb96c | ||
|
|
461c1fbbbf | ||
|
|
34e4c8d13b | ||
|
|
085c492bba | ||
|
|
5bb58b1bbc | ||
|
|
abf407a8ac | ||
|
|
fae9ad32b7 | ||
|
|
8dc6bf32c9 | ||
|
|
880914b97b | ||
|
|
3ee290c0dc | ||
|
|
3c9426b831 | ||
|
|
c59a400406 | ||
|
|
4f8e5dc14f | ||
|
|
0d5ebb3a7a | ||
|
|
c91d7c5de3 | ||
|
|
1c5d44a2af | ||
|
|
dcd4e61f66 | ||
|
|
4aba9229c9 | ||
|
|
a7a59ae494 | ||
|
|
4ccec233ca | ||
|
|
9f03c0bc06 | ||
|
|
450f2df4a2 | ||
|
|
6e7bb4ffee | ||
|
|
5da7e7db82 | ||
|
|
3fffd5f81d | ||
|
|
c0c15a4497 | ||
|
|
f7aea9a4d4 | ||
|
|
74647256bc | ||
|
|
6de0ded9c6 | ||
|
|
c3da853006 | ||
|
|
508c45fcd3 | ||
|
|
bf60aabc4a | ||
|
|
5fc3fbdb5e | ||
|
|
3a73662b9c | ||
|
|
2f18229ff2 | ||
|
|
edcd33c9f5 | ||
|
|
b8590bbcb2 | ||
|
|
fb8a0b1a55 | ||
|
|
4ed08b32c1 | ||
|
|
25516df934 | ||
|
|
866afdfc57 | ||
|
|
32943b9b08 | ||
|
|
bebc6aa3d0 | ||
|
|
faeec2752a | ||
|
|
1c86d90386 | ||
|
|
19e95f0d80 | ||
|
|
d261ee605b | ||
|
|
311879be50 | ||
|
|
55b166f3bc | ||
|
|
2f9375a540 | ||
|
|
0df8ae8a32 | ||
|
|
989ae8c81e | ||
|
|
f5d1c2010c | ||
|
|
0dd8d57ac5 | ||
|
|
26eb23f784 | ||
|
|
0e9cfa75a2 | ||
|
|
f0f5af3f6f | ||
|
|
53c5ba2df3 | ||
|
|
20e56aeb59 | ||
|
|
02732bdc62 | ||
|
|
1bd43f9279 | ||
|
|
398fe7f67e | ||
|
|
c444ac009b | ||
|
|
3e55d130b0 | ||
|
|
6c1ecd04ba | ||
|
|
3a9b06caf8 | ||
|
|
37d256a3a6 | ||
|
|
b6967d8954 | ||
|
|
70e712975a | ||
|
|
4cc5891f82 | ||
|
|
9c15d348fa | ||
|
|
a21d1e9358 | ||
|
|
9a9db9b5b6 | ||
|
|
79992579ca | ||
|
|
ec13643603 | ||
|
|
6365a8eec4 | ||
|
|
8b3bf216cb | ||
|
|
f6ffa7c485 | ||
|
|
0fbd9ee428 | ||
|
|
6b9510c58f | ||
|
|
f8561d38fd | ||
|
|
3e759e10bc | ||
|
|
042d7586d7 | ||
|
|
c1be9f7322 | ||
|
|
2c76424b6a | ||
|
|
8904d6093f | ||
|
|
3b07a5cb4f | ||
|
|
b0f4ea88a2 | ||
|
|
b9b6273cf5 | ||
|
|
81730a451a | ||
|
|
f5333fd271 | ||
|
|
54417357f9 | ||
|
|
32517e4df6 | ||
|
|
0f8506b4ce | ||
|
|
3a19eedf88 | ||
|
|
ce0837a3e6 | ||
|
|
57e72d425f | ||
|
|
9f51d78c50 | ||
|
|
e6f21a849d | ||
|
|
74f8464d17 | ||
|
|
55e621d147 | ||
|
|
d37106316d | ||
|
|
f3ddfb4258 | ||
|
|
772b9c9b90 | ||
|
|
694471af5d | ||
|
|
f60f7b7742 | ||
|
|
c205ad2e4e | ||
|
|
b704ab92d0 | ||
|
|
566ec0f7fe | ||
|
|
a42dc62ba4 | ||
|
|
6f020263c0 | ||
|
|
44d1611741 | ||
|
|
bd6b0932a6 | ||
|
|
61f46b300a | ||
|
|
82b83ad284 | ||
|
|
d12e6f5f67 | ||
|
|
e6556370b6 | ||
|
|
7ff4acba8e | ||
|
|
edf61053a4 | ||
|
|
d4c5b65859 | ||
|
|
273c685de7 | ||
|
|
4460ec07ee | ||
|
|
9754765987 | ||
|
|
d082740a09 | ||
|
|
806f56d936 | ||
|
|
98267c79ee | ||
|
|
4b84eccd1b | ||
|
|
52b5ffce8a | ||
|
|
eb1817aa74 | ||
|
|
682022ed77 | ||
|
|
5819082664 | ||
|
|
e834cf8c3e | ||
|
|
f7de4cf7db | ||
|
|
fd7a62700c | ||
|
|
e781d30842 | ||
|
|
100e018e05 | ||
|
|
4d7c29b73a | ||
|
|
30840a7bee | ||
|
|
a58496839d | ||
|
|
999acb6317 | ||
|
|
5329612f7a | ||
|
|
5791622620 | ||
|
|
489020a22d | ||
|
|
a24fe4e4ad | ||
|
|
70a83e3539 | ||
|
|
b3eda417ae | ||
|
|
84ace3e9de | ||
|
|
fe540f68fc | ||
|
|
2b174fd392 | ||
|
|
cf03145353 | ||
|
|
6c043c76d4 | ||
|
|
5e8d1d65be | ||
|
|
a819808ef8 | ||
|
|
3c9065b8ab | ||
|
|
013c2b5383 | ||
|
|
5bd4377f32 | ||
|
|
7950fec5f4 | ||
|
|
b9a648afe7 | ||
|
|
13fc8ac083 | ||
|
|
1b29b2eb24 | ||
|
|
9abd06dd0e | ||
|
|
5b363303c4 | ||
|
|
fbff5b5c40 | ||
|
|
c6eed92631 | ||
|
|
490203dcf5 | ||
|
|
c99b7c8e61 | ||
|
|
1bbd3739e0 | ||
|
|
ca2dd7ef23 | ||
|
|
43c21b71a4 | ||
|
|
4a0c66d331 | ||
|
|
8686803350 | ||
|
|
398d6024af | ||
|
|
044e0c0ba3 | ||
|
|
8d7d0b7fc2 | ||
|
|
881af11685 | ||
|
|
ac0687ac72 | ||
|
|
861dcf0ac3 | ||
|
|
98f4b25991 | ||
|
|
21600c5b36 | ||
|
|
4e1e2fdca3 | ||
|
|
2bd5e6fd65 | ||
|
|
31359ebebf | ||
|
|
cf6a63548f | ||
|
|
aa062d79b2 | ||
|
|
261a591680 | ||
|
|
30db3d2f75 | ||
|
|
8b0ec6836b | ||
|
|
ccdd55ce0b | ||
|
|
0739f7f060 | ||
|
|
7eab67bf8e | ||
|
|
5c879fb2b3 | ||
|
|
9a9fe0b162 | ||
|
|
f82741fde4 | ||
|
|
b0ce5c2615 | ||
|
|
5d34e48c27 | ||
|
|
d3fed82a61 | ||
|
|
cb98addc77 | ||
|
|
ea2fde6e2c | ||
|
|
dd7da4218d | ||
|
|
710c7df7e8 | ||
|
|
30e84cee7f | ||
|
|
b4d06efe8d | ||
|
|
d683b524bb | ||
|
|
9e1d151afd | ||
|
|
3380acff8a | ||
|
|
4a4d0943a8 | ||
|
|
1466ea9704 | ||
|
|
34f5312d95 | ||
|
|
f12246ac65 | ||
|
|
1c7853cf99 | ||
|
|
083af89e03 | ||
|
|
5536b51a45 | ||
|
|
ae7fd15049 | ||
|
|
42f64fa9d7 | ||
|
|
271f4ac578 | ||
|
|
29630c7e4b | ||
|
|
81bfafdf62 | ||
|
|
ba32862171 | ||
|
|
c0be0bdd8f | ||
|
|
7005ea7bdd | ||
|
|
13864ab2f1 | ||
|
|
bd6d74e1d4 | ||
|
|
1c96a326aa | ||
|
|
446def7e8d | ||
|
|
c64b8a1608 | ||
|
|
d796c3df27 | ||
|
|
d8af5b56ed | ||
|
|
c3716c1a85 | ||
|
|
0b33699684 | ||
|
|
f6f0bd31ca | ||
|
|
835611fdb5 | ||
|
|
f7d442595b | ||
|
|
e840c43d07 | ||
|
|
ad151ab5cc | ||
|
|
00ba51f252 | ||
|
|
aa38562373 | ||
|
|
59f8b6155b | ||
|
|
12f6f56e8e | ||
|
|
7bb416b23e | ||
|
|
b93adbcb9f | ||
|
|
772385d374 | ||
|
|
933e4145f9 | ||
|
|
777a760e4d | ||
|
|
0a0e751a38 | ||
|
|
973be6bcf3 | ||
|
|
1be11c3d6a | ||
|
|
61f54a53b1 | ||
|
|
e3e089bc7e | ||
|
|
49745bee0d | ||
|
|
c8d66be719 | ||
|
|
3e75ad034c | ||
|
|
bff924f4a7 | ||
|
|
854b6429ed | ||
|
|
438301dd1e | ||
|
|
f1366996ec | ||
|
|
092abae3d1 | ||
|
|
5243b740fe | ||
|
|
af8fb48f74 | ||
|
|
93b8eec64a | ||
|
|
5b4948f34f | ||
|
|
1dc4407c69 | ||
|
|
19ff12390f | ||
|
|
a439791abb | ||
|
|
14cb4b9339 | ||
|
|
0583cb9556 | ||
|
|
c8c7f6b046 | ||
|
|
53f6646b3e | ||
|
|
b081f28070 | ||
|
|
71aea715ad | ||
|
|
428025d530 | ||
|
|
8f1b2dceac | ||
|
|
7215960df6 | ||
|
|
9198dd2d7a | ||
|
|
609cdd5558 | ||
|
|
4d6fb59a83 | ||
|
|
6b6dd234cd | ||
|
|
778ff0ff2a | ||
|
|
322bc1f1f7 | ||
|
|
4577f67845 | ||
|
|
2cd7fe2ad2 | ||
|
|
1d877bf352 | ||
|
|
eb7085a34a | ||
|
|
ec713dad64 | ||
|
|
5c6ca0651d | ||
|
|
d17427ccfd | ||
|
|
a42988a3e2 | ||
|
|
e360ef3f16 | ||
|
|
794a22ae7e | ||
|
|
03f4ceb828 | ||
|
|
68fb90a939 | ||
|
|
1293f88eed | ||
|
|
acb6f0bcee | ||
|
|
bd60860681 | ||
|
|
3032cabf10 | ||
|
|
d4e98fa691 | ||
|
|
b2ced2d9d4 | ||
|
|
d408a0833b | ||
|
|
11a65173c9 | ||
|
|
dee44f7456 | ||
|
|
addc70f926 | ||
|
|
ce0ff8022f | ||
|
|
a7fe2f2ebf | ||
|
|
547809cb50 | ||
|
|
6833aaa94b | ||
|
|
a0c5dcc1ba | ||
|
|
df7334e74e | ||
|
|
fbfd9eb572 | ||
|
|
72715f229f | ||
|
|
ea352fcac9 | ||
|
|
7fac1b8977 | ||
|
|
a9217784b3 | ||
|
|
010d21ac18 | ||
|
|
4274171a8a | ||
|
|
6a78559c82 | ||
|
|
abfac7c446 | ||
|
|
62f258052c | ||
|
|
398ac2f606 | ||
|
|
7d386659fb | ||
|
|
fd96f73408 | ||
|
|
0676970825 | ||
|
|
74248df1b8 | ||
|
|
8420ce8816 | ||
|
|
58f39ea2b5 | ||
|
|
847c9d1287 | ||
|
|
17799f3f72 | ||
|
|
07c8f85658 | ||
|
|
de5183ed0e | ||
|
|
7b64a5388b | ||
|
|
cde1a01234 | ||
|
|
4ca97c92be | ||
|
|
4d5243d980 | ||
|
|
9a878242e5 | ||
|
|
8d854a1786 | ||
|
|
f4146b9b1f | ||
|
|
59c4baa8a8 | ||
|
|
f8c8709316 | ||
|
|
680e565e30 | ||
|
|
54f3f10ef6 | ||
|
|
af7e68c541 | ||
|
|
823cec88b6 | ||
|
|
05700424be | ||
|
|
aca20e461a | ||
|
|
5eff1f12bb | ||
|
|
301957cc99 | ||
|
|
34e0cd8311 | ||
|
|
3b7d285783 | ||
|
|
e6c15c036d | ||
|
|
4243a86d28 | ||
|
|
c425a07cd7 | ||
|
|
b906a93721 | ||
|
|
ab32d7ce89 | ||
|
|
cfc029cfff | ||
|
|
a6bc49aa8d | ||
|
|
fffdc81e20 | ||
|
|
f5c1695952 | ||
|
|
9b8b917a2d | ||
|
|
c133bb7e87 | ||
|
|
d3df1330f1 | ||
|
|
79f509afff | ||
|
|
8cd2215e88 | ||
|
|
4f707c418e | ||
|
|
83117b8922 | ||
|
|
2d494101ae | ||
|
|
a30cf5a797 | ||
|
|
5907333156 | ||
|
|
da70d5bfec | ||
|
|
6bd0b3015a | ||
|
|
16c26dfe66 | ||
|
|
bd1e6badd3 | ||
|
|
9353d95c7a | ||
|
|
42bf8f035f | ||
|
|
b1d115844b | ||
|
|
7c2f18b832 | ||
|
|
9b2c9f3c7d | ||
|
|
e62b66e657 | ||
|
|
d6ce607d2b | ||
|
|
33924fc680 | ||
|
|
23ff49fb16 | ||
|
|
756dcaf849 | ||
|
|
778dc4d2cb | ||
|
|
f476a70cd2 | ||
|
|
04279b4c72 | ||
|
|
6c0a24c232 | ||
|
|
5258f4965e | ||
|
|
63c72a12fc | ||
|
|
d999558421 | ||
|
|
63208b3192 | ||
|
|
2b7ec7b863 | ||
|
|
54f636504d | ||
|
|
5b2ae3af6b | ||
|
|
e7e550ded3 | ||
|
|
bf9520ff1c | ||
|
|
3953866090 | ||
|
|
3195bbb9c9 | ||
|
|
2d97d44d56 | ||
|
|
95e76916ce | ||
|
|
f0dceac22f | ||
|
|
0815e97042 | ||
|
|
0d9e83b889 | ||
|
|
e23a303bc6 | ||
|
|
b3b6caa19f | ||
|
|
1373913571 | ||
|
|
fc5aab23c4 | ||
|
|
31e0ae6598 | ||
|
|
b8bd5b26ec | ||
|
|
472de26bef | ||
|
|
21d46f3107 | ||
|
|
ac5be12867 | ||
|
|
579d3de129 | ||
|
|
4875e05d5e | ||
|
|
4a056a92a8 | ||
|
|
f5f67b9042 | ||
|
|
c48c94e61a | ||
|
|
9f722e41fb | ||
|
|
7995cd6dc2 | ||
|
|
37891d6ad4 | ||
|
|
5ec20a7fb1 | ||
|
|
d3a29da9e9 | ||
|
|
7e0b0ce7fa | ||
|
|
bdb4830e63 | ||
|
|
3c713de42f | ||
|
|
93f34fd2c9 | ||
|
|
8ca08f316c | ||
|
|
4d148e5ff3 | ||
|
|
28aee90b82 | ||
|
|
cb70bc42f9 | ||
|
|
022889bf26 | ||
|
|
f7cff20cd8 | ||
|
|
522162ae89 | ||
|
|
3f5c9a03af | ||
|
|
b615052d4a | ||
|
|
90da5149ec | ||
|
|
e97d01846a | ||
|
|
452f984e5c | ||
|
|
2a04b07198 | ||
|
|
4ccac06355 | ||
|
|
c40f378bb9 | ||
|
|
b44efb8ade | ||
|
|
5cf82db881 | ||
|
|
90793aeb54 | ||
|
|
b502872b49 | ||
|
|
277dc6608e | ||
|
|
39d9b907d3 | ||
|
|
5b45fa1788 | ||
|
|
d168ac6e4c | ||
|
|
a59f1d42d1 | ||
|
|
ea1e69dadc | ||
|
|
e24c7abfd6 | ||
|
|
1bcb4087eb | ||
|
|
df54a42de7 | ||
|
|
bcf7110b96 | ||
|
|
df9c29cf05 | ||
|
|
ec6bc83fd2 | ||
|
|
d78af4053e | ||
|
|
92b573ab18 | ||
|
|
d3976f1d74 | ||
|
|
55ba8b8f4d | ||
|
|
578269963a | ||
|
|
e36074e922 | ||
|
|
811489057b | ||
|
|
c16b32f194 | ||
|
|
c98536c124 | ||
|
|
030e5ec27a | ||
|
|
175de95717 | ||
|
|
612599c58d | ||
|
|
bd5256c927 | ||
|
|
04e02f0c3e | ||
|
|
e4519b5eef | ||
|
|
11e94aef4e | ||
|
|
7bf8df6666 | ||
|
|
42fb759cca | ||
|
|
bd6c1744e1 | ||
|
|
7b3b2e0743 | ||
|
|
0df2b02781 | ||
|
|
6066e8dd36 |
1550 changed files with 366404 additions and 24965 deletions
|
|
@ -7,6 +7,11 @@ node_modules
|
|||
server/dist
|
||||
web/dist
|
||||
shared/dist
|
||||
desktop/.desktop-release
|
||||
desktop/release
|
||||
.veritas-desktop-dev
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Git
|
||||
.git
|
||||
|
|
@ -45,9 +50,6 @@ LICENSE
|
|||
coverage
|
||||
|
||||
# Development tools
|
||||
cli/
|
||||
mcp/
|
||||
scripts/
|
||||
tasks/
|
||||
eslint.config.js
|
||||
.prettierrc
|
||||
|
|
|
|||
29
.env.example
29
.env.example
|
|
@ -47,12 +47,26 @@ VERITAS_ADMIN_KEY=
|
|||
# Default: <cwd>/.veritas-kanban
|
||||
# VERITAS_DATA_DIR=
|
||||
|
||||
# Storage backend and optional SQLite location.
|
||||
# VERITAS_STORAGE=file
|
||||
# VERITAS_SQLITE_PATH=
|
||||
|
||||
# Required only for governed SQLite DELETE compatibility or expert override mode.
|
||||
# Use a stable, unique value for this host; do not reuse it on another machine.
|
||||
# VERITAS_SQLITE_TOPOLOGY=single-host
|
||||
# VERITAS_SQLITE_HOST_ID=replace-with-stable-host-identity
|
||||
|
||||
# ── CORS ─────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed origins for CORS and WebSocket.
|
||||
# Default (dev): http://localhost:5173,http://localhost:3000,
|
||||
# http://127.0.0.1:5173,http://127.0.0.1:3000
|
||||
# CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Optional operator HTTP proxy for selective run-scoped egress.
|
||||
# Destination policy is still evaluated and the pinned IP is sent through CONNECT.
|
||||
# Credentials are held in memory and are not persisted in launch or telemetry evidence.
|
||||
# VERITAS_EGRESS_UPSTREAM_PROXY=http://proxy-user:proxy-password@proxy.internal:3128
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────
|
||||
# Pino log level: fatal | error | warn | info | debug | trace | silent
|
||||
# LOG_LEVEL=info
|
||||
|
|
@ -75,6 +89,19 @@ VERITAS_ADMIN_KEY=
|
|||
# Days after which telemetry is compressed
|
||||
# TELEMETRY_COMPRESS_DAYS=7
|
||||
|
||||
# Prometheus /metrics exposure
|
||||
# In production, /metrics requires normal auth with telemetry:read unless one of these is set.
|
||||
# PROMETHEUS_METRICS_TOKEN=
|
||||
# PROMETHEUS_METRICS_PUBLIC=false
|
||||
|
||||
# ── External Services ────────────────────────────────────────
|
||||
# Clawdbot gateway URL (default: http://127.0.0.1:18789)
|
||||
# OpenClaw gateway URL (default: http://127.0.0.1:18789) and optional bearer token.
|
||||
# OPENCLAW_GATEWAY_URL=http://127.0.0.1:18789
|
||||
# OPENCLAW_GATEWAY_TOKEN=
|
||||
|
||||
# Optional operator-declared OpenClaw version hint for provider runtime manifests.
|
||||
# This does not count as runtime-verified evidence; host registration must verify it.
|
||||
# OPENCLAW_GATEWAY_VERSION=
|
||||
|
||||
# Legacy gateway URL alias.
|
||||
# CLAWDBOT_GATEWAY=http://127.0.0.1:18789
|
||||
|
|
|
|||
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
A clear and concise description of what this PR does.
|
||||
|
||||
## Scope
|
||||
|
||||
**Linked issue:** Closes #
|
||||
|
||||
**In scope:** One independently shippable behavior and the documentation needed
|
||||
to use it.
|
||||
|
||||
**Linked follow-ups:** List separable UI, integration, refactor, or hardening
|
||||
work that was intentionally kept out of this PR, or write `None`.
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
|
|
@ -15,6 +25,15 @@ A clear and concise description of what this PR does.
|
|||
**How has this been tested?**
|
||||
Describe the tests you ran to verify your changes. Provide instructions so reviewers can reproduce.
|
||||
|
||||
**Verification tier:**
|
||||
|
||||
- [ ] Documentation or static checks only
|
||||
- [ ] Explicit focused diagnostic (manual workflow dispatch)
|
||||
- [ ] Full milestone gate (`ci:full`, critical security, integration, or release)
|
||||
|
||||
**Why this tier is sufficient:** Explain the changed behavior, covered failure
|
||||
modes, and why broader gates are or are not required.
|
||||
|
||||
**Test commands:**
|
||||
|
||||
```bash
|
||||
|
|
@ -23,9 +42,13 @@ Describe the tests you ran to verify your changes. Provide instructions so revie
|
|||
|
||||
## Checklist
|
||||
|
||||
- [ ] This PR contains one coherent, independently shippable behavior
|
||||
- [ ] Separable follow-up work is linked instead of folded into this PR
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have added or updated coverage for the next declared test milestone
|
||||
- [ ] I have updated the documentation accordingly
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] Any breaking changes have been documented in the PR description
|
||||
- [ ] I did not rerun unchanged passing gates after documentation or formatting-only edits
|
||||
- [ ] Optional desktop, artifact, and release workflows are marked relevant only when this PR touches their product boundary
|
||||
|
|
|
|||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
|
|
@ -12,6 +12,12 @@ updates:
|
|||
- 'BradGroux'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
ignore:
|
||||
# jsdom 30 requires Node >=22.22.2 and currently breaks the Mantine UI suite.
|
||||
# Keep receiving jsdom 29 patches until the runtime floor is deliberately raised.
|
||||
- dependency-name: 'jsdom'
|
||||
update-types:
|
||||
- 'version-update:semver-major'
|
||||
groups:
|
||||
# Group minor/patch updates to reduce PR noise
|
||||
production-dependencies:
|
||||
|
|
|
|||
477
.github/workflows/ci.yml
vendored
477
.github/workflows/ci.yml
vendored
|
|
@ -5,74 +5,499 @@ on:
|
|||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test_scope:
|
||||
description: Unit-test verification tier
|
||||
required: true
|
||||
default: full
|
||||
type: choice
|
||||
options:
|
||||
- focused
|
||||
- full
|
||||
base_sha:
|
||||
description: Optional base commit for a focused run (defaults to HEAD^)
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: >-
|
||||
${{
|
||||
format(
|
||||
'{0}-{1}-{2}',
|
||||
github.workflow,
|
||||
github.ref,
|
||||
github.event_name == 'pull_request' &&
|
||||
contains(fromJSON('["labeled","unlabeled"]'), github.event.action) &&
|
||||
github.event.label.name != 'ci:full' &&
|
||||
format('cosmetic-{0}', github.run_id) ||
|
||||
'authoritative'
|
||||
)
|
||||
}}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
|
||||
jobs:
|
||||
# ─── Deterministic Test Scope ───────────────────────────────────
|
||||
select-tests:
|
||||
name: Select Test Scope
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
scope: ${{ steps.scope.outputs.scope }}
|
||||
packages: ${{ steps.scope.outputs.packages }}
|
||||
base_sha: ${{ steps.scope.outputs.base_sha }}
|
||||
diff_range: ${{ steps.scope.outputs.diff_range }}
|
||||
reason: ${{ steps.scope.outputs.reason }}
|
||||
coverage_packages: ${{ steps.scope.outputs.coverage_packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Verify CI scope controls
|
||||
run: >-
|
||||
node --test
|
||||
scripts/check-actions-pinned.test.mjs
|
||||
scripts/check-delivery-cadence.test.mjs
|
||||
scripts/check-security-gates.test.mjs
|
||||
scripts/check-tracked-ignore.test.mjs
|
||||
scripts/select-ci-test-scope.test.mjs
|
||||
|
||||
- name: Guard delivery cadence
|
||||
run: node scripts/check-delivery-cadence.mjs
|
||||
|
||||
- name: Guard immutable GitHub Actions references
|
||||
run: node scripts/check-actions-pinned.mjs
|
||||
|
||||
- name: Guard continuous security gates
|
||||
run: node scripts/check-security-gates.mjs
|
||||
|
||||
- name: Reject tracked files covered by ignore rules
|
||||
run: node scripts/check-tracked-ignore.mjs
|
||||
|
||||
- name: Select verification tier
|
||||
id: scope
|
||||
shell: bash
|
||||
env:
|
||||
CI_EVENT_NAME: ${{ github.event_name }}
|
||||
CI_MANUAL_SCOPE: ${{ inputs.test_scope || '' }}
|
||||
CI_PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha || '' }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before || '' }}
|
||||
DISPATCH_BASE_SHA: ${{ inputs.base_sha || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$CI_EVENT_NAME" in
|
||||
pull_request)
|
||||
CI_BASE_SHA="$PR_BASE_SHA"
|
||||
CI_HEAD_SHA="$PR_HEAD_SHA"
|
||||
;;
|
||||
push)
|
||||
CI_BASE_SHA="$PUSH_BEFORE_SHA"
|
||||
CI_HEAD_SHA="$GITHUB_SHA"
|
||||
if [[ "$CI_BASE_SHA" =~ ^0+$ ]]; then
|
||||
CI_BASE_SHA="$(git rev-parse "${GITHUB_SHA}^")"
|
||||
fi
|
||||
;;
|
||||
workflow_dispatch)
|
||||
CI_HEAD_SHA="$GITHUB_SHA"
|
||||
if [[ -n "$DISPATCH_BASE_SHA" ]]; then
|
||||
if [[ ! "$DISPATCH_BASE_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
|
||||
echo "::error::base_sha must be a 7-40 character hexadecimal commit ID"
|
||||
exit 1
|
||||
fi
|
||||
CI_BASE_SHA="$(git rev-parse --verify "${DISPATCH_BASE_SHA}^{commit}")"
|
||||
else
|
||||
CI_BASE_SHA="$(git rev-parse "${GITHUB_SHA}^")"
|
||||
fi
|
||||
;;
|
||||
schedule)
|
||||
CI_BASE_SHA="$GITHUB_SHA"
|
||||
CI_HEAD_SHA="$GITHUB_SHA"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unsupported CI event: $CI_EVENT_NAME"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
export CI_BASE_SHA CI_HEAD_SHA
|
||||
node scripts/select-ci-test-scope.mjs
|
||||
|
||||
# ─── Lint & Type Check ───────────────────────────────────────────
|
||||
lint-and-typecheck:
|
||||
name: Lint & Type Check
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Check pnpm settings location
|
||||
run: node scripts/check-pnpm-settings.mjs
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check service filesystem boundary
|
||||
run: pnpm check:service-filesystem-boundary
|
||||
|
||||
- name: Build shared (dependency for typecheck)
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Enforce lint warning budget
|
||||
run: pnpm lint:budget
|
||||
|
||||
- name: Check permission coverage
|
||||
run: node scripts/check-permission-coverage.mjs
|
||||
|
||||
- name: Type check all packages
|
||||
run: pnpm typecheck
|
||||
|
||||
# ─── Workspace Unit Tests ────────────────────────────────────────
|
||||
test-workspace:
|
||||
name: Workspace Unit Tests
|
||||
# ─── Focused Related Tests ──────────────────────────────────────
|
||||
test-changed:
|
||||
name: Changed Tests
|
||||
needs: select-tests
|
||||
if: >-
|
||||
always() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Require a successful scope decision
|
||||
env:
|
||||
SELECTOR_RESULT: ${{ needs.select-tests.result }}
|
||||
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
|
||||
SELECTED_PACKAGES: ${{ needs.select-tests.outputs.packages }}
|
||||
run: |
|
||||
if [[ "$SELECTOR_RESULT" != "success" ]]; then
|
||||
echo "::error::Select Test Scope did not complete successfully"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$SELECTED_SCOPE" =~ ^(none|focused|full)$ ]]; then
|
||||
echo "::error::Select Test Scope returned an invalid scope"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$SELECTED_SCOPE" == "focused" && -z "$SELECTED_PACKAGES" ]]; then
|
||||
echo "::error::Focused scope requires at least one workspace"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build shared test dependency
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
- name: Run related tests in affected workspaces
|
||||
if: needs.select-tests.outputs.scope == 'focused'
|
||||
env:
|
||||
DIFF_RANGE: ${{ needs.select-tests.outputs.diff_range }}
|
||||
SELECTED_PACKAGES: ${{ needs.select-tests.outputs.packages }}
|
||||
VERITAS_DISABLE_WATCHERS: '1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
IFS=',' read -r -a packages <<< "$SELECTED_PACKAGES"
|
||||
executed_packages=()
|
||||
{
|
||||
echo "### Changed Tests"
|
||||
echo
|
||||
echo "- Diff range: \`$DIFF_RANGE\`"
|
||||
echo "- Selected workspaces: \`$SELECTED_PACKAGES\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
for package_name in "${packages[@]}"; do
|
||||
related_files=()
|
||||
while IFS= read -r changed_file; do
|
||||
related_files+=("./${changed_file#"$package_name/"}")
|
||||
done < <(
|
||||
git diff --name-only --diff-filter=ACMR "$DIFF_RANGE" -- "$package_name/"
|
||||
)
|
||||
|
||||
if (( ${#related_files[@]} == 0 )); then
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$package_name" in
|
||||
server|web|cli|mcp)
|
||||
package_filter="@veritas-kanban/${package_name}"
|
||||
extra_args=()
|
||||
if [[ "$package_name" == "web" ]]; then
|
||||
extra_args+=(--testTimeout 15000)
|
||||
fi
|
||||
;;
|
||||
desktop)
|
||||
package_filter="@veritas-kanban/desktop"
|
||||
extra_args=(--config vitest.config.ts)
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown selected workspace: $package_name"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
pnpm --filter "$package_filter" exec vitest related \
|
||||
--run \
|
||||
--maxWorkers=4 \
|
||||
--passWithNoTests \
|
||||
"${extra_args[@]}" \
|
||||
"${related_files[@]}"
|
||||
executed_packages+=("$package_name")
|
||||
done
|
||||
|
||||
{
|
||||
if (( ${#executed_packages[@]} > 0 )); then
|
||||
echo "- Related coverage executed for: \`${executed_packages[*]}\`"
|
||||
else
|
||||
echo "- No added, copied, modified, or renamed workspace inputs required related coverage."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Record focused-tier skip
|
||||
if: needs.select-tests.outputs.scope != 'focused'
|
||||
env:
|
||||
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
|
||||
SELECTION_REASON: ${{ needs.select-tests.outputs.reason }}
|
||||
run: |
|
||||
{
|
||||
echo "### Changed Tests"
|
||||
echo
|
||||
echo "- Decision: skipped related coverage because scope is \`$SELECTED_SCOPE\`."
|
||||
echo "- Selection reason: $SELECTION_REASON"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─── Full Workspace Unit Tests ───────────────────────────────────
|
||||
test-workspace:
|
||||
name: Workspace Unit Tests
|
||||
needs: select-tests
|
||||
if: >-
|
||||
always() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require a successful scope decision
|
||||
env:
|
||||
SELECTOR_RESULT: ${{ needs.select-tests.result }}
|
||||
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
|
||||
run: |
|
||||
if [[ "$SELECTOR_RESULT" != "success" ]]; then
|
||||
echo "::error::Select Test Scope did not complete successfully"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$SELECTED_SCOPE" =~ ^(none|focused|full)$ ]]; then
|
||||
echo "::error::Select Test Scope returned an invalid scope"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build shared (dependency for workspace tests)
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
- name: Run workspace unit tests
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Run desktop readiness regression tests
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: pnpm desktop:test:readiness
|
||||
|
||||
- name: Run dual-storage parity tests
|
||||
if: needs.select-tests.outputs.scope == 'full'
|
||||
run: >-
|
||||
pnpm --filter @veritas-kanban/server exec vitest run
|
||||
src/__tests__/storage/dual-storage-parity.test.ts
|
||||
|
||||
- name: Record full-suite evidence
|
||||
if: >-
|
||||
always() &&
|
||||
needs.select-tests.result == 'success' &&
|
||||
needs.select-tests.outputs.scope == 'full'
|
||||
env:
|
||||
DIFF_RANGE: ${{ needs.select-tests.outputs.diff_range }}
|
||||
SELECTION_REASON: ${{ needs.select-tests.outputs.reason }}
|
||||
CURRENT_JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
{
|
||||
echo "### Workspace Unit Tests"
|
||||
echo
|
||||
echo "- Diff range: \`${DIFF_RANGE:-not required}\`"
|
||||
echo "- Selection reason: $SELECTION_REASON"
|
||||
echo "- Workflow checkout SHA: \`$GITHUB_SHA\`"
|
||||
echo "- Current job status: \`$CURRENT_JOB_STATUS\`"
|
||||
echo "- Unit-test workspaces: \`server, web, cli, mcp\`"
|
||||
echo "- Workspace workers: \`4 maximum per Vitest project\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Record full-tier skip
|
||||
if: needs.select-tests.outputs.scope != 'full'
|
||||
env:
|
||||
SELECTED_SCOPE: ${{ needs.select-tests.outputs.scope }}
|
||||
SELECTION_REASON: ${{ needs.select-tests.outputs.reason }}
|
||||
run: |
|
||||
{
|
||||
echo "### Workspace Unit Tests"
|
||||
echo
|
||||
echo "- Decision: skipped the complete suite because scope is \`$SELECTED_SCOPE\`."
|
||||
echo "- Selection reason: $SELECTION_REASON"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─── Critical-path Coverage ─────────────────────────────────────
|
||||
critical-path-coverage:
|
||||
name: Critical Path Coverage
|
||||
needs: select-tests
|
||||
if: >-
|
||||
always() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require a successful scope decision
|
||||
env:
|
||||
SELECTOR_RESULT: ${{ needs.select-tests.result }}
|
||||
run: |
|
||||
if [[ "$SELECTOR_RESULT" != "success" ]]; then
|
||||
echo "::error::Select Test Scope did not complete successfully"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
with:
|
||||
# Policy downgrade and changed-critical-file checks compare against the event base SHA.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify coverage policy
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
env:
|
||||
COVERAGE_BASE_REF: ${{ needs.select-tests.outputs.base_sha }}
|
||||
run: pnpm check:coverage-policy
|
||||
|
||||
- name: Measure and ratchet critical paths
|
||||
if: needs.select-tests.outputs.coverage_packages != ''
|
||||
env:
|
||||
COVERAGE_PACKAGES: ${{ needs.select-tests.outputs.coverage_packages }}
|
||||
COVERAGE_BASE_REF: ${{ needs.select-tests.outputs.base_sha }}
|
||||
run: pnpm test:coverage --packages "$COVERAGE_PACKAGES"
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: always() && needs.select-tests.outputs.coverage_packages != ''
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: critical-path-coverage-${{ github.sha }}
|
||||
path: coverage/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Record coverage skip
|
||||
if: needs.select-tests.outputs.coverage_packages == ''
|
||||
run: |
|
||||
{
|
||||
echo "### Critical-path coverage ratchets"
|
||||
echo
|
||||
echo "No governed critical-path package changed in this verification scope."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─── Build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -83,6 +508,9 @@ jobs:
|
|||
- name: Build shared (dependency for all builds)
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
- name: Verify native Vite config loading
|
||||
run: pnpm check:vite-native-config
|
||||
|
||||
- name: Build all packages
|
||||
run: pnpm build
|
||||
|
||||
|
|
@ -104,16 +532,30 @@ jobs:
|
|||
echo "✅ Server build output exists"
|
||||
ls -la server/dist/
|
||||
|
||||
- name: Verify CLI and MCP build output
|
||||
run: |
|
||||
for file in cli/dist/index.js mcp/dist/index.js; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "::error::$file not found"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "✅ CLI and MCP build outputs exist"
|
||||
|
||||
# ─── Security Audit ──────────────────────────────────────────────
|
||||
security-audit:
|
||||
name: Security Audit
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
!contains(fromJSON('["labeled","unlabeled"]'), github.event.action) ||
|
||||
github.event.label.name == 'ci:full'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
|
@ -121,6 +563,9 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Reject tracked runtime security configuration
|
||||
run: pnpm check:security-artifacts
|
||||
|
||||
- name: Audit production dependencies (blocks on high/critical)
|
||||
run: pnpm audit --prod --audit-level=high
|
||||
|
||||
|
|
|
|||
176
.github/workflows/desktop-artifacts.yml
vendored
Normal file
176
.github/workflows/desktop-artifacts.yml
vendored
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
name: Desktop Artifacts
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
|
||||
jobs:
|
||||
mac-unsigned:
|
||||
name: Unsigned macOS Artifact
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### Unsigned macOS artifact milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\` or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build workspace
|
||||
run: pnpm build
|
||||
|
||||
- name: Prepare desktop runtime payload
|
||||
run: pnpm --filter @veritas-kanban/desktop package:prepare
|
||||
|
||||
- name: Build unsigned macOS DMG and update metadata
|
||||
working-directory: desktop
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: node ./node_modules/electron-builder/cli.js --mac dmg zip --publish never --config.mac.identity=null --config.mac.notarize=false
|
||||
|
||||
- name: Upload desktop artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: veritas-kanban-mac-unsigned
|
||||
path: |
|
||||
desktop/release/*.dmg
|
||||
desktop/release/*.zip
|
||||
desktop/release/*.yml
|
||||
desktop/release/*.blockmap
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
linux-unsigned:
|
||||
name: Unsigned Linux Artifacts
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### Unsigned Linux artifact milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\` or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install Linux packaging tools
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends fakeroot libarchive-tools rpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build workspace
|
||||
run: pnpm build
|
||||
|
||||
- name: Prepare desktop runtime payload
|
||||
run: pnpm --filter @veritas-kanban/desktop package:prepare
|
||||
|
||||
- name: Build unsigned Linux preview AppImage, deb, and rpm artifacts
|
||||
working-directory: desktop
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: node ./node_modules/electron-builder/cli.js --linux AppImage deb rpm --x64 --publish never
|
||||
|
||||
- name: Upload Linux preview desktop artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: veritas-kanban-linux-unsigned
|
||||
path: |
|
||||
desktop/release/*.AppImage
|
||||
desktop/release/*.deb
|
||||
desktop/release/*.rpm
|
||||
desktop/release/*.yml
|
||||
desktop/release/*.blockmap
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
windows-unsigned:
|
||||
name: Unsigned Windows Artifacts
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: windows-2025
|
||||
steps:
|
||||
- name: Record milestone selection
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "### Unsigned Windows artifact milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\` or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build workspace
|
||||
run: pnpm build
|
||||
|
||||
- name: Prepare desktop runtime payload
|
||||
run: pnpm --filter @veritas-kanban/desktop package:prepare
|
||||
|
||||
- name: Build unsigned Windows preview NSIS and ZIP artifacts
|
||||
working-directory: desktop
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: node ./node_modules/electron-builder/cli.js --win nsis zip --x64 --publish never
|
||||
|
||||
- name: Upload Windows preview desktop artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: veritas-kanban-windows-unsigned
|
||||
path: |
|
||||
desktop/release/*.exe
|
||||
desktop/release/*.zip
|
||||
desktop/release/*.yml
|
||||
desktop/release/*.blockmap
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
205
.github/workflows/desktop-release.yml
vendored
Normal file
205
.github/workflows/desktop-release.yml
vendored
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
name: Desktop Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Update channel to publish.
|
||||
required: true
|
||||
default: stable
|
||||
type: choice
|
||||
options:
|
||||
- stable
|
||||
- beta
|
||||
- dev
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
VERITAS_BUILD_SHA: ${{ github.sha }}
|
||||
VERITAS_UPDATE_CHANNEL: ${{ github.event.inputs.channel || 'stable' }}
|
||||
|
||||
jobs:
|
||||
mac-signed:
|
||||
name: Signed and Notarized macOS Artifact
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate published release body
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pnpm validate:release -- --version "${version}" --github --skip-build-output
|
||||
|
||||
- name: Verify signing and notarization secrets are configured
|
||||
id: notarization-secrets
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MACOS_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }}
|
||||
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
errors=0
|
||||
|
||||
for name in CSC_LINK CSC_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required for signed/notarized desktop releases"
|
||||
errors=1
|
||||
fi
|
||||
done
|
||||
|
||||
api_key_count=0
|
||||
for name in APPLE_API_KEY_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER; do
|
||||
if [ -n "${!name}" ]; then
|
||||
api_key_count=$((api_key_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
apple_id_count=0
|
||||
for name in APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
|
||||
if [ -n "${!name}" ]; then
|
||||
apple_id_count=$((apple_id_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${api_key_count}" -gt 0 ] && [ "${api_key_count}" -lt 3 ]; then
|
||||
echo "::error::App Store Connect API-key notarization requires APPLE_API_KEY_BASE64, APPLE_API_KEY_ID, and APPLE_API_ISSUER together"
|
||||
errors=1
|
||||
fi
|
||||
|
||||
if [ "${apple_id_count}" -gt 0 ] && [ "${apple_id_count}" -lt 3 ]; then
|
||||
echo "::error::Apple ID notarization requires APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID together"
|
||||
errors=1
|
||||
fi
|
||||
|
||||
if [ "${api_key_count}" -eq 3 ] && [ "${apple_id_count}" -eq 3 ]; then
|
||||
echo "::error::Configure exactly one notarization credential set, not both App Store Connect API-key and Apple ID credentials"
|
||||
errors=1
|
||||
elif [ "${api_key_count}" -eq 3 ]; then
|
||||
echo "notarization_mode=api-key" >> "${GITHUB_OUTPUT}"
|
||||
elif [ "${apple_id_count}" -eq 3 ]; then
|
||||
echo "notarization_mode=apple-id" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "::error::One complete notarization credential set is required: App Store Connect API key or Apple ID/app-specific password"
|
||||
errors=1
|
||||
fi
|
||||
|
||||
exit "${errors}"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build workspace
|
||||
run: pnpm build
|
||||
|
||||
- name: Prepare desktop runtime payload
|
||||
run: pnpm --filter @veritas-kanban/desktop package:prepare
|
||||
|
||||
- name: Write App Store Connect API key
|
||||
id: notary-key
|
||||
if: steps.notarization-secrets.outputs.notarization_mode == 'api-key'
|
||||
env:
|
||||
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
|
||||
run: |
|
||||
key_path="${RUNNER_TEMP}/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8"
|
||||
umask 077
|
||||
printf '%s' "${APPLE_API_KEY_BASE64}" | base64 --decode > "${key_path}"
|
||||
|
||||
if ! grep -q "BEGIN PRIVATE KEY" "${key_path}"; then
|
||||
echo "::error::APPLE_API_KEY_BASE64 does not decode to an App Store Connect API private key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "key_path=${key_path}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Build, sign, notarize, and stage macOS artifacts
|
||||
working-directory: desktop
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CSC_LINK: ${{ secrets.MACOS_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }}
|
||||
APPLE_API_KEY: ${{ steps.notary-key.outputs.key_path }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: node ./node_modules/electron-builder/cli.js --mac dmg zip --publish always
|
||||
|
||||
- name: Finalize notarized macOS release assets
|
||||
env:
|
||||
APPLE_API_KEY: ${{ steps.notary-key.outputs.key_path }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: node scripts/finalize-macos-release-assets.mjs
|
||||
|
||||
- name: Upload macOS release assets
|
||||
working-directory: desktop
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
tag="v${version}"
|
||||
dmg="release/Veritas-Kanban-${version}-mac-arm64.dmg"
|
||||
zip="release/Veritas-Kanban-${version}-mac-arm64.zip"
|
||||
|
||||
for file in "${dmg}" "${zip}"; do
|
||||
if [ ! -f "${file}" ]; then
|
||||
echo "::error::Expected release asset missing: ${file}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${file}" = "${dmg}" ]; then
|
||||
codesign --verify --verbose=2 "${file}"
|
||||
spctl -a -vvv -t open --context context:primary-signature "${file}"
|
||||
fi
|
||||
|
||||
checksum="$(shasum -a 256 "${file}" | awk '{print $1}')"
|
||||
printf '%s %s\n' "${checksum}" "$(basename "${file}")" > "${file}.sha256"
|
||||
done
|
||||
|
||||
assets=(
|
||||
"release/latest-mac.yml"
|
||||
"${dmg}"
|
||||
"${dmg}.blockmap"
|
||||
"${dmg}.sha256"
|
||||
"${zip}"
|
||||
"${zip}.blockmap"
|
||||
"${zip}.sha256"
|
||||
)
|
||||
|
||||
for file in "${assets[@]}"; do
|
||||
if [ ! -f "${file}" ]; then
|
||||
echo "::error::Expected release asset missing: ${file}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
gh release upload "${tag}" "${assets[@]}" --clobber
|
||||
39
.github/workflows/docker-image.yml
vendored
Normal file
39
.github/workflows/docker-image.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Docker Image Contract
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docker-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
contract:
|
||||
name: Build, Size, and Runtime Contract
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### Docker image milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\` or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Build production image
|
||||
run: docker build --target production --tag veritas-kanban:contract .
|
||||
|
||||
- name: Enforce image and runtime contract
|
||||
run: node scripts/check-docker-image.mjs veritas-kanban:contract
|
||||
203
.github/workflows/scheduled-qa.yml
vendored
Normal file
203
.github/workflows/scheduled-qa.yml
vendored
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
name: Scheduled QA
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
schedule:
|
||||
- cron: '17 8 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
load_profile:
|
||||
description: k6 load profile to run
|
||||
required: true
|
||||
default: smoke
|
||||
type: choice
|
||||
options:
|
||||
- smoke
|
||||
- full
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
K6_IMAGE: grafana/k6:1.7.1
|
||||
LOG_LEVEL: warn
|
||||
VERITAS_ADMIN_KEY: scheduled-qa-admin-key-000000000000
|
||||
VERITAS_AUTH_LOCALHOST_BYPASS: 'true'
|
||||
VERITAS_AUTH_LOCALHOST_ROLE: admin
|
||||
VERITAS_JWT_SECRET: scheduled-qa-jwt-secret-00000000000000000000000000000000
|
||||
VERITAS_STORAGE: sqlite
|
||||
RATE_LIMIT_MAX: '100000'
|
||||
RATE_LIMIT_WRITE_MAX: '100000'
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
name: Playwright E2E
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### Playwright E2E milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Reason: explicit \`ci:full\`, scheduled, or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Configure isolated Playwright data directory
|
||||
run: |
|
||||
echo "VERITAS_DATA_DIR=$RUNNER_TEMP/veritas-playwright-data" >> "$GITHUB_ENV"
|
||||
mkdir -p "$RUNNER_TEMP/veritas-playwright-data"
|
||||
|
||||
- name: Build shared package
|
||||
run: pnpm --filter @veritas-kanban/shared build
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium webkit
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
env:
|
||||
PLAYWRIGHT_HTML_REPORT: '1'
|
||||
PLAYWRIGHT_HTML_OPEN: never
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-artifacts
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
k6:
|
||||
name: k6 Load Smoke
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'ci:full')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
K6_PROFILE: ${{ github.event_name == 'workflow_dispatch' && inputs.load_profile || 'smoke' }}
|
||||
steps:
|
||||
- name: Record milestone selection
|
||||
run: |
|
||||
{
|
||||
echo "### k6 milestone"
|
||||
echo
|
||||
echo "- Trigger: \`$GITHUB_EVENT_NAME\`"
|
||||
echo "- Profile: \`$K6_PROFILE\`"
|
||||
echo "- Reason: explicit \`ci:full\`, scheduled, or manual milestone"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Configure isolated k6 data directory
|
||||
run: |
|
||||
echo "VERITAS_DATA_DIR=$RUNNER_TEMP/veritas-k6-data" >> "$GITHUB_ENV"
|
||||
mkdir -p "$RUNNER_TEMP/veritas-k6-data"
|
||||
|
||||
- name: Build runtime packages
|
||||
run: pnpm build
|
||||
|
||||
- name: Start API server
|
||||
run: |
|
||||
mkdir -p "$VERITAS_DATA_DIR"
|
||||
pnpm --filter @veritas-kanban/server start > "$RUNNER_TEMP/veritas-server.log" 2>&1 &
|
||||
echo "$!" > "$RUNNER_TEMP/veritas-server.pid"
|
||||
|
||||
- name: Wait for API health
|
||||
run: |
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS http://127.0.0.1:3001/api/health > /dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
cat "$RUNNER_TEMP/veritas-server.log"
|
||||
exit 1
|
||||
|
||||
- name: Run k6 profile
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p k6-results
|
||||
chmod 0777 k6-results
|
||||
|
||||
if [ "$K6_PROFILE" = "full" ]; then
|
||||
scripts="smoke read-load write-load mixed-load ws-stress v5-remote-mix"
|
||||
else
|
||||
scripts="smoke"
|
||||
fi
|
||||
|
||||
for script in $scripts; do
|
||||
docker run --rm --network host \
|
||||
-e BASE_URL=http://127.0.0.1:3001 \
|
||||
-e WS_URL=ws://127.0.0.1:3001/ws \
|
||||
-e API_KEY="$VERITAS_ADMIN_KEY" \
|
||||
-e V5_SEED_TASKS=120 \
|
||||
-e V5_SEED_CHATS=12 \
|
||||
-e V5_HTTP_VUS=20 \
|
||||
-e V5_WS_VUS=30 \
|
||||
-e V5_DURATION=45s \
|
||||
-e V5_WS_HOLD_MS=40000 \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
"$K6_IMAGE" run \
|
||||
--summary-export "k6-results/${script}.json" \
|
||||
"load-tests/k6/${script}.js" 2>&1 | tee "k6-results/${script}.log"
|
||||
done
|
||||
|
||||
- name: Stop API server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "$RUNNER_TEMP/veritas-server.pid" ]; then
|
||||
kill "$(cat "$RUNNER_TEMP/veritas-server.pid")" || true
|
||||
fi
|
||||
|
||||
- name: Upload k6 artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: k6-artifacts
|
||||
path: |
|
||||
k6-results/
|
||||
${{ runner.temp }}/veritas-server.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
65
.github/workflows/security.yml
vendored
Normal file
65
.github/workflows/security.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: Security Gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '17 9 * * 3'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
name: CodeQL
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
queries: security-extended
|
||||
- name: Analyze JavaScript and TypeScript
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
category: '/language:javascript-typescript'
|
||||
|
||||
gitleaks:
|
||||
name: Gitleaks
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
GITLEAKS_VERSION: 8.30.1
|
||||
GITLEAKS_LINUX_X64_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Download verified gitleaks release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="$RUNNER_TEMP/gitleaks.tar.gz"
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||
--output "$archive"
|
||||
echo "${GITLEAKS_LINUX_X64_SHA256} ${archive}" | sha256sum --check --status
|
||||
tar -xzf "$archive" -C "$RUNNER_TEMP" gitleaks
|
||||
- name: Guard security workflow policy
|
||||
run: pnpm check:security-gates
|
||||
- name: Scan reviewed tree and test detection
|
||||
env:
|
||||
GITLEAKS_BIN: ${{ runner.temp }}/gitleaks
|
||||
run: pnpm check:gitleaks
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,9 +1,11 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
|
||||
# IDE
|
||||
|
|
@ -42,8 +44,13 @@ tasks/archive/*.md
|
|||
tasks/backlog/*.md
|
||||
tasks/attachments/
|
||||
tasks/archive-attachments/
|
||||
/storage/
|
||||
/server/storage/
|
||||
.veritas-kanban/*
|
||||
!.veritas-kanban/.gitkeep
|
||||
.veritas-desktop-dev/
|
||||
desktop/.desktop-release/
|
||||
desktop/release/
|
||||
|
||||
# Historical broken config data (should never have been tracked)
|
||||
.veritas-kanban.broken/
|
||||
|
|
@ -59,6 +66,7 @@ coverage/
|
|||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.veritas-kanban/worktrees/
|
||||
.veritas-kanban/worktree-manifests/
|
||||
|
||||
# TypeScript build info
|
||||
*.tsbuildinfo
|
||||
|
|
@ -102,7 +110,7 @@ tasks/
|
|||
!tasks/
|
||||
!tasks/examples/
|
||||
!tasks/examples/*.md
|
||||
.veritas-kanban/
|
||||
/.veritas-kanban/
|
||||
|
||||
# Local security middleware (not shared)
|
||||
server/src/middleware/external-api-key.ts
|
||||
|
|
|
|||
|
|
@ -1,22 +1,53 @@
|
|||
# Gitleaks False Positives
|
||||
# Updated: 2026-01-29 (post-history-rewrite)
|
||||
# All entries below are placeholder/example/test values, NOT real secrets.
|
||||
# CLI snapshot uses an intentionally synthetic API key in serialized output.
|
||||
cli/src/__tests__/snapshot.test.ts:generic-api-key:220
|
||||
|
||||
# Documentation example: "your-admin-key" placeholder in deployment guide
|
||||
39423f74cf3849684e8de4ebf746156a6be0ea00:docs/DEPLOYMENT.md:curl-auth-header:557
|
||||
# API documentation contains non-functional response examples.
|
||||
docs/API-REFERENCE.md:generic-api-key:991
|
||||
docs/API-WORKFLOWS.md:generic-api-key:1460
|
||||
|
||||
# Documentation example: "dev-admin-key" placeholder in security audit
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:docs/SECURITY_AUDIT_2026-01-28.md:curl-auth-header:135
|
||||
# Operator documentation uses placeholders in curl authentication examples.
|
||||
docs/DEPLOYMENT.md:curl-auth-header:926
|
||||
docs/TROUBLESHOOTING.md:curl-auth-header:230
|
||||
docs/TROUBLESHOOTING.md:curl-auth-header:258
|
||||
docs/TROUBLESHOOTING.md:curl-auth-header:261
|
||||
docs/features/prd-driven-development.md:curl-auth-header:95
|
||||
docs/features/prd-driven-development.md:curl-auth-header:775
|
||||
docs/guides/SELF_HOST.md:curl-auth-header:742
|
||||
docs/security.md:curl-auth-header:51
|
||||
docs/security.md:curl-auth-header:58
|
||||
|
||||
# Test fixture: hardcoded test JWT secret (not used in production)
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:server/src/__tests__/routes/auth.test.ts:generic-api-key:28
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:server/src/__tests__/routes/auth.test.ts:generic-api-key:29
|
||||
f01a0157f1d1612ab7bea646cf7265ec018e1d9a:server/src/__tests__/routes/auth.test.ts:generic-api-key:60
|
||||
# Demo seeding passes the operator-provided key variable to curl.
|
||||
seed-demo-data.sh:curl-auth-header:45
|
||||
|
||||
# .env.example placeholder values ("your-api-key")
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:server/.env.example:curl-auth-header:43
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:server/.env.example:curl-auth-header:46
|
||||
# Environment template documents shell-variable authentication examples.
|
||||
server/.env.example:curl-auth-header:127
|
||||
server/.env.example:curl-auth-header:130
|
||||
|
||||
# Documentation placeholder values ("your-api-key")
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:docs/security.md:curl-auth-header:40
|
||||
55c742c2dfd731069505e1baab16bb4c328234bd:docs/security.md:curl-auth-header:47
|
||||
# Compatibility test verifies redaction of a deliberately synthetic value.
|
||||
server/src/__tests__/buzz-compatibility-service.test.ts:generic-api-key:477
|
||||
|
||||
# Governance trace test verifies Stripe-shaped token redaction.
|
||||
server/src/__tests__/governance-trace-service.test.ts:stripe-access-token:22
|
||||
|
||||
# Log redaction tests require JWT- and Stripe-shaped synthetic fixtures.
|
||||
server/src/__tests__/log-redaction.test.ts:jwt:17
|
||||
server/src/__tests__/log-redaction.test.ts:stripe-access-token:30
|
||||
server/src/__tests__/log-redaction.test.ts:stripe-access-token:31
|
||||
|
||||
# Completion service test verifies JWT-shaped output redaction.
|
||||
server/src/__tests__/provider-completion-service.test.ts:jwt:314
|
||||
|
||||
# Local admission tests use synthetic idempotency keys, not credentials.
|
||||
server/src/__tests__/routes/agents-local-capability.test.ts:generic-api-key:606
|
||||
server/src/__tests__/routes/agents-local-capability.test.ts:generic-api-key:619
|
||||
|
||||
# Authentication route tests require a synthetic JWT signing value.
|
||||
server/src/__tests__/routes/auth.test.ts:generic-api-key:29
|
||||
server/src/__tests__/routes/auth.test.ts:generic-api-key:31
|
||||
server/src/__tests__/routes/auth.test.ts:generic-api-key:80
|
||||
|
||||
# Skill capability test verifies Stripe-shaped token redaction.
|
||||
server/src/__tests__/skill-capability-service.test.ts:stripe-access-token:63
|
||||
|
||||
# Multi-user UI test renders a non-secret token prefix fixture.
|
||||
web/src/__tests__/multi-user-tab.test.tsx:generic-api-key:123
|
||||
|
|
|
|||
|
|
@ -1 +1,5 @@
|
|||
pnpm check:security-artifacts
|
||||
pnpm check:actions-pinned
|
||||
pnpm check:tracked-ignore
|
||||
node scripts/check-delivery-cadence.mjs
|
||||
npx lint-staged
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# Pre-commit hooks for veritas-kanban
|
||||
# Install: pip install pre-commit && pre-commit install
|
||||
# Or standalone gitleaks hook (no pre-commit framework needed):
|
||||
# gitleaks protect --staged --verbose
|
||||
# Or scan the reviewed tree without the pre-commit framework:
|
||||
# gitleaks dir . --redact=100
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.21.2
|
||||
rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # v8.30.1
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
|
|
|
|||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
pnpm-lock.yaml
|
||||
526
AGENTS.md
Normal file
526
AGENTS.md
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
# AGENTS.md — Canonical Agent Instructions for Veritas Kanban
|
||||
|
||||
> **Canonical source.** Contributors and harnesses with repository-instruction discovery read
|
||||
> this file first. Every Veritas-managed run also receives an immutable task envelope; do not
|
||||
> assume a provider that disables custom instructions reads repository files implicitly.
|
||||
> Harness-specific supplements (for example `CLAUDE.md`) extend, never duplicate or contradict,
|
||||
> these rules. See `docs/AGENTS-TEMPLATE.md` for the managed-run and external-agent protocols.
|
||||
>
|
||||
> **Version:** 6.1.2
|
||||
> **Freshness policy:** update within two working days of any toolchain or architecture change.
|
||||
> Stale fields (package manager, Node version, provider list, test commands) are caught by
|
||||
> `pnpm check:pnpm-settings` and the smoke-test CI job.
|
||||
|
||||
---
|
||||
|
||||
## Runtime requirements
|
||||
|
||||
| Tool | Required version | How to verify |
|
||||
| ------- | ---------------- | ---------------- |
|
||||
| Node.js | ≥ 22.22.1 | `node --version` |
|
||||
| pnpm | ≥ 11.0.0 | `pnpm --version` |
|
||||
| Git | ≥ 2.38 | `git --version` |
|
||||
|
||||
The `packageManager` field in `package.json` is pinned to `pnpm@11.1.1`. Do not install with npm
|
||||
or yarn. Do not up-rev the pin without updating this file.
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
veritas-kanban/
|
||||
├── server/ Express + TypeScript API, agent orchestration, storage
|
||||
├── web/ React + Vite SPA
|
||||
├── cli/ Commander.js CLI (mirrors API endpoints)
|
||||
├── shared/ Shared TypeScript types and utilities
|
||||
├── mcp/ MCP server
|
||||
├── desktop/ Electron desktop wrapper
|
||||
├── docs/ Operator and developer documentation
|
||||
├── prompt-registry/ Prompt templates and optional review workflows
|
||||
└── .veritas-kanban/ Runtime data: agent-registry, logs, telemetry
|
||||
```
|
||||
|
||||
Workspaces are declared in `pnpm-workspace.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Essential commands
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pnpm install
|
||||
|
||||
# Build (all workspaces in dependency order)
|
||||
pnpm build
|
||||
|
||||
# Dev server (server + web, hot-reload)
|
||||
pnpm dev
|
||||
|
||||
# Tests
|
||||
pnpm test # Canonical sequential workspace unit gate
|
||||
pnpm test:unit # Shared build, then server, web, CLI, and MCP
|
||||
pnpm test:coverage # Critical-path V8 coverage, HTML/JSON reports, and ratchets
|
||||
pnpm test:e2e # Playwright end-to-end, zero retries
|
||||
|
||||
# Type check (builds shared first)
|
||||
pnpm typecheck
|
||||
|
||||
# Lint / fix
|
||||
pnpm lint
|
||||
pnpm lint:fix
|
||||
|
||||
# Smoke checks
|
||||
pnpm check:actions-pinned # Rejects mutable external GitHub Action references
|
||||
pnpm check:pnpm-settings # Validates package manager fields match this file
|
||||
pnpm check:tracked-ignore # Rejects tracked files covered by ignore rules
|
||||
pnpm check:coverage-policy # Validates coverage policy, configs, CI, and regression tests
|
||||
pnpm check:delivery-cadence # Prevents verification and review policy drift
|
||||
pnpm check:security-gates # Validates CodeQL/gitleaks workflow and exact suppressions
|
||||
pnpm check:gitleaks # Scans reviewed tree and proves new-secret detection
|
||||
pnpm check:vite-native-config # Loads web build and test configs with Vite's native loader
|
||||
pnpm check:service-filesystem-boundary # Prevents new direct filesystem imports in services
|
||||
pnpm test:ci-scope # Validates path-aware CI test selection
|
||||
pnpm smoke:cli-mcp # CLI ↔ MCP compatibility smoke test
|
||||
pnpm test:buzz:compatibility # Credential-free composed Buzz release gate
|
||||
```
|
||||
|
||||
Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise, resolve with
|
||||
`pnpm install` and commit the updated `pnpm-lock.yaml` without reformatting it.
|
||||
|
||||
---
|
||||
|
||||
## GitHub workflow
|
||||
|
||||
- Use the authenticated GitHub CLI (`gh`) as the default interface for GitHub issues, pull
|
||||
requests, releases, workflow runs, and API calls.
|
||||
- Use `git` for local repository operations and `gh` for GitHub-hosted state.
|
||||
- Do not loop through alternate connectors or permission paths while `gh` is authenticated and
|
||||
can perform the operation.
|
||||
- Fall back only when `gh` is unavailable or cannot support the required operation. Report the
|
||||
exact blocker before changing paths.
|
||||
- Source every published GitHub release body from `docs/releases/vX.Y.Z.md` and pass that file
|
||||
to `gh release create` or `gh release edit` with `--notes-file`.
|
||||
- Never hand-author or repair a release body with `--notes`, the GitHub editor, or a raw API
|
||||
body. Edit the reviewed source file first, validate it, and publish that exact file.
|
||||
- Keep each prose paragraph and list item on one logical Markdown source line. Separate blocks
|
||||
with blank lines. Do not hard-wrap release prose or add carriage returns, trailing-space hard
|
||||
breaks, literal escaped newlines, HTML `<br>` tags, or blockquotes.
|
||||
- Prefer compact, natural paragraphs over bullet-per-sentence formatting. Use lists only for
|
||||
genuinely parallel items. Keep rendered prose blocks concise so they do not become walls of
|
||||
text on GitHub's release index.
|
||||
- Run `pnpm validate:release -- --version X.Y.Z`; the post-publication `--github` form also
|
||||
requires the published GitHub body to match the reviewed file exactly.
|
||||
- After publication, inspect both the releases index and tag page. Raw Markdown validation does
|
||||
not replace a rendered-format check.
|
||||
|
||||
---
|
||||
|
||||
## Sustainable execution cadence
|
||||
|
||||
- Keep each issue and pull request to one independently shippable behavior. When implementation
|
||||
reveals a separable UI surface, secondary integration, refactor, or hardening follow-up, open
|
||||
a linked issue instead of expanding the active pull request.
|
||||
- Re-scope before continuing when an issue no longer fits one coherent review, an unexpected
|
||||
subsystem becomes necessary, or verification work is larger than the behavior being changed.
|
||||
- At the 45-minute delivery checkpoint, if the issue is not pull-request ready, stop adding scope
|
||||
and report the concrete cause. Split independent remaining work into linked issues, or continue
|
||||
only when the next step is required to preserve correctness of the current behavior.
|
||||
- During ordinary implementation, use source inspection, changed-file formatting/linting, and
|
||||
touched-package type checking. Do not run workspace unit, coverage, E2E, desktop packaging, or
|
||||
Docker contract tests between implementation PRs.
|
||||
- When a maintainer explicitly declares a focused diagnostic milestone, run the exact Vitest slice
|
||||
once with
|
||||
`pnpm --filter <package> exec vitest run <exact-test-files>`. Do not use
|
||||
`pnpm --filter <package> test -- <test-files>` or
|
||||
`pnpm --filter <package> test -- --run <test-files>`; package wrappers can ignore that file
|
||||
boundary and expand into the entire package suite.
|
||||
- Do not rerun an unchanged passing gate after documentation, comments, or formatting-only edits.
|
||||
Rerun only the checks affected by the later change.
|
||||
- Use the complete workspace suite once at an explicit integration, critical-security, or release
|
||||
milestone. Pull-request label `ci:full`, scheduled CI, and manual full dispatch are the
|
||||
authoritative broad gates. Critical coverage, unsigned desktop artifacts, and the Docker image
|
||||
contract run only at those milestones.
|
||||
- Trust `scripts/select-ci-test-scope.mjs` and the `Select Test Scope` job to record the required
|
||||
CI tier. Ordinary pull requests and `main` pushes select no workspace tests. Do not add local
|
||||
test gates merely to duplicate a future milestone.
|
||||
- Do not wait for optional desktop packaging, artifact previews, or release workflows when the
|
||||
change does not touch their product boundary. They are evidence only when declared relevant.
|
||||
- Add enough regression coverage to prove the behavior and its meaningful failure modes. Test
|
||||
count is not a quality target.
|
||||
|
||||
---
|
||||
|
||||
## Architecture rules
|
||||
|
||||
### Server (Express + TypeScript)
|
||||
|
||||
- All routes go through centralized middleware in `server/src/middleware/`.
|
||||
- Auth: JWT + API keys. Dev bypass: `VERITAS_AUTH_LOCALHOST_BYPASS=true`.
|
||||
- Storage: always go through `storage/interfaces.ts`. Never import `fs` directly in service files.
|
||||
- Append-only durable records must complete the entire serialized write before
|
||||
sync. Never assume one `FileHandle.write()` call wrote every byte or ignore
|
||||
`bytesWritten`.
|
||||
- Error classes: `UnauthorizedError`, `ForbiddenError`, `BadRequestError`, `InternalError`.
|
||||
- Pagination: `sendPaginated(res, items, { page, limit, total })`.
|
||||
- Path traversal: always call `validatePathSegment()` on any user-supplied path component,
|
||||
then `ensureWithinBase(base, resolved)` before file I/O.
|
||||
- SQLite journal conversion runs from the bootstrap before `server.ts` imports routes. Normal
|
||||
startup eagerly creates many independent SQLite handles, so a live API handler cannot prove
|
||||
exclusive database ownership.
|
||||
- Governed SQLite `DELETE` or expert-override mode requires the signed external policy and the
|
||||
reference-counted process/host ownership lock. Do not reuse the short-lived generic `FileLock`
|
||||
for authoritative database ownership.
|
||||
|
||||
### Web (React + Vite)
|
||||
|
||||
- State: Zustand stores. No prop drilling past 2 levels.
|
||||
- Realtime: `useRealtimeUpdates` WebSocket hooks. Do not add polling when a hook exists.
|
||||
- Styling: Tailwind CSS with component-scoped overrides.
|
||||
- Frontend interfaces must exactly match server response shapes. Server is the source of truth.
|
||||
|
||||
### CLI (Commander.js)
|
||||
|
||||
- Every command mirrors an API endpoint.
|
||||
- `--json` flag for machine-readable output.
|
||||
- Colored output via `chalk`.
|
||||
|
||||
### Shared types
|
||||
|
||||
- All cross-package types live in `shared/src/types/`.
|
||||
- `AgentProvider` union is the single definition consumed by both server and web.
|
||||
**Currently supported providers:**
|
||||
`openclaw` | `codex-cli` | `codex-sdk` | `codex-app-server` | `codex-cloud` |
|
||||
`claude-code` | `acp-stdio` | `hermes-cli` | `ollama-local` | `ollama-cloud` |
|
||||
`lm-studio-local` | `custom`
|
||||
- Executable task adapters are currently `openclaw`, `codex-cli`, `codex-sdk`,
|
||||
`codex-app-server`, `claude-code`, `acp-stdio`, and `hermes-cli`. Explicitly
|
||||
configured providers outside that set must fail closed; never route them
|
||||
through an implicit OpenClaw fallback.
|
||||
- Probe and persist `provider-runtime-manifest/v1` before mutating attempt state.
|
||||
New runtime controls must use the persisted evidence instead of provider-name
|
||||
checks, and provider version/build changes must invalidate cached conformance.
|
||||
Increment `PROVIDER_RUNTIME_PROBE_REVISION` whenever probe semantics or the
|
||||
built-in adapter capability evidence changes.
|
||||
- Normalize every configured harness through `harness-support-profile/v1`.
|
||||
Settings, API diagnostics, `vk doctor`, dispatch, and telemetry must use the
|
||||
same support tier and redacted readiness evidence. Only known legacy records
|
||||
whose built-in type and command both identify `codex` or `hermes` may infer a
|
||||
provider during migration; provider-less or profile/adapter-mismatched records
|
||||
fail closed before an attempt is created.
|
||||
- Route direct, profile, conversation, provider-handoff, child-agent, retry,
|
||||
fallback, scheduled, watcher, and workflow launches through the shared
|
||||
admission controller. A `queued` response means Veritas durably accepted
|
||||
ownership; harnesses must not submit a duplicate or create a hidden
|
||||
provider-side queue. Provider adapters require
|
||||
`provider-admission-evidence/v1` before dispatch.
|
||||
- Phase authority uses the versioned contracts in
|
||||
`shared/src/types/phase-capability.types.ts`. Compile parent, phase, agent
|
||||
profile, sandbox, tool-catalog, and launch-policy authority only through
|
||||
`phase-capability-service.ts`; never union scopes or infer missing
|
||||
dimensions. The plan artifact exception is one harness-owned exact path and
|
||||
never implies general filesystem write authority. Active phase changes go
|
||||
only through `phase-transition-service.ts` with exact attempt, sequence,
|
||||
evidence-digest, and launch-manifest compare-and-set guards. Authority
|
||||
expansion requires an exact-action approval; an emergency override requires
|
||||
`admin:manage`, expires within 24 hours, and is durably reverted. Every task
|
||||
launch, workflow step, retry or fallback, resume, follow-up, fork, compaction
|
||||
control, and provider handoff must bind the effective phase before attempt
|
||||
mutation. Descendants inherit and intersect the exact parent launch or
|
||||
transition evidence and cannot widen it. Explicit phases fail closed when any
|
||||
required dimension is not enforceable. Run tool catalogs are filtered by the
|
||||
launch phase, mediated calls re-check the active phase, and approvals bind the
|
||||
exact phase evidence and transition sequence. ACP stdio is the only current
|
||||
adapter with enforceable command and external-action mediation; other
|
||||
adapters return typed blockers for explicit phases.
|
||||
- Credential-bound tool servers persist only exact definition/scope digests and
|
||||
safe target names in `run-tool-catalog/v1`. Discovery strips their source
|
||||
environment/header values, native provider injection omits them, and
|
||||
mediated invocation issues exact-action leases using the server-owned launch
|
||||
manifest digest. Credential-bound sessions are one-shot and raw values may
|
||||
exist only inside the controlled downstream dispatch callback.
|
||||
- Providers access credential-bound tools only through the system-owned
|
||||
`veritas-run` MCP bridge and an opaque in-memory run handle. Codex CLI/SDK,
|
||||
Codex app-server, Claude Code, and ACP stdio inject this shared contract;
|
||||
Hermes and OpenClaw fail closed until their certified transports can enforce
|
||||
it.
|
||||
- Classify launch credentials through `run-launch-credential-plan/v1`.
|
||||
Provider boot authentication, task integration definition IDs, and explicit
|
||||
high-risk environment passthrough are separate classes. Task integration
|
||||
credentials fail closed until an accepted tool or egress boundary proves
|
||||
brokered, non-bypassable delivery.
|
||||
- Atomically persist `admission-reservation/v1` before direct task attempts,
|
||||
workflow roots, executable workflow steps, pending-run state, or provider
|
||||
state. Workflow roots use the explicit `workflow-control` admission provider;
|
||||
provider-backed steps bind the resolved provider, selected host, root
|
||||
reservation, run, and step before attempt mutation. Capacity claims use the
|
||||
storage repository transaction or file lock, never process-local counters.
|
||||
Keep the invariant one-active-run-per-task policy and configured global,
|
||||
workspace, root-task, provider, and host ceilings aligned across dispatch,
|
||||
REST, and `vk`.
|
||||
Persist only a stable digest of caller-supplied idempotency values.
|
||||
Completion, interruption, cancellation, and start failure release once;
|
||||
restart recovery may reclaim only after the durable run supervisor verifies
|
||||
the original live process or session.
|
||||
- Bind every executable reservation to `execution-tree-identity/v1`. Descendants
|
||||
retain the root objective and exact parent edge across resume, follow-up,
|
||||
fork, retry, fallback, provider handoff, workflow step, and child-agent
|
||||
launches. Claim capacity and aggregate budget in the same repository lock or
|
||||
transaction. Usage events must be idempotent and attributable to one node;
|
||||
never copy cumulative parent or descendant totals into another contributor.
|
||||
Release unused reservation while retaining committed usage.
|
||||
- Persist `run-supervisor/v1` before provider dispatch. Restart recovery must
|
||||
validate the exact runtime, task-envelope, launch-manifest, worktree, host,
|
||||
lease, and process/session identity; replay only after the durable event
|
||||
cursor; and record a typed recovery action instead of starting duplicate work
|
||||
or signaling an unverified process.
|
||||
- Resolve selected MCP servers through `tool-server-definition/v1` and persist
|
||||
an immutable `run-tool-catalog/v1` before provider dispatch. Required
|
||||
discovery failures block launch; optional failures remain visible and
|
||||
audited.
|
||||
- Native provider configuration may expose only tools with an `allow`
|
||||
decision. Approval-required tools must use the Veritas-mediated
|
||||
`call_run_tool` path so the exact action hash is approved before dispatch.
|
||||
- Tool-server environment values and credential values are never persisted.
|
||||
Credential-bound tool definitions remain fail-closed until the provider
|
||||
launch credential broker is active.
|
||||
- Run-owned commands use `run-terminal-handle/v1`, never a provider's generic
|
||||
stdin channel. The current runtime supports background pipe mode with
|
||||
exact-action approval, stable request IDs, manifest-approved executable,
|
||||
cwd, and environment posture, bounded
|
||||
cursor-addressable redacted output, bounded single/any/all waits,
|
||||
foreground detachment, process-group termination, and durable journal
|
||||
reconstruction. A dangling handle becomes `interrupted` after restart
|
||||
because inherited pipes cannot be reattached safely. PTY, interactive stdin,
|
||||
and restart reattachment fail closed until their typed controls ship.
|
||||
- Harnesses start a run-owned command with
|
||||
`POST /api/v1/run-terminals/runs/:taskId/:attemptId/execute`. Send one stable
|
||||
`requestId`, a command plus argument array, `mode: "pipe"`, start mode,
|
||||
optional worktree-relative cwd, and environment names only. A `202`
|
||||
response requires an operator decision through `run-approvals`; retry the
|
||||
identical request after approval to receive the `201` handle. Never place
|
||||
credential values in arguments or environment fields.
|
||||
|
||||
---
|
||||
|
||||
## Agent provider notes
|
||||
|
||||
### OpenClaw (v2026.6.11)
|
||||
|
||||
- Task dispatch uses the gateway `/tools/invoke` endpoint with `sessions_spawn`.
|
||||
- **Required gateway policy:** `sessions_spawn` and `sessions_send` must be explicitly allowed
|
||||
on the operator-level gateway; they are blocked by default on fresh OpenClaw installs.
|
||||
- Set `OPENCLAW_GATEWAY_URL` (default `http://127.0.0.1:18789`) and optionally
|
||||
`OPENCLAW_GATEWAY_TOKEN`.
|
||||
- A pre-flight check is run before a task is marked active; policy denial returns an actionable
|
||||
configuration error.
|
||||
- See `docs/AGENT-PROVIDERS.md` § OpenClaw for full setup instructions.
|
||||
|
||||
### Hermes Agent (v2026.7.7.2)
|
||||
|
||||
- Dispatch uses the one-shot scripted interface: `hermes -z <prompt>`.
|
||||
- Hermes is spawned in the task worktree without a shell; stdout captures the final response,
|
||||
stderr captures diagnostics.
|
||||
- Project instructions are loaded automatically from `AGENTS.md` in the worktree root.
|
||||
- Session resume is not yet implemented; `--resume`/`--continue` are reserved for a future
|
||||
provider iteration.
|
||||
- Provider ID: `hermes-cli`. Auth probe: `hermes --version`.
|
||||
- Set `HERMES_API_KEY` or the appropriate model-provider key in the operator environment.
|
||||
- See `docs/AGENT-PROVIDERS.md` § Hermes for full setup instructions.
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
- `codex-cli`: `codex exec --sandbox workspace-write --json`
|
||||
- `codex-sdk`: programmatic SDK, requires `@openai/codex-sdk`
|
||||
- `codex-app-server`: pinned to `codex-cli 0.145.0`; supervised JSON-RPC v2 over
|
||||
strict stdio for one task-bound thread and turn.
|
||||
- App-server launch arguments are system-owned. Inherited MCP servers, hooks,
|
||||
plugins, apps, browser/computer tools, and remote control remain disabled.
|
||||
Selected run-scoped MCP servers are injected only through the immutable
|
||||
catalog's thread configuration.
|
||||
- App-server consumes only the checked-in v0.145.0 schemas and exposes
|
||||
`initialize`, thread start/resume/fork/compact/archive, and turn
|
||||
start/steer/interrupt. `thread/shellCommand` is never reachable.
|
||||
- `conversation-lifecycle/v1` persists opaque thread, turn, item, parent, and
|
||||
fork identities. Resume and fork validate the source launch manifest,
|
||||
provider/model/policy, base revision, and worktree compatibility before a new
|
||||
attempt is created.
|
||||
- App-server command, file, permission, tool-question, and elicitation requests
|
||||
use `run-approval/v1`. Decisions must preserve the persisted revision and
|
||||
exact action hash; interruption and cancellation invalidate pending requests.
|
||||
- Auth: `codex login status` / `OPENAI_API_KEY`
|
||||
|
||||
### Claude Code (v2.1.218)
|
||||
|
||||
- Provider ID: `claude-code`. Default command: `claude`.
|
||||
- Veritas launches `claude --bare --print --output-format stream-json` with
|
||||
static sandbox-derived permissions and no shell.
|
||||
- Bare mode requires explicit environment authentication. OAuth/keychain state
|
||||
reported by `claude auth status` does not prove bare-mode readiness.
|
||||
- The terminal `result` record is authoritative. Veritas drains stdout after
|
||||
process close, persists `session_id`, and maps partial, hook, tool, subagent,
|
||||
usage, cost, and result records into `run-event/v1`.
|
||||
- Resume uses the exact persisted session through system-owned `--resume`.
|
||||
Native history fork adds `--fork-session`; caller-supplied lifecycle flags
|
||||
remain prohibited. Run-scoped MCP uses a system-owned strict config and
|
||||
exposes only catalog tools with an `allow` decision.
|
||||
- The shared approval broker is available, but Claude stays on static
|
||||
`dontAsk` permissions until its adapter exposes a pinned interactive
|
||||
request/response contract.
|
||||
|
||||
### Agent Client Protocol (ACP v1)
|
||||
|
||||
- Provider ID: `acp-stdio`. Configure the exact ACP agent command and arguments.
|
||||
- Veritas launches the agent without a shell in the task worktree and negotiates
|
||||
stable ACP protocol version 1 before attempt mutation.
|
||||
- Capability evidence comes from `initialize`; resume/load, fork, and close fail
|
||||
closed when the runtime does not advertise them.
|
||||
- `session/update` records enter the causal run journal.
|
||||
`session/request_permission` uses the durable approval broker.
|
||||
- Only immutable all-allow MCP server catalogs can be passed natively because
|
||||
ACP v1 has no per-tool allowlist. Profiles may explicitly require the
|
||||
system-owned `veritas-run` bridge for mediated catalogs; otherwise partial
|
||||
native catalogs fail closed.
|
||||
- The built-in `buzz-agent` profile remains provider `acp-stdio`, pins Buzz
|
||||
`v0.4.24` at commit `710ed9fff57878a1d69f809b80a6ee0416c53fc4`, and rejects
|
||||
`buzz-acp`, version drift, session loading, and network MCP claims. Selected
|
||||
run tools are delivered only through the opaque, attempt-bound
|
||||
`veritas-run` bridge.
|
||||
- The built-in `copilot` profile remains provider `acp-stdio`, pins Copilot CLI
|
||||
`v1.0.74`, owns the stdio safety argv, rejects broad allow/remote/TCP/config
|
||||
injection, and records public-preview plus incomplete-source limitations.
|
||||
- The built-in `grok-build` profile remains provider `acp-stdio`, pins Grok
|
||||
Build `v0.2.111` build `94172f2aa4e5`, launches `grok agent --no-leader
|
||||
stdio`, and rejects approval bypass, reauthentication, leader, plugin,
|
||||
endpoint, prompt, and resume argument injection.
|
||||
- Harness certification uses `harness-conformance-suite/v1`; run the committed
|
||||
mock lane with `pnpm --filter @veritas-kanban/server exec tsx
|
||||
src/scripts/run-harness-conformance.ts -- --suite <suite.json>
|
||||
--observations <observations.json>`. Credential-gated lanes require explicit
|
||||
opt-in and never commit raw provider output or secrets.
|
||||
- Cross-harness compatibility is published as
|
||||
`harness-compatibility-matrix/v1`. API, `vk doctor`, Settings, telemetry, and
|
||||
`docs/HARNESS-COMPATIBILITY.md` must use the reviewed profile capability
|
||||
digest, fixture revision, invalidation policy, and source caveats rather than
|
||||
defining provider-specific tiers.
|
||||
- Runtime extensions use the in-process `runtime-hook/v1` bus. Only documented
|
||||
pre-events may deny, post-events remain passive, and arbitrary executable or
|
||||
HTTP handlers stay unsupported until their filesystem and egress boundaries
|
||||
are enforceable. See `docs/architecture/RUNTIME-HOOK-V1.md`.
|
||||
- `vk acp serve --stdio` exposes one Veritas-managed task as an ACP v1 server
|
||||
view for editors and other ACP clients. Bind with `--task` or require
|
||||
`_meta["veritas/taskId"]` on `session/new`; client-owned MCP catalogs fail
|
||||
closed.
|
||||
- ACP client disconnect never stops the durable Veritas run. Reconnect with
|
||||
`session/load` and `_meta["veritas/afterSequence"]`; cancellation uses the
|
||||
conversation interrupt path, not task termination.
|
||||
- See `docs/AGENT-PROVIDERS.md` § ACP stdio agent provider.
|
||||
|
||||
---
|
||||
|
||||
## Security boundaries
|
||||
|
||||
- **No secrets in code.** Use environment variables or brokered credentials.
|
||||
- **Input validation.** All user input is validated with Zod schemas before processing.
|
||||
- **Path traversal.** `validatePathSegment()` + `ensureWithinBase()` on every user-supplied path.
|
||||
- **Env passthrough.** Agents receive only the keys in the configured safe allowlist; see
|
||||
`server/src/utils/codex-env.ts`, `server/src/utils/hermes-env.ts`, and
|
||||
`server/src/services/claude-code-adapter.ts` plus
|
||||
`server/src/services/acp-stdio-adapter.ts`.
|
||||
- **Launch arguments.** Never put credential values in provider commands or arguments; use an
|
||||
allowlisted environment key or run-scoped brokered credential reference.
|
||||
- **Workspace execution trust.** Scan repository-controlled instructions,
|
||||
hooks, MCP servers, workflows, extensions, and provider configuration before
|
||||
launch. Bind the exact inventory and decision to the run launch manifest,
|
||||
then rescan before provider creation. Project policy may narrow trust only.
|
||||
- **Log redaction.** Trace logs and telemetry run through `TRACE_SECRET_PATTERNS` before storage.
|
||||
- **No credentials in PR descriptions, test fixtures, or log snippets.**
|
||||
|
||||
---
|
||||
|
||||
## Testing expectations
|
||||
|
||||
- Framework: **Vitest** (server, cli, mcp), **React Testing Library** (web).
|
||||
- Test files: `*.test.ts` co-located in `src/__tests__/` or alongside source.
|
||||
- Aim for >80% coverage on critical paths (agent dispatch, auth, storage adapters).
|
||||
- Use `vi.mock()`/`vi.fn()` to isolate external processes and HTTP calls; no live credentials
|
||||
in unit tests.
|
||||
- Credential-gated smoke tests document the tested provider version in a `@smoke` describe block.
|
||||
- Live MCP-to-HTTP integration groups require a running API and explicit
|
||||
`VK_MCP_INTEGRATION_TEST=1`; the default MCP test suite must remain
|
||||
server-independent.
|
||||
- Match actual runtime schema in test fixtures — wrong field names (`status: "success"` vs
|
||||
`success: true`) are a common source of false-passing tests.
|
||||
|
||||
---
|
||||
|
||||
## Multi-agent runtime
|
||||
|
||||
- Agent registry: `.veritas-kanban/agent-registry.json` (file-based).
|
||||
- Agent names: use ALL CAPS for acronyms (VERITAS, TARS, CASE, K-2SO, R2-D2, MAX).
|
||||
- Heartbeat timeout: 5 min (configurable). Stale-check interval: 1 min.
|
||||
- Activity data source of truth: `status-history` files, not `activity.json`.
|
||||
- Dashboard optimistic updates: use `onMutate` in Zustand mutations.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
| Artifact | Style |
|
||||
| ----------- | --------------------------------------------------------- |
|
||||
| TS files | `kebab-case.ts` |
|
||||
| Components | `PascalCase.tsx` |
|
||||
| Variables | `camelCase` |
|
||||
| Constants | `UPPER_SNAKE_CASE` |
|
||||
| Git commits | Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`) |
|
||||
| Branches | `feat/description-issue-number`, `fix/...`, or `docs/...` |
|
||||
|
||||
---
|
||||
|
||||
## Code quality gates
|
||||
|
||||
1. **No direct `fs` imports** in service files — use the storage abstraction layer.
|
||||
2. **All provider schemas validated** — do not guess flag names; verify against versioned docs
|
||||
or provider `--help` output.
|
||||
3. **pnpm-lock.yaml** is generated by pnpm; do not reformat or hand-edit it.
|
||||
|
||||
Independent or cross-model review is optional. Run it only when the task,
|
||||
configured governance policy, issue owner, or release owner explicitly requires
|
||||
it.
|
||||
|
||||
---
|
||||
|
||||
## File locations quick-reference
|
||||
|
||||
| What | Where |
|
||||
| ----------------- | -------------------------------------------------------- |
|
||||
| API routes | `server/src/routes/` |
|
||||
| Services | `server/src/services/` |
|
||||
| Zod schemas | `server/src/schemas/` |
|
||||
| Storage | `server/src/storage/` |
|
||||
| Server utilities | `server/src/utils/` |
|
||||
| Provider adapters | `server/src/services/agent-provider-adapter-registry.ts` |
|
||||
| React components | `web/src/components/` |
|
||||
| Zustand stores | `web/src/stores/` |
|
||||
| CLI commands | `cli/src/commands/` |
|
||||
| Shared types | `shared/src/` |
|
||||
| MCP server | `mcp/src/` |
|
||||
| Prompt registry | `prompt-registry/` |
|
||||
| SOPs | `docs/SOP-*.md` |
|
||||
| Agent registry | `.veritas-kanban/agent-registry.json` |
|
||||
| Agent run logs | `.veritas-kanban/logs/` |
|
||||
| Telemetry events | `.veritas-kanban/telemetry/` |
|
||||
|
||||
---
|
||||
|
||||
## Harness instruction sources
|
||||
|
||||
| Harness | Instruction source | Purpose |
|
||||
| ------------------ | ---------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| Buzz Agent | Veritas task envelope; repository files only if the runtime reads them | ACP task, worktree, tool, and completion contract |
|
||||
| Grok Build | Veritas task envelope | ACP task, worktree, tool, and completion contract |
|
||||
| GitHub Copilot CLI | Veritas task envelope | ACP task, worktree, tool, and completion contract |
|
||||
| Codex / GPT | `AGENTS.md` plus Veritas task envelope | Canonical repository rules and managed-run contract |
|
||||
| Claude Code | `AGENTS.md`, `CLAUDE.md`, and Veritas task envelope | Canonical rules plus Claude-specific lessons |
|
||||
| Hermes | `AGENTS.md` plus Veritas task envelope | Hermes reads `AGENTS.md` from the worktree |
|
||||
| OpenClaw | `AGENTS.md` plus the gateway task request | Canonical rules and callback completion contract |
|
||||
1169
CHANGELOG.md
1169
CHANGELOG.md
File diff suppressed because it is too large
Load diff
164
CLAUDE.md
164
CLAUDE.md
|
|
@ -1,150 +1,64 @@
|
|||
# CLAUDE.md — Agent Guidelines for Veritas Kanban
|
||||
# CLAUDE.md — Claude-Specific Supplement for Veritas Kanban
|
||||
|
||||
This file defines project-specific rules, context, and lessons learned for AI agents working on Veritas Kanban. Update it after every mistake, discovery, or workflow change.
|
||||
|
||||
> **Last updated:** 2026-02-06 (v2.0.0)
|
||||
> **Freshness check:** Review monthly or after major releases
|
||||
> **Canonical instructions are in `AGENTS.md`.** Read that file first. This supplement contains
|
||||
> Claude-specific lessons and common mistakes caught by previous Claude runs. Do not duplicate
|
||||
> `AGENTS.md` content here.
|
||||
>
|
||||
> **Last updated:** 2026-08-24 (v6.1.2 release freshness)
|
||||
> **Freshness check:** Update after mistakes; review monthly.
|
||||
|
||||
---
|
||||
|
||||
## Project Context
|
||||
## What changed in v2.1
|
||||
|
||||
**Veritas Kanban** is an open-source AI-native task management system. It's designed for humans + AI agents to collaborate on work through a shared board, CLI, and API.
|
||||
`AGENTS.md` is now the canonical project instruction file. It supersedes the duplicate context
|
||||
that was previously embedded here. The fields updated from their stale v2.0 values:
|
||||
|
||||
- **Primary language:** TypeScript (strict mode)
|
||||
- **Monorepo:** pnpm workspaces — `server/`, `web/`, `cli/`, `shared/`, `mcp/`
|
||||
- **Build:** Node 22+, pnpm 9+
|
||||
- **Test:** Vitest (server), React Testing Library (web)
|
||||
- **Style:** ESLint + Prettier, conventional commits
|
||||
- **pnpm:** was `9+` → now `≥ 11.0.0` (pinned `pnpm@11.1.1`)
|
||||
- **Node:** was `22+` → now `≥ 22.22.1`
|
||||
- **Providers:** managed Buzz, Grok Build, Codex, Claude Code, Copilot CLI,
|
||||
Hermes, and OpenClaw contracts are documented in `AGENTS.md`
|
||||
|
||||
---
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
### Server (Express + TypeScript)
|
||||
|
||||
- All routes go through centralized middleware in `server/src/middleware/`
|
||||
- Auth: JWT + API keys, localhost bypass for dev (`VERITAS_AUTH_LOCALHOST_BYPASS=true`)
|
||||
- Storage: Abstract via `storage/interfaces.ts` — never import `fs` directly in services
|
||||
- Error handling: Use `UnauthorizedError`, `ForbiddenError`, `BadRequestError`, `InternalError`
|
||||
- Pagination: Use `sendPaginated(res, items, {page, limit, total})`
|
||||
|
||||
### Web (React + Vite)
|
||||
|
||||
- State: Zustand stores, no prop drilling past 2 levels
|
||||
- Realtime: WebSocket via `useRealtimeUpdates` hooks
|
||||
- Styling: Tailwind CSS, component-scoped styles
|
||||
|
||||
### CLI (Commander.js)
|
||||
|
||||
- Every command mirrors an API endpoint
|
||||
- JSON output via `--json` flag for scripting
|
||||
- Use `chalk` for colored output
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Gates
|
||||
|
||||
1. **Cross-model review required for all code changes**
|
||||
- If Claude writes it, GPT reviews (and vice versa)
|
||||
- See `prompt-registry/cross-model-review.md`
|
||||
|
||||
2. **No hardcoded secrets** — use environment variables
|
||||
|
||||
3. **All user input validated** — use Zod schemas
|
||||
|
||||
4. **Path traversal prevention** — use `validatePathSegment()` from security module
|
||||
|
||||
5. **Tests for new features** — aim for >80% coverage on critical paths
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes (Don't Repeat These)
|
||||
## Lessons learned (Claude-specific)
|
||||
|
||||
### Security
|
||||
|
||||
- ❌ Forgot global middleware — flagged missing per-route auth that was already in `app.use()`
|
||||
- ❌ Used `path.join()` without validation — allows `../` traversal
|
||||
- ✅ Always check `validatePathSegment()` for any user-supplied path component
|
||||
- ❌ Forgot global middleware — flagged missing per-route auth that was already in `app.use()`.
|
||||
Global middleware is in `server/src/middleware/`; check there before adding per-route auth.
|
||||
- ❌ Used `path.join()` without validation — allows `../` traversal.
|
||||
Always follow with `validatePathSegment()` + `ensureWithinBase()`.
|
||||
|
||||
### Architecture
|
||||
|
||||
- ❌ Imported `fs` directly in service files — breaks storage abstraction
|
||||
- ❌ Added polling when WebSocket hook existed — use `useRealtimeAgentStatus`
|
||||
- ❌ Frontend interface didn't match server response (e.g., `totalAgents` vs `total` in registry stats)
|
||||
- ✅ Check for existing hooks/services before creating new ones
|
||||
- ✅ Server response format is source of truth — frontend interfaces must match exactly
|
||||
|
||||
### Multi-Agent (v2.0)
|
||||
|
||||
- Agent names use ALL CAPS for acronyms (VERITAS, TARS, CASE, K-2SO, R2-D2, MAX)
|
||||
- Agent registry is file-based at `.veritas-kanban/agent-registry.json`
|
||||
- Heartbeat timeout: 5 min (configurable). Stale check interval: 1 min
|
||||
- Activity data uses `status-history` (not `activity.json`) as source of truth
|
||||
- Timezone: server uses local time; clients send `?tz=<offset>` for cross-region display
|
||||
- Dashboard widgets: use `onMutate` for optimistic updates (archive, status changes)
|
||||
- ❌ Imported `fs` directly in service files — breaks storage abstraction.
|
||||
- ❌ Added polling when WebSocket hook existed — use `useRealtimeAgentStatus`.
|
||||
- ❌ Frontend interface didn't match server response (`totalAgents` vs `total`).
|
||||
Server response is the source of truth; interfaces must match exactly.
|
||||
- ❌ Agent provider guessing — always verify flag names and interfaces against versioned docs
|
||||
before implementing a new provider adapter.
|
||||
|
||||
### Testing
|
||||
|
||||
- ❌ Used wrong field in backfilled events (`status: "success"` vs `success: true`)
|
||||
- ✅ Match actual runtime schema exactly in test fixtures
|
||||
- ❌ Used wrong schema field in test fixtures (`status: "success"` vs `success: true`).
|
||||
Copy fixture shapes from live runtime output or type definitions, not from memory.
|
||||
- ✅ `pnpm-lock.yaml` is generated by pnpm; never reformat or hand-edit it.
|
||||
|
||||
### Multi-agent runtime
|
||||
|
||||
- Heartbeat timeout: 5 min. Stale-check interval: 1 min.
|
||||
- Activity source of truth: `status-history` files, not `activity.json`.
|
||||
- Dashboard optimistic updates: use `onMutate`, not refetch-after-mutate.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
## When to update this file
|
||||
|
||||
### Naming
|
||||
|
||||
- Files: `kebab-case.ts`
|
||||
- Components: `PascalCase.tsx`
|
||||
- Variables/functions: `camelCase`
|
||||
- Constants: `UPPER_SNAKE_CASE`
|
||||
|
||||
### Git
|
||||
|
||||
- Branch: `feat/description-issue-number`, `fix/description-issue-number`
|
||||
- Commit: Conventional commits (`feat:`, `fix:`, `docs:`, `chore:`)
|
||||
- PR: Always reference issue number
|
||||
|
||||
### Task Workflow
|
||||
|
||||
1. Start timer: `vk begin <id>`
|
||||
2. Update status: `vk status <id> in-progress`
|
||||
3. Work, commit, push
|
||||
4. Cross-model review
|
||||
5. Complete: `vk done <id> "summary"`
|
||||
- After a mistake that a rule would have prevented.
|
||||
- After any review catches a systemic pattern worth preserving.
|
||||
- Monthly freshness review.
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
| What | Where |
|
||||
| ---------------- | ------------------------------------- |
|
||||
| API routes | `server/src/routes/` |
|
||||
| Services | `server/src/services/` |
|
||||
| Schemas | `server/src/schemas/` |
|
||||
| Storage | `server/src/storage/` |
|
||||
| React components | `web/src/components/` |
|
||||
| Zustand stores | `web/src/stores/` |
|
||||
| CLI commands | `cli/src/commands/` |
|
||||
| Shared types | `shared/src/` |
|
||||
| MCP server | `mcp/src/` |
|
||||
| Prompts | `prompt-registry/` |
|
||||
| SOPs | `docs/SOP-*.md` |
|
||||
| Agent registry | `.veritas-kanban/agent-registry.json` |
|
||||
| Telemetry events | `.veritas-kanban/telemetry/` |
|
||||
|
||||
---
|
||||
|
||||
## When to Update This File
|
||||
|
||||
- After a bug that could have been prevented by a rule
|
||||
- After discovering a pattern that should be standard
|
||||
- After a cross-model review catches something systemic
|
||||
- Monthly freshness review (add to calendar)
|
||||
|
||||
---
|
||||
|
||||
## Credit
|
||||
|
||||
Structure inspired by Anthropic's CLAUDE.md convention and [BoardKit Orchestrator](https://github.com/BoardKit/orchestrator) by Monika Voutov.
|
||||
_Structure inspired by Anthropic's CLAUDE.md convention._
|
||||
|
|
|
|||
197
CONTRIBUTING.md
197
CONTRIBUTING.md
|
|
@ -5,7 +5,7 @@ Thanks for your interest in contributing! This guide will help you get started.
|
|||
## Prerequisites
|
||||
|
||||
- **Node.js** 22 or later
|
||||
- **pnpm** 9+ (package manager)
|
||||
- **pnpm** 11+ (package manager)
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
|
@ -73,31 +73,75 @@ veritas-kanban/
|
|||
|
||||
2. Make your changes — write code, add tests, update docs.
|
||||
|
||||
3. Run type checking, linting, and tests before committing:
|
||||
3. Run touched-package type checking and changed-file linting before
|
||||
committing. Ordinary implementation pull requests do not run workspace
|
||||
tests:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm --filter @veritas-kanban/server typecheck
|
||||
pnpm exec eslint server/src/path/to/changed.ts
|
||||
```
|
||||
|
||||
At an explicitly declared focused diagnostic milestone, use direct
|
||||
`exec vitest run` invocation for one exact-file slice. Do not use
|
||||
`pnpm --filter <package> test -- <test-files>` or
|
||||
`pnpm --filter <package> test -- --run <test-files>` as a focused command.
|
||||
Package wrappers can ignore that file boundary and expand into the entire
|
||||
package suite.
|
||||
|
||||
Build `@veritas-kanban/shared` first and type-check its known consumers when
|
||||
a shared contract changes. Use `pnpm test` at an explicit integration,
|
||||
critical-security, or release milestone, or when a maintainer explicitly
|
||||
selects the `ci:full` gate. Critical coverage, E2E, desktop packaging, and
|
||||
Docker contracts follow the same milestone boundary.
|
||||
|
||||
4. Commit using [conventional commits](#commit-conventions).
|
||||
|
||||
5. Push to your fork and open a pull request.
|
||||
|
||||
### Scope and Verification Budget
|
||||
|
||||
This cadence extends the deterministic CI selector delivered in
|
||||
[#1000](https://github.com/BradGroux/veritas-kanban/issues/1000).
|
||||
|
||||
- Keep one independently shippable behavior per issue and pull request.
|
||||
- Split separable UI work, secondary integrations, refactors, and additional
|
||||
hardening into linked follow-up issues before implementing them.
|
||||
- Re-scope when a second unexpected subsystem becomes necessary or the
|
||||
verification effort becomes larger than the changed behavior.
|
||||
- Do not rerun an unchanged passing check after documentation, comments, or
|
||||
formatting-only edits.
|
||||
- Treat `Select Test Scope` as the CI authority. Ordinary pull requests and
|
||||
`main` pushes select no workspace tests; manual focused diagnostics and full
|
||||
milestone selections are recorded in the job summary.
|
||||
- Do not wait for optional desktop artifacts, packaging previews, or release
|
||||
workflows unless the pull request changes that product boundary.
|
||||
- Test the behavior and meaningful failure modes. Do not use raw test count as
|
||||
a quality measure.
|
||||
- The dependency-free delivery cadence checker guards these rules in
|
||||
pre-commit and the early CI scope-control job without installing packages or
|
||||
running workspace tests.
|
||||
|
||||
### Branch Merge Protocol
|
||||
|
||||
**Critical:** When merging multiple feature branches, merge **one at a time**. Never batch-merge parallel branches.
|
||||
When merging multiple feature branches, merge one at a time so the next branch
|
||||
can rebase on the exact result.
|
||||
|
||||
**Process:**
|
||||
|
||||
1. Merge first branch to `main`
|
||||
2. Build all packages: `pnpm build`
|
||||
3. Run smoke tests (see [Testing Requirements](#testing-requirements))
|
||||
4. Only after smoke tests pass, merge the next branch
|
||||
5. Repeat for each branch
|
||||
2. Confirm the required GitHub checks for that pull request
|
||||
3. Rebase the next branch on the updated `main`
|
||||
4. Inspect conflict resolution and run changed-file static checks
|
||||
5. Merge the next branch
|
||||
|
||||
**Why:** Parallel branches often introduce integration issues that are hidden when batch-merging. Sequential merges with testing between each merge catch these immediately.
|
||||
The complete workspace suite, coverage, integration, E2E, desktop artifact,
|
||||
and Docker gates run once at the declared milestone. They are not repeated
|
||||
after every unrelated merge.
|
||||
|
||||
**Why:** Sequential merges keep conflicts attributable without paying the
|
||||
release-certification cost after every independent change. The declared
|
||||
milestone verifies the integrated candidate once.
|
||||
|
||||
### One Agent Per File Rule
|
||||
|
||||
|
|
@ -136,43 +180,34 @@ The `--model` flag is optional but recommended — it shows which AI model is be
|
|||
|
||||
See [SQUAD-CHAT-PROTOCOL.md](docs/SQUAD-CHAT-PROTOCOL.md) for full details.
|
||||
|
||||
### Pre-Commit Review Protocol (Mandatory)
|
||||
### Risk-Proportional Review
|
||||
|
||||
Before every commit, run these 4 reviews:
|
||||
Review the changed behavior once before committing. In that pass, cover
|
||||
correctness and any security, reliability, performance, accessibility, or
|
||||
architecture risks that actually apply to the change.
|
||||
|
||||
1. **Code Review** — Code quality, anti-patterns, architectural issues, file locking, path validation
|
||||
2. **Functionality Review** — All endpoints work, CRUD operations, settings save/load
|
||||
3. **Performance Review** — API response times, bundle size, React optimizations, memory leaks
|
||||
4. **Security Review** — Auth/authz, injection vectors, secrets exposure, CORS/CSP, rate limiting
|
||||
|
||||
All four must pass (10/10) before committing. If ANY review says unsafe:
|
||||
|
||||
1. Fix the issue
|
||||
2. Have the SAME reviewer who found it verify the fix
|
||||
3. Get human approval
|
||||
4. Then commit
|
||||
|
||||
**Never commit when a review says "unsafe." Never push without human approval.**
|
||||
|
||||
These reviews are mandatory, not optional. They catch runtime issues that static analysis and builds miss.
|
||||
Do not create separate review tasks for inapplicable categories or require
|
||||
numeric review scores. If the review finds an unsafe behavior, fix it and
|
||||
recheck the affected path before committing. Independent or cross-model review
|
||||
is optional unless a configured governance policy, issue owner, or release
|
||||
owner explicitly requires it.
|
||||
|
||||
### Pre-Merge Checklist
|
||||
|
||||
Before merging any branch, verify:
|
||||
Before merging, verify the checks selected for the changed product boundary:
|
||||
|
||||
- [ ] **Type exports:** All new types added to `shared/` are exported in `shared/src/types/index.ts`
|
||||
- [ ] **Type checks pass:** `pnpm typecheck` succeeds for all workspace packages (shared, server, web, CLI, MCP)
|
||||
- [ ] **Builds pass:** `pnpm build` succeeds for all packages (shared, server, web)
|
||||
- [ ] **No hardcoded values:** No hardcoded ports, URLs, or timeouts in application code
|
||||
- [ ] **CSP/CORS configs:** Security policies work in both `NODE_ENV=development` AND `NODE_ENV=production`
|
||||
- [ ] **Frontend hooks:** All HTTP calls use shared helpers (`apiFetch`) and all WebSocket/URL logic uses `window.location.host` (not hardcoded ports)
|
||||
- [ ] **Environment variables:** All configurable values use env vars with sensible defaults
|
||||
- [ ] **Selected CI tier:** Every required check started for the pull request is green.
|
||||
- [ ] **Implementation evidence:** The diff and applicable static checks support the changed behavior.
|
||||
- [ ] **Shared contracts, when changed:** New types are exported and known consumers type-check.
|
||||
- [ ] **Configuration, when changed:** Ports, URLs, timeouts, environment variables, CSP, and CORS behave in the affected modes.
|
||||
- [ ] **Frontend integration, when changed:** HTTP calls use shared helpers and location-sensitive behavior avoids hardcoded hosts.
|
||||
- [ ] **Milestone gate, when selected:** Complete build, typecheck, test, security, integration, E2E, or artifact checks required by `ci:full` or the release plan pass once.
|
||||
|
||||
### Environment Rules
|
||||
|
||||
**Never change these without team agreement:**
|
||||
|
||||
- **PORT in `.env`:** Default is 3000. Changing this breaks developer workflows and bookmarks.
|
||||
- **PORT in `.env`:** Server default is 3001. Changing this breaks CLI/API workflows and bookmarks.
|
||||
- **CORS_ORIGINS:** Must include the production serving origin (e.g., `http://localhost:3000` when Express serves the built frontend in production mode).
|
||||
- **CSP `connect-src`:** Must allow WebSocket connections in all modes (dev, production, test). Don't hide WebSocket support behind `isDev` checks.
|
||||
- **Configurable values:** Use environment variables with sensible defaults. No magic numbers in code.
|
||||
|
|
@ -181,17 +216,19 @@ Before merging any branch, verify:
|
|||
|
||||
### Testing Requirements
|
||||
|
||||
**"Builds clean" is necessary but NOT sufficient.**
|
||||
Run browser or API smoke tests only at an explicit integration or release
|
||||
milestone when the change affects that product boundary. Choose the smallest
|
||||
runtime check that proves the behavior:
|
||||
|
||||
Before declaring a branch ready to merge, verify **runtime behavior:**
|
||||
- **Server or API changes:** Exercise the changed endpoint and its meaningful auth or failure path. Add a health check only when startup or routing changed.
|
||||
- **Web changes:** Open the changed route and verify its primary interaction, keyboard flow, and failure state.
|
||||
- **Realtime changes:** Verify the changed event path with the minimum number of clients needed to prove propagation.
|
||||
- **Desktop changes:** Use the relevant desktop readiness or packaging smoke check.
|
||||
- **Documentation and static tooling:** No runtime smoke is required unless deterministic CI escalates the change.
|
||||
|
||||
1. **Health check:** `curl http://localhost:3000/api/health` returns 200
|
||||
2. **Auth flow:** Log in via the UI, verify token handling works
|
||||
3. **Task CRUD:** Create, update, move, and delete a task
|
||||
4. **WebSocket connection:** Verify real-time updates work (open two browser tabs, change task in one, see update in the other)
|
||||
5. **No stray processes:** Check for leftover Vite dev servers or conflicting processes before starting: `lsof -i :3000`
|
||||
|
||||
**Static code reviews (AI or human) cannot catch runtime issues.** You must test in a running browser.
|
||||
Static review does not replace runtime evidence when runtime behavior changed,
|
||||
but unrelated browser, CRUD, WebSocket, or packaging checks add no useful
|
||||
confidence to a focused change.
|
||||
|
||||
### Common Integration Failures
|
||||
|
||||
|
|
@ -247,7 +284,11 @@ docs: update README with deployment instructions
|
|||
2. **Branch naming:** Use descriptive names like `feat/task-filters`, `fix/login-redirect`, `docs/api-reference`.
|
||||
3. **Open a PR** against `main`.
|
||||
4. **Fill out the PR template** — describe changes, link related issues, include screenshots for UI changes.
|
||||
5. **Ensure CI passes** — all checks must be green.
|
||||
5. **Ensure the selected PR CI tier passes** — all checks started for the pull
|
||||
request must be green. The scope selector records affected workspaces but
|
||||
defers their tests on ordinary pull requests. Use `ci:full` for release
|
||||
candidates, critical integration/security boundaries, or other changes that
|
||||
require an explicit complete-suite gate.
|
||||
6. **Request review** — a maintainer will review and may request changes.
|
||||
7. **Address feedback** — push additional commits as needed.
|
||||
8. **Merge** — once approved, a maintainer will merge.
|
||||
|
|
@ -256,6 +297,7 @@ docs: update README with deployment instructions
|
|||
|
||||
- **Language:** TypeScript (strict mode)
|
||||
- **Linting:** ESLint — `pnpm lint`
|
||||
- **Lint budget:** `pnpm lint:budget` enforces the current warning ceiling so lint debt cannot grow.
|
||||
- **Formatting:** Prettier — `pnpm format`
|
||||
- **Editor:** VS Code recommended with ESLint + Prettier extensions
|
||||
|
||||
|
|
@ -269,15 +311,76 @@ Follow the existing conventions in `.eslintrc.*`, `.prettierrc`, and `tsconfig.j
|
|||
pnpm test
|
||||
```
|
||||
|
||||
This is the canonical unit gate. It builds the shared package, then runs the
|
||||
server, web, CLI, and MCP suites sequentially with at most four Vitest workers
|
||||
per project. The final line reports PASS, FAIL, or NOT RUN for every workspace.
|
||||
|
||||
- **End-to-end tests** use [Playwright](https://playwright.dev/):
|
||||
|
||||
```bash
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
Playwright does not retry failures. Screenshots and traces from the first
|
||||
failure are retained in `test-results/` and uploaded by Scheduled QA.
|
||||
|
||||
- **Load smoke tests** use [k6](https://k6.io/):
|
||||
|
||||
```bash
|
||||
pnpm test:load:smoke
|
||||
```
|
||||
|
||||
- **Release readiness** checks workspace versions, changelog, README badge, build outputs, and optional GitHub tag/release state:
|
||||
|
||||
```bash
|
||||
pnpm validate:release
|
||||
pnpm validate:release -- --github
|
||||
```
|
||||
|
||||
- Write tests for new features and bug fixes.
|
||||
- Ensure existing tests pass before submitting.
|
||||
|
||||
### CI tiers
|
||||
|
||||
| Trigger | Stable checks | Scope |
|
||||
| ---------------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| Documentation-only pull request or merge | Static gates; test jobs record skip decisions | No workspace tests |
|
||||
| Ordinary code pull request or merge to `main` | `Lint & Type Check`, `Build`, `Security Audit`, scope recording | No workspace tests or coverage; affected packages remain visible |
|
||||
| Pull request with `ci:full` | Default checks plus every milestone test and artifact gate | Complete unit, coverage, desktop, Docker, and applicable integration gates |
|
||||
| Nightly 08:00 UTC or manual `CI` dispatch with `test_scope=full` | Static gates plus complete workspace and coverage gates | Authoritative recurring or operator-selected milestone |
|
||||
| Manual `CI` dispatch with `test_scope=focused` and optional `base_sha` | Static gates plus `Changed Tests` | Explicit diagnostic slice for affected workspaces; no coverage ratchet |
|
||||
| Manual `Desktop Artifacts` or `Docker Image Contract` dispatch | Selected artifact or container contract | Explicit operator milestone outside a pull request |
|
||||
|
||||
`Select Test Scope` is the decision record for each run. Its summary names the
|
||||
event, exact base/head range, changed-path count, selected tier, affected
|
||||
workspaces, and why `Changed Tests` or `Workspace Unit Tests` ran or skipped.
|
||||
Shared contracts, package manifests, lockfiles, storage implementations,
|
||||
desktop source, and known-workspace deletions are recorded as affected
|
||||
workspaces without launching tests. Build and typecheck remain
|
||||
whole-repository gates on every ordinary code pull request. The full workspace
|
||||
suite and release-grade artifact gates run at scheduled, explicit `ci:full`,
|
||||
critical integration/security, and release milestones.
|
||||
|
||||
Run the selector contract locally with:
|
||||
|
||||
```bash
|
||||
pnpm test:ci-scope
|
||||
```
|
||||
|
||||
Release validation remains the final authority: clean-clone build, full unit
|
||||
and integration suites, applicable E2E, and signed artifact verification.
|
||||
|
||||
The operational target for the default pull-request tier is under 10 minutes,
|
||||
with no workspace tests, coverage, or desktop/container packaging. This is a
|
||||
target rather than an SLA; dependency installation and hosted-runner
|
||||
availability still vary. Behavior changes should include coverage that the
|
||||
next declared milestone can exercise.
|
||||
|
||||
Optional `Desktop Artifacts`, packaging previews, and release workflows are not
|
||||
merge blockers outside their path boundary. If one starts without providing
|
||||
evidence required by the pull request, continue based on required checks;
|
||||
maintainers may cancel the redundant run.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open a [GitHub Discussion](https://github.com/BradGroux/veritas-kanban/discussions) or reach out to the maintainers.
|
||||
|
|
|
|||
83
Dockerfile
83
Dockerfile
|
|
@ -5,18 +5,19 @@
|
|||
# 1. deps — Install all workspace dependencies (shared cache layer)
|
||||
# 2. build-shared — Build the shared package
|
||||
# 3. build-web — Build React frontend with Vite
|
||||
# 4. build-server — Compile Express server TypeScript
|
||||
# 5. production — Minimal runtime image
|
||||
# 4. build-server — Compile the Express server TypeScript
|
||||
# 5. production-deps — Install the server-only runtime closure
|
||||
# 6. production — Minimal runtime image
|
||||
#
|
||||
# Target image size: < 200MB
|
||||
# Target image size: < 200,000,000 bytes on arm64; < 600,000,000 bytes on amd64
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Install dependencies (shared across build stages)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS deps
|
||||
FROM node:22-alpine3.24 AS deps
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -25,8 +26,12 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
|||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
COPY web/package.json ./web/
|
||||
COPY cli/package.json ./cli/
|
||||
COPY mcp/package.json ./mcp/
|
||||
COPY scripts/ ./scripts/
|
||||
|
||||
# Install all dependencies (dev + prod) for building
|
||||
ENV HUSKY=0
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -59,43 +64,54 @@ COPY server/ ./server/
|
|||
RUN pnpm --filter @veritas-kanban/server build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 5: Production runtime
|
||||
# Stage 5: Install the server-only production dependency closure
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
# Security: run as non-root
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S veritas -u 1001 -G nodejs
|
||||
FROM node:22-alpine3.24 AS production-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace config for pnpm (include real web/package.json for lockfile integrity)
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
COPY web/package.json ./web/
|
||||
COPY scripts/ ./scripts/
|
||||
RUN corepack enable && \
|
||||
corepack prepare pnpm@11.1.1 --activate && \
|
||||
HUSKY=0 pnpm install --frozen-lockfile --prod --filter @veritas-kanban/server... && \
|
||||
rm -rf /root/.cache/node/corepack /root/.local/share/pnpm/store /root/.local/share/pnpm/.tools
|
||||
|
||||
# Install production-only dependencies
|
||||
# --ignore-scripts: skip husky prepare hook (not needed in container)
|
||||
# Note: web deps get installed to satisfy the lockfile, but we remove them
|
||||
# since the frontend is pre-built as static assets
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts && \
|
||||
rm -rf web/node_modules && \
|
||||
pnpm store prune
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 6: Production runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
# The matching Alpine base keeps Node's musl ABI while excluding npm,
|
||||
# Corepack, headers, and package-manager tooling from the runtime image.
|
||||
FROM alpine:3.24 AS production
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=build-shared /app/shared/dist ./shared/dist
|
||||
COPY --from=build-server /app/server/dist ./server/dist
|
||||
COPY --from=build-web /app/web/dist ./web/dist
|
||||
RUN apk add --no-cache ca-certificates libstdc++ && \
|
||||
addgroup -g 1001 -S nodejs && \
|
||||
adduser -S veritas -u 1001 -G nodejs
|
||||
|
||||
# Create data directories for persistent storage and runtime config
|
||||
# Note: services resolve .veritas-kanban from both cwd/.. and cwd directly,
|
||||
# so we create it at /app/ level AND ensure server/ is writable for services
|
||||
# that use process.cwd()/.veritas-kanban when WORKDIR is /app/server
|
||||
RUN mkdir -p /app/data /app/.veritas-kanban /app/tasks && \
|
||||
chown -R veritas:nodejs /app/data /app/.veritas-kanban /app/tasks /app/server
|
||||
COPY --from=production-deps /usr/local/bin/node /usr/local/bin/node
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only the resolved server runtime closure. The platform-specific Codex
|
||||
# binary remains available, while npm, pnpm, workspace manifests, and build
|
||||
# tooling never enter the production stage.
|
||||
COPY --from=production-deps --chown=veritas:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=production-deps --chown=veritas:nodejs /app/server/node_modules ./server/node_modules
|
||||
COPY --from=production-deps --chown=veritas:nodejs /app/shared/package.json ./shared/package.json
|
||||
COPY --from=production-deps --chown=veritas:nodejs /app/server/package.json ./server/package.json
|
||||
|
||||
# Copy only built runtime artifacts. CLI, MCP, frontend dependencies, source,
|
||||
# and build tooling never enter the production stage.
|
||||
COPY --from=build-shared --chown=veritas:nodejs /app/shared/dist ./shared/dist
|
||||
COPY --from=build-server --chown=veritas:nodejs /app/server/dist ./server/dist
|
||||
COPY --from=build-web --chown=veritas:nodejs /app/web/dist ./web/dist
|
||||
|
||||
# Create the single volume-backed storage root. Runtime state is stored at
|
||||
# /app/data/.veritas-kanban and task data at /app/data/tasks.
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R veritas:nodejs /app/data /app/server
|
||||
|
||||
# Switch to non-root user
|
||||
USER veritas
|
||||
|
|
@ -111,8 +127,7 @@ EXPOSE 3001
|
|||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
|
||||
|
||||
# Set working directory to server/ so path.resolve(cwd, '..') resolves to /app
|
||||
# (Services use process.cwd()/.. to find .veritas-kanban and tasks directories)
|
||||
# The runtime path contract is independent of cwd when DATA_DIR is set.
|
||||
WORKDIR /app/server
|
||||
|
||||
# Start server
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This document outlines the manual integration steps required to complete feature
|
|||
**Action:** Export the new prompt registry types
|
||||
|
||||
Add these lines to the exports:
|
||||
|
||||
```typescript
|
||||
// Prompt Registry Types
|
||||
export type {
|
||||
|
|
@ -41,9 +42,10 @@ app.use('/api/prompt-registry', promptRegistryRouter);
|
|||
**Location:** Find where other routes like `templates`, `tasks`, `chat` are registered. Add the prompt registry route in the same pattern.
|
||||
|
||||
**Expected pattern:**
|
||||
|
||||
```typescript
|
||||
app.use('/api/templates', templateRouter);
|
||||
app.use('/api/prompt-registry', promptRegistryRouter); // <-- Add this line
|
||||
app.use('/api/prompt-registry', promptRegistryRouter); // <-- Add this line
|
||||
app.use('/api/tasks', taskRouter);
|
||||
```
|
||||
|
||||
|
|
@ -151,7 +153,7 @@ These files are complete and require no further changes:
|
|||
|
||||
## Notes
|
||||
|
||||
- All files follow existing code patterns (zod validation, gray-matter for storage, React Query hooks)
|
||||
- All files follow existing code patterns (zod validation, YAML frontmatter storage, React Query hooks)
|
||||
- No external dependencies added beyond what's already in use
|
||||
- Storage uses file-based approach (consistent with template-service)
|
||||
- Endpoints follow REST conventions
|
||||
|
|
@ -160,6 +162,7 @@ These files are complete and require no further changes:
|
|||
## Questions?
|
||||
|
||||
Refer to reference implementations:
|
||||
|
||||
- **Template pattern:** `server/src/services/template-service.ts`
|
||||
- **Route pattern:** `server/src/routes/templates.ts`
|
||||
- **API client pattern:** `web/src/lib/api/entities.ts`
|
||||
|
|
|
|||
451
README.md
451
README.md
|
|
@ -4,23 +4,23 @@
|
|||
|
||||
_Veritas in actis — Truth in action._
|
||||
|
||||
**Local-first task management and AI agent orchestration platform.**
|
||||
**Local-first task management board with optional AI agent orchestration.**
|
||||
|
||||
Built for developers who want a visual Kanban board that works with autonomous coding agents.
|
||||
Start with a visual Kanban board. Add CLI, MCP, OpenClaw, Squad Chat webhooks, workflows, or governance only when you need that layer.
|
||||
|
||||
[](https://github.com/BradGroux/veritas-kanban/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](CONTRIBUTING.md)
|
||||
|
||||

|
||||

|
||||
|
||||
> 🎬 [Watch the full demo video (MP4)](assets/demo-overview.mp4)
|
||||
> 🎬 [Watch the full demo video](https://bradgroux.github.io/veritas-kanban/demo/)
|
||||
|
||||
⭐ **If you find this useful, star the repo — it helps others discover it!**
|
||||
|
||||
[Quickstart](#-quickstart) · [Features](#-feature-highlights) · [Why VK](#-why-veritas-kanban) · [All Features](docs/FEATURES.md) · [Docs](docs/) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [API](#-api-versioning) · [Agent Integration](#-agent-integration) · [MCP Server](#-mcp-server) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
|
||||
[Quickstart](#-quickstart) · [Features](#-feature-highlights) · [Why VK](#-why-veritas-kanban) · [All Features](docs/FEATURES.md) · [Docs](docs/) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [API](#-api-versioning) · [Agent Setup](#-optional-agent-integration) · [MCP Server](#-mcp-server) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -32,13 +32,29 @@ Created by **Brad Groux** — CEO of [Digital Meld](https://digitalmeld.io), and
|
|||
|
||||
## ⚡ Quickstart
|
||||
|
||||
Want to take the easy way out? Ask your agent (like [OpenClaw](https://github.com/openclaw/openclaw)):
|
||||
Start with the local board. OpenClaw, MCP, Squad Chat webhooks, notifications, workflows, and governance gates are optional layers you can turn on later. See [Setup Paths](docs/SETUP-PATHS.md) for the board-only, CLI, MCP, OpenClaw, and self-hosted paths.
|
||||
|
||||
Want to take the easy way out? Ask your agent:
|
||||
|
||||
```
|
||||
Clone and set up veritas-kanban locally. Install dependencies with pnpm, copy the .env.example, and start the dev server. Verify it's running at localhost:3000.
|
||||
Clone and set up veritas-kanban locally using the board-only setup path first. Install dependencies with pnpm, copy server/.env.example to server/.env, and start the dev server. Verify the UI at localhost:3000 and the API health endpoint at localhost:3001/api/health. Do not configure OpenClaw, MCP, Squad Chat webhooks, workflows, or notifications unless I explicitly ask for that layer.
|
||||
```
|
||||
|
||||
Want to do it yourself? Get up and running in under 5 minutes:
|
||||
Want to do it yourself? Choose the packaged Mac app or a local source checkout:
|
||||
|
||||
For the packaged Mac desktop app:
|
||||
|
||||
```bash
|
||||
brew tap BradGroux/tap
|
||||
brew install --cask veritas-kanban
|
||||
```
|
||||
|
||||
Existing desktop users should follow the
|
||||
[routine Mac upgrade](docs/V6-UPGRADE-INSTALL-ADMIN-GUIDE.md#routine-mac-desktop-upgrade)
|
||||
path so backup, heartbeat pause, app replacement, launch, and exact-version
|
||||
server readiness happen in the right order.
|
||||
|
||||
For local source development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/BradGroux/veritas-kanban.git
|
||||
|
|
@ -48,7 +64,22 @@ cp server/.env.example server/.env # Edit to change VERITAS_ADMIN_KEY
|
|||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) — that's it. The board auto-seeds with example tasks on first run so you can explore right away.
|
||||
Open [http://localhost:3000](http://localhost:3000) for source runs, or install
|
||||
the signed/notarized Mac app with
|
||||
`brew tap BradGroux/tap && brew install --cask veritas-kanban`. The board
|
||||
auto-seeds with example tasks on first run so you can explore right away.
|
||||
|
||||
A working board means the UI loads and `http://localhost:3001/api/health` returns healthy. Agent-ready and external wake/delivery-ready are separate setup levels; use [Setup Paths](docs/SETUP-PATHS.md#readiness-levels) before adding those layers.
|
||||
|
||||
**Do not configure these on day one unless you already know you need them:**
|
||||
|
||||
- OpenClaw gateway or browser relay
|
||||
- MCP write access
|
||||
- Squad Chat webhook or external wake behavior
|
||||
- Notification delivery channels
|
||||
- Workflow gates or governance policies
|
||||
|
||||
When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the next layer and run the read/write smoke checks before handing the board to an assistant.
|
||||
|
||||
> **Want a clean slate?** Delete the example tasks: `rm tasks/active/task_example_*.md` and refresh.
|
||||
> **Want to re-seed?** Run `pnpm seed` to restore the example tasks (only works when the board is empty).
|
||||
|
|
@ -59,17 +90,43 @@ Open [http://localhost:3000](http://localhost:3000) — that's it. The board aut
|
|||
|
||||
## 📚 Documentation Map
|
||||
|
||||
- [MCP Server Guide](docs/mcp/README.md) — 33+ tools, architecture, quickstart, tool catalog, security model, troubleshooting.
|
||||
- [API Reference](docs/API-REFERENCE.md) — Auth, endpoints, request/response examples, WebSocket, common workflows.
|
||||
- [Self-Hosting Guide](docs/guides/SELF_HOST.md) — production deployment, reverse proxy, auth hardening, Docker, and backups.
|
||||
- [Setup Paths](docs/SETUP-PATHS.md) — start here for board-only, CLI, MCP, OpenClaw, and self-hosted paths without mixing optional layers into first-run setup.
|
||||
- [Getting Started Guide](docs/GETTING-STARTED.md) — zero ➝ agent-ready in 5 minutes, plus sanity checks and prompt registry tips.
|
||||
- [MCP Server Guide](docs/mcp/README.md) — optional MCP setup, 42 tools, architecture, tool catalog, security model, and read/write smoke checks.
|
||||
- [Agent Guide and `AGENTS.md` Template](docs/AGENTS-TEMPLATE.md) — shared managed-run protocol, external self-reporting template, and unmanaged MCP setup.
|
||||
- [Agent Providers](docs/AGENT-PROVIDERS.md) — evidence-backed Buzz, Grok Build, Codex, Claude Code, Copilot CLI, Hermes, OpenClaw, and optional model profiles.
|
||||
- [v6 Agent Runtime Control Plane](docs/architecture/V6-AGENT-RUNTIME-CONTROL-PLANE.md) — authority, adapter, lifecycle, approval, tool, credential, Buzz, and certification boundaries.
|
||||
- [Phase Capability Profiles](docs/architecture/PHASE-CAPABILITY-PROFILES.md) — versioned execution-phase authority contracts, deterministic intersections, exact-path plan artifacts, and current delivery boundaries.
|
||||
- [Phase Transition Journal](docs/architecture/PHASE-TRANSITION-JOURNAL.md) — durable compare-and-set transitions, approval and override controls, restart recovery, REST, and CLI operations.
|
||||
- [Knowledge Collections v1](docs/architecture/KNOWLEDGE-COLLECTIONS-V1.md) — immutable sources, cited pages, stable identity, bidirectional links, and reversible reviewed ingestion with file/SQLite parity.
|
||||
- [OpenAI Codex Integration Roadmap](docs/CODEX-INTEGRATION.md) — optional local execution, SDK sessions, cloud delegation, MCP setup, workflows, telemetry, and release QA.
|
||||
- [Veritas Cutover Operating Guide](docs/VERITAS-CUTOVER.md) — authority model, HermesAgent roster, QA evidence gate, and GitHub-backed task templates.
|
||||
- [Codex Integration SOP](docs/SOP-codex-integration.md) & [Codex Workflow Examples](docs/EXAMPLES-codex-workflows.md) — operational playbooks for using Codex as a first-class Veritas agent.
|
||||
- [API Reference](docs/API-REFERENCE.md) — Auth, endpoints, request/response examples, WebSocket, common workflows.
|
||||
- [Identity and RBAC Model](docs/IDENTITY-RBAC.md) — users, workspaces, memberships, roles, agent tokens, permission matrix, migration, and UX flows.
|
||||
- [v6 GA Checklist](docs/V6-GA-CHECKLIST.md) — release gates for harness certification, migration, runtime, desktop, and distribution evidence.
|
||||
- [v6 Visual Tour](docs/V6-VISUAL-TOUR.md) — release-safe views of provider support, Buzz setup, approvals, and run evidence.
|
||||
- [v6 Upgrade, Install, Remote, And Admin Guide](docs/V6-UPGRADE-INSTALL-ADMIN-GUIDE.md) — fresh install, v5-to-v6 upgrade, harness setup, desktop, backup, and diagnostics paths.
|
||||
- [v6 Compatibility And Release Policy](docs/V6-COMPATIBILITY-AND-RELEASE-POLICY.md) — provider support tiers, tested builds, platform combinations, update channels, and rollback limits.
|
||||
- [v6 Release Notes](docs/V6-RELEASE-NOTES.md) — user-facing highlights, stabilization fixes, install/upgrade steps, behavior changes, and known limits.
|
||||
- [Desktop Architecture ADR](docs/architecture/ADR-0001-v5-desktop-architecture.md) — shell decision, native/server boundaries, connection modes, lifecycle, packaging, and security model.
|
||||
- [Post-GA Desktop Agent Workbench Spec](docs/DESKTOP-AGENT-WORKBENCH.md) — desktop workbench UX, run controls, approvals, evidence, native affordances, and safety coverage.
|
||||
- [Post-GA Native Mobile Offline ADR](docs/architecture/ADR-0003-post-ga-native-mobile-offline.md) — native mobile authority model, offline queue semantics, conflict handling, and security review.
|
||||
- [Post-GA Cloud Sync And Hosted SaaS ADR](docs/architecture/ADR-0004-post-ga-cloud-sync-hosted-saas.md) — optional hosted model, tenant isolation, lifecycle, support, cost, and migration boundaries.
|
||||
- [Self-Hosting Guide](docs/guides/SELF_HOST.md) — production deployment, reverse proxy, auth hardening, Docker, and backups.
|
||||
- [Agent Task Workflow SOP](docs/SOP-agent-task-workflow.md) — lifecycle, API/CLI snippets, prompts.
|
||||
- [Squad Chat Protocol](docs/SQUAD-CHAT-PROTOCOL.md) — agent messaging, system events (spawned/completed/failed), model attribution, and helper scripts.
|
||||
- [Buzz Integration](docs/BUZZ-INTEGRATION.md) — signed Squad Chat bridging,
|
||||
explicit persona/team import, and a separate disabled-by-default
|
||||
`buzz-agent` profile under the generic ACP provider.
|
||||
- [Agent Providers](docs/AGENT-PROVIDERS.md#grok-build-acp) — exact-version
|
||||
Grok Build, GitHub Copilot CLI, Buzz Agent, Claude Code, and Codex runtime
|
||||
setup, safety policy, and known limitations.
|
||||
- [Sprint Planning SOP](docs/SOP-sprint-planning.md) — epic → sprint → task breakdown.
|
||||
- [Multi-Agent Orchestration](docs/SOP-multi-agent-orchestration.md) — PM + worker handoffs.
|
||||
- [Cross-Model Code Review](docs/SOP-cross-model-code-review.md) — enforce Claude ↔ GPT reviews.
|
||||
- [Optional Cross-Model Code Review](docs/SOP-cross-model-code-review.md) — add an independent model only when the issue or release owner requires it.
|
||||
- [Agent Governance SOPs](docs/) — [Policy engine](docs/SOP-agent-policy-engine.md), [drift detection](docs/SOP-behavioral-drift-detection.md), [decision audit](docs/SOP-decision-audit-trail.md), [output evaluation](docs/SOP-output-evaluation.md), [user feedback](docs/SOP-user-feedback.md).
|
||||
- [Operational SOPs](docs/) — [Broadcasts](docs/SOP-broadcasts.md), [delegation](docs/SOP-delegation.md), [deliverables](docs/SOP-deliverables.md), [prompt registry](docs/SOP-prompt-registry.md), [squad chat](docs/SOP-squad-chat.md), [system health](docs/SOP-system-health-monitoring.md).
|
||||
- [Operational SOPs](docs/) — [Broadcasts](docs/SOP-broadcasts.md), [delegation](docs/SOP-delegation.md), [deliverables](docs/SOP-deliverables.md), [work products](docs/features/work-products.md), [prompt registry](docs/SOP-prompt-registry.md), [squad chat](docs/SOP-squad-chat.md), [system health](docs/SOP-system-health-monitoring.md).
|
||||
- [Best Practices](docs/BEST-PRACTICES.md) & [Tips + Tricks](docs/TIPS-AND-TRICKS.md) — patterns, shortcuts, integrations.
|
||||
- [Real-World Examples](docs/EXAMPLES-agent-workflows.md) — copy/pasteable agent recipes.
|
||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) — deeper diagnostics when things wobble.
|
||||
|
|
@ -85,7 +142,7 @@ Open [http://localhost:3000](http://localhost:3000) — that's it. The board aut
|
|||
|
||||
2. **Never trigger agents from uncontrolled inputs.** Don't let inbound emails, webhooks from third parties, or public form submissions automatically spawn agent work. An attacker who can craft an input can control your agent.
|
||||
|
||||
3. **Principle of least privilege.** Give agents the minimum permissions they need. Use the `agent` role (not `admin`) for API keys. Restrict file system access. Don't run agents as root.
|
||||
3. **Principle of least privilege.** Give agents the minimum permissions they need. Use the `agent` role (not `admin`) for API keys. Restrict file system access with sandbox policy presets, enforce run budgets before long-running work, and don't run agents as root.
|
||||
|
||||
4. **Review before merge.** Agents can write code — that doesn't mean the code is correct or safe. Always review agent-generated code before merging to production branches. Use the built-in code review workflow.
|
||||
|
||||
|
|
@ -95,29 +152,47 @@ Open [http://localhost:3000](http://localhost:3000) — that's it. The board aut
|
|||
|
||||
7. **Rotate credentials regularly.** If an agent has access to API keys, tokens, or secrets, rotate them on a schedule. Don't embed real credentials in task descriptions or prompts.
|
||||
|
||||
8. **Isolate environments.** Run agents in containers, VMs, or sandboxed environments when possible. Keep agent workspaces separate from sensitive data.
|
||||
8. **Isolate environments.** Run agents in containers, VMs, or sandboxed environments when possible. Keep agent workspaces separate from sensitive data, use deny-by-default network presets for untrusted work, and broker credentials instead of exposing broad environment variables.
|
||||
|
||||
**The bottom line:** Agentic AI is transformational, but it amplifies both your capabilities and your mistakes. Plan accordingly, start small, and add autonomy gradually as you build confidence in your guardrails.
|
||||
**The bottom line:** Agents amplify both useful work and mistakes. Start locally, keep permissions narrow, and add autonomy only after the smaller setup is understood and verified.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Highlights
|
||||
|
||||
### 🛡️ Agent Governance (New in v4.0)
|
||||
### 🛡️ Agent Governance
|
||||
|
||||
**Policy Engine** — Define what agents can and can't do. Configurable tool/action policies with `allow`, `deny`, and `require-approval` guard rules. Every policy decision is logged. **Decision Audit Trail** — Log agent decisions with confidence scores, supporting evidence, and stated assumptions. Record outcomes afterward to see whether assumptions held. **Behavioral Drift Detection** — Set metric baselines and thresholds; get alerted when an agent's behavior deviates. **User Feedback Loop** — Collect feedback on agent outputs with sentiment tagging and category analytics. **Output Evaluation** — Score agent outputs against weighted criteria profiles (regex, keyword, numeric range, custom expressions).
|
||||
**Policy Engine** — Define what agents can and can't do. Configurable tool/action policies with `allow`, `deny`, and `require-approval` guard rules. Every policy decision is logged. **Sandbox Policy Presets** — Assign reusable filesystem, network, environment, and credential rules to agents, workflow agents, or one-off runs; unsupported required controls fail closed before launch with redacted audit traces. **Decision Audit Trail** — Log agent decisions with confidence scores, supporting evidence, and stated assumptions. Record outcomes afterward to see whether assumptions held. **Behavioral Drift Detection** — Set metric baselines and thresholds; get alerted when an agent's behavior deviates. **User Feedback Loop** — Collect feedback on agent outputs with sentiment tagging and category analytics. **Output Evaluation** — Score agent outputs against weighted bounded criteria profiles (regex, keyword, numeric range, occurrence ratio).
|
||||
|
||||
### 🤖 Agent Orchestration
|
||||
|
||||
Spawn autonomous coding agents on tasks. Track them in real-time with the multi-agent dashboard — status indicators, expandable agent cards, model attribution. Squad Chat gives agents a shared communication channel with system lifecycle events (spawned, completed, failed). Assign multiple agents per task, set permission levels (Intern/Specialist/Lead), and let them coordinate.
|
||||
Spawn autonomous coding agents on tasks when you choose to connect an agent runner. Track them in real-time with the multi-agent dashboard — status indicators, expandable agent cards, model attribution. Team roster manifests and workspace capability discovery route work to the right agent or trusted workspace before a run starts. Shared live run sessions let workspace members observe an active task run, co-drive with attributed messages, or fork a clean follow-up task without taking over the parent run. Squad Chat gives agents a shared local communication channel with system lifecycle events (spawned, completed, failed). Assign multiple agents per task, set permission levels (Intern/Specialist/Lead), and let them coordinate.
|
||||
|
||||

|
||||

|
||||
|
||||
### 📊 Customizable Dashboard (New in v4.0)
|
||||

|
||||
|
||||
Desktop Board Chat and Squad Chat open in a bounded right-side Workbench dock by
|
||||
default. Switch to Bottom when vertical space is preferable; both orientations
|
||||
keep the board, header, close control, and keyboard recovery paths reachable.
|
||||
|
||||

|
||||
|
||||
### 🧭 Provider And Cutover Operations
|
||||
|
||||
The cutover guide documents a GitHub-backed operating model for Codex and HermesAgent work. Veritas remains the source of truth, HermesAgent/Hermes Gateway can provide the execution control plane, and GitHub Issues, pull requests, reviews, and CI remain the durable implementation record. Copy/paste task templates cover product specs, research intake, and approval-gated client workflows.
|
||||
|
||||
### 🧠 OpenAI Codex Integration
|
||||
|
||||
Codex can run as the default first-class Veritas agent through local `codex exec`, SDK-backed sessions, GitHub-native `@codex` delegation, workflow-engine steps, review actions, Settings health checks, and MCP access to the board. Ollama Local, Ollama Cloud, and LM Studio Local profiles are optional routing targets for users who want local/server-hosted models or explicit cloud model execution. The docs include a roadmap, SOP, workflow examples, and an AGENTS template so provider-backed work can be started, tracked, reviewed, and released through the same Veritas lifecycle as other agents.
|
||||
|
||||

|
||||
|
||||
### 📊 Customizable Dashboard
|
||||
|
||||
**Draggable & Resizable Widget Grid** — Rearrange and resize dashboard widgets via drag-and-drop. Layouts persist across sessions. Add widgets from the library or remove ones you don't need. **Global System Health Bar** — Persistent header status bar with five health levels (stable → alert) across three signal categories: system resources, agent availability, and operation success rate.
|
||||
|
||||
### 📝 Prompt Template Registry (New in v4.0)
|
||||
### 📝 Prompt Template Registry
|
||||
|
||||
Version-controlled prompt templates with variable extraction, full version history with rollback, usage tracking, and preview rendering with sample variable injection. Manage your prompt library the same way you manage code.
|
||||
|
||||
|
|
@ -129,19 +204,23 @@ Define multi-step agent pipelines as version-controlled YAML. Sequential steps,
|
|||
|
||||
Not just cards on a board. Tasks have dependency graphs with cycle detection, crash-recovery checkpointing (auto-sanitizes secrets), observational memory with importance scoring, time tracking, and full activity logs. Enforcement gates (review gates, delegation enforcement, auto-telemetry) add production guardrails — all optional, all toggleable.
|
||||
|
||||

|
||||

|
||||
|
||||
### 🔀 Git-Native Development
|
||||
|
||||
Isolated worktrees per task — no branch switching, no conflicts. Built-in code review with unified diff viewer and inline comments. Approval workflows (approve, request changes, reject). Visual merge conflict resolution. Create GitHub PRs directly from the task detail panel. Bidirectional GitHub Issues sync with label mapping.
|
||||
|
||||
### 📁 Zero Infrastructure
|
||||
### 📁 Local-First Storage
|
||||
|
||||
Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no Redis, and no Docker required for local use. Clone, `pnpm install`, `pnpm dev` — done. Everything is `grep`-friendly, version-controllable, and human-readable. Back up your entire board with `git push`.
|
||||
File storage remains the zero-infrastructure default: tasks are Markdown,
|
||||
settings are JSON, and workflows are YAML. SQLite is available for governed
|
||||
multi-user and higher-integrity deployments; Redis and Docker are not required
|
||||
for local use. Clone, `pnpm install`, and `pnpm dev` to start. Back up the
|
||||
complete configured storage root, not only the Git-tracked board files.
|
||||
|
||||
### 🔌 Three Integration Surfaces
|
||||
### 🔌 Optional Integration Surfaces
|
||||
|
||||
- **MCP Server** — 33+ tools across 7 categories via Model Context Protocol (v4.0 adds project management and comment CRUD)
|
||||
- **MCP Server** — 42 tools across 9 categories via Model Context Protocol
|
||||
- **CLI** — `vk begin <id>` / `vk done <id> "summary"` replaces 6 API calls with 2 commands
|
||||
- **REST API** — Full lifecycle management. If it can make HTTP calls, it can drive the board.
|
||||
|
||||
|
|
@ -169,11 +248,34 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
- **Agent orchestration** — Spawn autonomous coding agents on tasks
|
||||
- **Custom agents** — Add your own agents with any name and command; not limited to built-in types
|
||||
- **Platform-agnostic API** — REST endpoints work with any agentic platform
|
||||
- **Built-in OpenClaw support** — Native integration with [OpenClaw](https://github.com/openclaw/openclaw)
|
||||
- **HermesAgent support** — documents HermesAgent/Hermes Gateway as the active control plane, with Veritas as the GitHub-backed source of truth
|
||||
- **OpenAI Codex support** — Local CLI runs, SDK-backed sessions, Codex Cloud delegation, workflow steps, review actions, health checks, MCP setup, and default routing for fresh installs
|
||||
- **Local LLM provider profiles** — Optional Ollama Local, Ollama Cloud, and LM Studio Local profiles with health metadata and routing support
|
||||
- **Team roster routing** — Workspace coordinator/member manifests route tasks by capabilities, reviewers, fallbacks, and escalation posture
|
||||
- **Workspace capability discovery** — Trusted workspace capability catalogs let Veritas package delegated work intake before handing work across workspace boundaries
|
||||
- **Agent profile packages** — Portable YAML/JSON packages that bundle role, runtime, prompt, tools, permissions, sandbox, budget, workflow, and health metadata for reusable launches
|
||||
- **Phase capability contract** — Built-in explore, plan, implement, verify, and
|
||||
publish profiles compile parent, phase, agent, sandbox, tool, and launch
|
||||
authority without widening it. The current slice defines the shared contract
|
||||
and compiler; runtime transition and enforcement work remains explicitly
|
||||
tracked.
|
||||
- **Provider-owned task envelopes** — OpenClaw, Codex CLI, Codex SDK, and Hermes render the same immutable task contract through adapter-owned transports with explicit commit policy and completion posture
|
||||
- **Decision review sessions** — Multi-participant decision reviews with independent responses, critique rounds, final synthesis packets, work-product attachment, and decision audit links
|
||||
- **Shared live run sessions** — Create workspace-scoped view, co-drive, or fork links for active task runs; viewers receive live output and events, editors send attributed messages and mobile-safe approval responses, and forks create linked tasks without mutating the parent run
|
||||
- **Sandbox policy presets** — Built-in and custom presets for filesystem scope, network egress, environment passthrough, and credential brokering, with Settings dry-runs before agent launch
|
||||
- **Agent budget enforcement** — Workspace, agent, workflow, workflow-agent, and per-run caps for tokens, cost, tool calls, runtime, retries, and fan-out with auditable warn, approval, downgrade, pause, or cancel decisions
|
||||
- **Optional OpenClaw support** — Native integration with [OpenClaw](https://github.com/openclaw/openclaw) when you want OpenClaw to execute or wake agents
|
||||
- **Squad Chat** — Real-time agent-to-agent communication with WebSocket updates, system lifecycle events, model attribution per message, and configurable display names
|
||||
- **@Mention notifications** — @agent-name parsing in comments, thread subscriptions
|
||||
- **Broadcast Notifications** — Priority-based persistent notifications with read receipts and agent-specific delivery
|
||||
- **Squad Chat Webhook** — Configurable webhooks (generic HTTP or OpenClaw Direct) for external agent integration
|
||||
- **Buzz Communication Adapter** — Native signed root/reply bridge between one mapped Buzz community channel and Squad Chat, with durable replay, ambiguous-send reconciliation, and operator-confirmed persona/team definition materialization
|
||||
- **Buzz Agent ACP profile** — Generic ACP execution with pinned Buzz v0.4.24
|
||||
identity/capability evidence, safe environment allowlists, and honest
|
||||
no-resume and stdio-only MCP posture
|
||||
- **GitHub Copilot CLI ACP profile** — Generic ACP execution with a system-owned
|
||||
stdio/public-preview launch baseline, exact v1.0.74 compatibility evidence,
|
||||
bounded restrictive process policy, and honest source/authentication limits
|
||||
- **Agent registry** — Service discovery with heartbeat tracking, capabilities, and live status
|
||||
- **Multi-agent dashboard** — Real-time sidebar with expandable agent cards, status indicators
|
||||
- **Multi-agent task assignment** — Assign multiple agents per task with color-coded chips
|
||||
|
|
@ -181,9 +283,10 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
- **Error learning** — Structured failure analysis with similarity search
|
||||
- **Task lifecycle hooks** — 7 built-in hooks, 8 events, custom hooks API
|
||||
- **Task Deliverables** — First-class deliverable objects with type/status tracking (code, documentation, data, etc.)
|
||||
- **Durable Work Products** — Versioned generated reports, checklists, tables, and handoff artifacts with provenance and redacted previews
|
||||
- **Efficient Polling** — `/api/changes?since=...` endpoint with ETag support for optimized agent polling
|
||||
- **Approval Delegation** — Vacation mode with scoped approval delegation and automatic routing
|
||||
- **OpenClaw Integration** — Direct gateway wake for real-time squad chat notifications and agent orchestration
|
||||
- **OpenClaw Integration** — Optional direct gateway wake for real-time squad chat notifications and agent orchestration
|
||||
- **Reverse Proxy Ready** — Deploy behind nginx, Caddy, Traefik, or any reverse proxy with `TRUST_PROXY`
|
||||
- **Multiple attempts** — Retry with different agents, preserve history
|
||||
- **Running indicator** — Visual feedback when agents are working
|
||||
|
|
@ -199,6 +302,7 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
- **Run state management** — Persistent run state survives server restarts, retry with exponential backoff, resume blocked runs
|
||||
- **Tool policies** — Role-based tool restrictions (5 default roles: planner, developer, reviewer, tester, deployer) with custom role CRUD
|
||||
- **Session isolation** — Each workflow step runs in a fresh OpenClaw session with configurable context injection
|
||||
- **Veritas Cutover templates** — GitHub-backed product/spec, research/revenue, approval-gated client, and completion-comment templates
|
||||
- **Monitoring dashboard** — Summary cards, live active runs table, recent history, per-workflow health metrics
|
||||
- **Real-time updates** — WebSocket-primary with polling fallback; 75% reduction in API calls when connected
|
||||
- **Workflow API** — 9 CRUD endpoints for workflow definitions, runs, and control
|
||||
|
|
@ -223,7 +327,7 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
- **Activity page** — Status history with clickable task navigation, color-coded badges, and daily summary
|
||||
- **Daily standup summary** — Generate standup reports via API or CLI (`vk summary standup`)
|
||||
- **Task Templates** — Create reusable templates with defaults, subtasks, and multi-task blueprints
|
||||
- **Documentation freshness** — Steward workflow with freshness headers and automated staleness detection
|
||||
- **Documentation freshness** — Registry-backed review dates, thresholds, scores, and staleness alerts
|
||||
- **Cost prediction** — Multi-factor cost estimation for tasks
|
||||
|
||||
#### Dashboard
|
||||
|
|
@ -260,7 +364,8 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
#### Integration
|
||||
|
||||
- **CLI** — `vk` command for terminal workflows
|
||||
- **MCP Server** — 33+ tools across 7 categories via Model Context Protocol
|
||||
- **MCP Server** — 42 tools across 9 categories via Model Context Protocol
|
||||
- **Codex MCP setup** — documented `codex mcp add veritas-kanban` setup for local and API-key-backed deployments
|
||||
- **Notifications** — Teams integration for task updates
|
||||
|
||||
</details>
|
||||
|
|
@ -269,16 +374,16 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
| Layer | Technology | Version |
|
||||
| ------------------- | ------------------------------------ | -------------------------------- |
|
||||
| **Frontend** | React, Vite, Tailwind CSS, Shadcn UI | React 19, Vite 7.3, Tailwind 4.2 |
|
||||
| **Backend** | Express, WebSocket | Express 5.2 |
|
||||
| **Language** | TypeScript (strict mode) | 6.0 |
|
||||
| **Storage** | Markdown files with YAML frontmatter | gray-matter |
|
||||
| **Git** | simple-git, worktree management | — |
|
||||
| **Testing** | Playwright (E2E), Vitest (unit) | Playwright 1.58, Vitest 4 |
|
||||
| **Runtime** | Node.js | 22+ |
|
||||
| **Package Manager** | pnpm | 9+ |
|
||||
| Layer | Technology | Version |
|
||||
| ------------------- | ------------------------------------- | ------------------------------------------- |
|
||||
| **Frontend** | React, Vite, Tailwind CSS, Mantine UI | React 19, Vite 8, Tailwind 4.3, Mantine 9.5 |
|
||||
| **Backend** | Express, WebSocket | Express 5.2 |
|
||||
| **Language** | TypeScript (strict mode) | 6.0 |
|
||||
| **Storage** | Markdown files with YAML frontmatter | yaml + local frontmatter helper |
|
||||
| **Git** | simple-git, worktree management | — |
|
||||
| **Testing** | Playwright (E2E), Vitest (unit) | Playwright 1.62, Vitest 4.1 |
|
||||
| **Runtime** | Node.js | 22.22.1+ |
|
||||
| **Package Manager** | pnpm | 11.1.1 (pinned) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -297,7 +402,7 @@ Veritas Kanban is neither. It's the **visual command center for agentic work**
|
|||
| **YAML workflow pipelines** | ✅ Loops, gates, parallel | ⚠️ Code-defined only | ❌ |
|
||||
| **Real-time agent dashboard** | ✅ Status, model attribution | ❌ | ❌ |
|
||||
| **Agent communication** | ✅ Squad Chat with lifecycle events | ⚠️ Internal only | ❌ |
|
||||
| **MCP server** | ✅ 33+ tools | ❌ | ❌ |
|
||||
| **MCP server** | ✅ 42 tools | ❌ | ❌ |
|
||||
| **CLI** | ✅ Full lifecycle | ❌ | ⚠️ Limited |
|
||||
| **Git worktrees + code review** | ✅ Built-in | ❌ | ❌ |
|
||||
| **Task persistence** | ✅ Markdown files | ❌ In-memory | ✅ Database |
|
||||
|
|
@ -309,7 +414,7 @@ Veritas Kanban is neither. It's the **visual command center for agentic work**
|
|||
|
||||
**The bottom line:** Orchestration frameworks give you agent execution without visibility. Project boards give you visibility without agent execution. Veritas Kanban gives you both — plus the guardrails, telemetry, and audit trails that production agentic work demands.
|
||||
|
||||
Built and battle-tested with [OpenClaw](https://github.com/openclaw/openclaw). Works with any platform that can make HTTP calls.
|
||||
Built and battle-tested with [OpenClaw](https://github.com/openclaw/openclaw), with docs for Codex and HermesAgent/Hermes Gateway workflows. OpenClaw is optional. VK works with any platform that can make HTTP calls.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -381,7 +486,7 @@ veritas-kanban/ ← pnpm monorepo
|
|||
└── agent-requests/
|
||||
```
|
||||
|
||||
**Data flow:** Web ↔ REST API / WebSocket ↔ Server ↔ Markdown/YAML files on disk
|
||||
**Data flow:** Web ↔ REST API / WebSocket ↔ Server ↔ configured file or SQLite storage
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -410,22 +515,51 @@ curl -H "X-API-Version: v1" http://localhost:3001/api/tasks
|
|||
|
||||
> 📖 **Comprehensive CLI guide:** [docs/CLI-GUIDE.md](docs/CLI-GUIDE.md) — installation, every command, scripting examples, and tips.
|
||||
|
||||
Manage your entire task lifecycle with two commands.
|
||||
Handle the common start-and-complete task lifecycle with two commands.
|
||||
|
||||
```bash
|
||||
# Install globally
|
||||
cd cli && npm link
|
||||
pnpm --filter @veritas-kanban/shared build
|
||||
pnpm --filter @veritas-kanban/cli build
|
||||
cd cli
|
||||
npm link
|
||||
```
|
||||
|
||||
For read/write auth checks, use the smoke tests in the [CLI Guide](docs/CLI-GUIDE.md#readwrite-smoke-check).
|
||||
|
||||
### Setup & Onboarding
|
||||
|
||||
```bash
|
||||
vk setup # Guided environment check + sample task
|
||||
vk setup --skip-task # Check only, no sample task
|
||||
vk setup --json # Machine-readable output
|
||||
vk doctor # Redacted setup health report
|
||||
vk doctor --json # Support-safe JSON report
|
||||
vk snapshot --format markdown # Redacted runtime support snapshot
|
||||
vk prompts import prompt-registry --dry-run
|
||||
vk sqlite journal status --json # Journal maintenance and override posture
|
||||
vk tool-servers list --json # Registered run-scoped MCP servers
|
||||
vk tool-servers discover <id> # Refresh version-bound tool discovery
|
||||
```
|
||||
|
||||
Validates Node version, server health, API auth, and optionally creates a welcome task to get you started.
|
||||
`vk doctor` adds deeper checks for CLI linking, task identity conflicts, agent
|
||||
executables, routing fallbacks, prompt registry drift, Codex readiness, and
|
||||
notification/webhook configuration. Local paths and delivery URLs are redacted
|
||||
unless you pass `--show-paths`.
|
||||
`vk snapshot` exports a paste-safe runtime snapshot covering versions, API
|
||||
reachability, projects, sprints, agents, global agent status, routing, prompt
|
||||
registry counts, task status counts, notification/webhook enabled states, and
|
||||
maintenance health. Use `--format json|markdown` and `--output <path>` when
|
||||
attaching it to a support handoff.
|
||||
`vk prompts import` syncs file-based prompt templates into the runtime registry.
|
||||
Run with `--dry-run` first; rerun with `--force` only when you want disk content
|
||||
to replace a differing runtime template.
|
||||
`vk sqlite journal preview` and `apply` stage governed journal conversion for
|
||||
the configured authoritative database. Conversion executes before server
|
||||
startup, with a verified backup, rollback, and single-host ownership policy;
|
||||
pre-close failures revert mode while SQLite exclusivity is still held, and the
|
||||
live API never flips journal mode in place.
|
||||
|
||||
### Workflow Commands
|
||||
|
||||
|
|
@ -513,7 +647,16 @@ vk github mappings # List issue↔task mappings
|
|||
```bash
|
||||
vk agents:pending # List pending agent requests
|
||||
vk agents:status <id> # Check if agent running
|
||||
vk agents:complete <id> -s # Mark agent complete
|
||||
vk agents:complete <id> -s --attempt-id <id> --manifest-digest <sha256:...>
|
||||
vk launch-preview <id> --json # Inspect effective launch evidence without dispatch
|
||||
vk profiles list # List reusable agent profile packages
|
||||
vk profiles validate ./agent.yml # Validate a package before import
|
||||
vk profiles import ./agent.yml # Import or replace a package
|
||||
vk start <task> --profile <id> # Launch a task with a profile package
|
||||
vk agent:resume <task> --source-attempt <id> -m "Continue the work"
|
||||
vk agent:fork <task> --source-attempt <id> --fork-turn <id> -m "Try another path"
|
||||
vk agent:steer <task> --attempt <id> -m "Use the smaller fix"
|
||||
vk agent:compact <task> --attempt <id>
|
||||
```
|
||||
|
||||
### Utilities
|
||||
|
|
@ -528,11 +671,19 @@ All commands support `--json` for scripting and machine consumption.
|
|||
|
||||
---
|
||||
|
||||
## 🤖 Agent Integration
|
||||
## 🤖 Optional Agent Integration
|
||||
|
||||
Veritas Kanban works with any agentic platform that can make HTTP calls. The REST API covers the full task lifecycle — create, update, track time, complete.
|
||||
Veritas Kanban works with any agentic platform that can make HTTP calls. The REST API covers the full task lifecycle — create, update, track time, complete. No agent runner is required for board-only use.
|
||||
|
||||
Built and tested with [OpenClaw](https://github.com/openclaw/openclaw) (formerly Clawdbot/Moltbot), which provides native orchestration via `sessions_spawn`. The built-in agent service targets OpenClaw — PRs welcome for adapters to other platforms.
|
||||
Built and tested with [OpenClaw](https://github.com/openclaw/openclaw) (formerly Clawdbot/Moltbot), which provides native orchestration via `sessions_spawn`. OpenClaw is optional. Use it when you want VK to hand work to OpenClaw or wake OpenClaw from Squad Chat events.
|
||||
|
||||
VK also documents the Codex and Hermes operating model:
|
||||
|
||||
- **Veritas is the source of truth** for tasks, status, audit trail, release readiness, and GitHub-linked implementation history.
|
||||
- **HermesAgent/Hermes Gateway is the active control plane** for the named Hermes roster and execution routing.
|
||||
- **Mission Control is display/control only** in the cutover model, while GitHub Issues, PRs, review comments, and CI remain the durable delivery record.
|
||||
- **OpenAI Codex can be a first-class agent** through local CLI runs, SDK sessions, Codex Cloud delegation, workflow steps, review actions, and MCP access.
|
||||
- **Ollama and LM Studio profiles are first-class routing targets** for local/server-hosted model workflows, with Ollama Cloud available when cloud execution is intentional.
|
||||
|
||||
### How It Works
|
||||
|
||||
|
|
@ -541,7 +692,7 @@ Built and tested with [OpenClaw](https://github.com/openclaw/openclaw) (formerly
|
|||
3. **Agent Picks Up** — Your agent reads the request and begins work
|
||||
4. **Work Happens** — Agent updates task status, tracks time, commits code
|
||||
5. **Completion** — Agent calls the completion endpoint with results
|
||||
6. **Task Updates** — Status moves to Review, notifications sent
|
||||
6. **Task Updates** — Status moves to Review; notifications are sent only when configured
|
||||
|
||||
### Any Platform (REST API)
|
||||
|
||||
|
|
@ -579,7 +730,7 @@ curl http://localhost:3001/api/github/sync/status \
|
|||
|
||||
Issues with the `kanban` label are imported as tasks. Status changes push back (done → close, reopen on todo/in-progress/blocked). Labels like `priority:high` and `type:story` map to task fields. Configure in `.veritas-kanban/integrations.json`.
|
||||
|
||||
### OpenClaw (Native)
|
||||
### OpenClaw (Optional Native)
|
||||
|
||||
```bash
|
||||
# Check for pending agent requests
|
||||
|
|
@ -589,13 +740,33 @@ vk agents:pending
|
|||
# then call the completion endpoint automatically.
|
||||
```
|
||||
|
||||
### Managed agent harnesses and external clients
|
||||
|
||||
- Start with the [Agent Guide and `AGENTS.md` Template](docs/AGENTS-TEMPLATE.md)
|
||||
so managed and external agents do not duplicate lifecycle callbacks or
|
||||
telemetry.
|
||||
- Use the [Agent Providers guide](docs/AGENT-PROVIDERS.md) to enable and operate
|
||||
Buzz Agent, Grok Build, Codex, Claude Code, Copilot CLI, Hermes, OpenClaw,
|
||||
ACP-compatible agents, Ollama, or LM Studio.
|
||||
- Use [Harness Compatibility](docs/HARNESS-COMPATIBILITY.md) and
|
||||
`vk doctor --json` to verify the installed runtime instead of relying on a
|
||||
provider name alone.
|
||||
- Use the [Buzz Integration guide](docs/BUZZ-INTEGRATION.md) for relay,
|
||||
community, persona/team import, ACP execution, and workflow-trigger setup.
|
||||
- Configure unmanaged client access with the
|
||||
[MCP Server Guide](docs/mcp/README.md). Managed runs receive only their
|
||||
selected run-scoped catalog and do not need a separate global VK MCP config.
|
||||
- Follow the [Codex Integration SOP](docs/SOP-codex-integration.md) or
|
||||
[Veritas Cutover Operating Guide](docs/VERITAS-CUTOVER.md) only when those
|
||||
specialized workflows apply.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 MCP Server
|
||||
|
||||
33+ tools across 7 categories (tasks, agents, automation, notifications, summaries, sprints, projects) via [Model Context Protocol](https://modelcontextprotocol.io/).
|
||||
Optional. The MCP server exposes 42 tools across 9 categories (tasks, agents, automation, notifications, summaries, sprints, comments, projects, and run-scoped tool control) via [Model Context Protocol](https://modelcontextprotocol.io/). Skip this for board-only use.
|
||||
|
||||
**→ [Full MCP documentation](docs/mcp/README.md)** — architecture, quickstart, tool catalog with examples, security model, and troubleshooting.
|
||||
**→ [Full MCP documentation](docs/mcp/README.md)** — architecture, quickstart, tool catalog with examples, security model, read/write smoke checks, and troubleshooting.
|
||||
|
||||
**Quick config** (Claude Desktop / Cursor / OpenClaw):
|
||||
|
||||
|
|
@ -606,14 +777,17 @@ vk agents:pending
|
|||
"command": "node",
|
||||
"args": ["/path/to/veritas-kanban/mcp/dist/index.js"],
|
||||
"env": {
|
||||
"VK_API_URL": "http://localhost:3001"
|
||||
"VK_API_URL": "http://localhost:3001",
|
||||
"VK_API_KEY": "your-agent-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After adding the config, restart OpenClaw:**
|
||||
`VK_API_KEY` is required for write tools unless localhost auth bypass grants an `agent` or `admin` role. Prefer an `agent` role key over the admin key.
|
||||
|
||||
**After adding the config, restart your MCP client. For OpenClaw:**
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
|
|
@ -623,8 +797,8 @@ Verify discovery with `openclaw mcp list`. See [Troubleshooting](docs/TROUBLESHO
|
|||
|
||||
**Troubleshooting MCP connection issues:**
|
||||
|
||||
- **Always restart OpenClaw after MCP config changes** — MCP servers are discovered at startup
|
||||
- **Verify tools are available:** Run `openclaw mcp list` to confirm 26 Veritas Kanban tools appear
|
||||
- **Always restart the MCP client after MCP config changes** — MCP servers are discovered at startup
|
||||
- **Verify tools are available:** Run `openclaw mcp list` to confirm 42 Veritas Kanban tools appear
|
||||
- **When reporting issues, provide:**
|
||||
- OpenClaw version (`openclaw --version`)
|
||||
- VK version and health (`curl http://localhost:3001/api/health`)
|
||||
|
|
@ -665,78 +839,64 @@ pnpm dev # Start dev servers (web + API concurrently)
|
|||
pnpm build # Production build
|
||||
pnpm typecheck # TypeScript strict check
|
||||
pnpm lint # ESLint
|
||||
pnpm test # Unit tests (Vitest)
|
||||
pnpm lint:budget # ESLint with current warning budget
|
||||
pnpm test # Canonical unit gate (server, web, CLI, MCP)
|
||||
pnpm test:e2e # E2E tests (Playwright)
|
||||
pnpm test:load:smoke # k6 API smoke test
|
||||
pnpm validate:release # Release readiness checks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
| Document | Description |
|
||||
| ---------------------------------------------- | -------------------------------------------- |
|
||||
| [Features](docs/FEATURES.md) | Complete feature reference |
|
||||
| [API Reference](docs/API-REFERENCE.md) | Auth, endpoints, WebSocket docs |
|
||||
| [CLI Guide](docs/CLI-GUIDE.md) | Comprehensive CLI usage guide |
|
||||
| [Self-Hosting Guide](docs/guides/SELF_HOST.md) | Production deployment, reverse proxy, Docker |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, bare metal, env config |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common issues & solutions |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute, PR guidelines |
|
||||
| [Security Policy](SECURITY.md) | Vulnerability reporting |
|
||||
| [Code of Conduct](CODE_OF_CONDUCT.md) | Community guidelines |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
| [Sprint Docs](docs/) | Sprint planning & audit reports |
|
||||
| Document | Description |
|
||||
| ---------------------------------------------- | --------------------------------------------------- |
|
||||
| [Features](docs/FEATURES.md) | Complete feature reference |
|
||||
| [v6 Visual Tour](docs/V6-VISUAL-TOUR.md) | Provider, Buzz, approval, and run evidence views |
|
||||
| [API Reference](docs/API-REFERENCE.md) | Auth, endpoints, WebSocket docs |
|
||||
| [CLI Guide](docs/CLI-GUIDE.md) | Comprehensive CLI usage guide |
|
||||
| [Self-Hosting Guide](docs/guides/SELF_HOST.md) | Production deployment, reverse proxy, Docker |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, bare metal, env config |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common issues and solutions |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute and pull request guidelines |
|
||||
| [Security Policy](SECURITY.md) | Vulnerability reporting |
|
||||
| [Code of Conduct](CODE_OF_CONDUCT.md) | Community guidelines |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
| [Documentation Index](docs/) | Operator, developer, architecture, and release docs |
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
## 📸 Visuals
|
||||
|
||||
<details>
|
||||
<summary><strong>Click to expand screenshots</strong></summary>
|
||||
<summary><strong>Click to expand screenshots and GIFs</strong></summary>
|
||||
|
||||
### Board Overview
|
||||
These captures use release-safe dummy content against the current app surfaces. See the [v6 Visual Tour](docs/V6-VISUAL-TOUR.md) for the current release views and retained v5 shell captures.
|
||||
|
||||
| | |
|
||||
| -------------------------------------------------- | --------------------------------------------------- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
### Desktop
|
||||
|
||||
### Task Management
|
||||
| Board and workflow tour | Desktop shell |
|
||||
| --------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
| | |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
| Workbench panel | Agent provider settings |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
|  |  |
|
||||
|
||||
### Task Extras
|
||||
| Squad Chat coordination | Human reply adapter settings |
|
||||
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
|  |  |
|
||||
|
||||
| | |
|
||||
| -------------------------------------------- | ---------------------------------------------------- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
| Task work view | Maintenance Center |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
|  |  |
|
||||
|
||||
### Metrics & Dashboard
|
||||
### Mobile/PWA
|
||||
|
||||
| | |
|
||||
| -------------------------------------------------- | -------------------------------------------------- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
### Settings
|
||||
|
||||
| | |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
### Menus & Activity
|
||||
|
||||
| | |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
| Mobile board | Mobile board to runs/settings flow |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
|  |  |
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -744,65 +904,22 @@ pnpm test:e2e # E2E tests (Playwright)
|
|||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
See the [open issues](https://github.com/BradGroux/veritas-kanban/issues) for what's next. Community contributions welcome!
|
||||
Current work and priorities live in GitHub, not in a version-specific README checklist:
|
||||
|
||||
### Shipped in v4.0.0
|
||||
- [Open issues](https://github.com/BradGroux/veritas-kanban/issues)
|
||||
- [Release history](CHANGELOG.md)
|
||||
- [GitHub releases](https://github.com/BradGroux/veritas-kanban/releases)
|
||||
|
||||
- ~~[#178](https://github.com/BradGroux/veritas-kanban/issues/178) — Agent Policy & Guard Engine~~ — Configurable tool/action policies with allow/deny/require-approval guard rules
|
||||
- ~~[#179](https://github.com/BradGroux/veritas-kanban/issues/179) — Decision Audit Trail with Assumption Tracking~~ — Log decisions with confidence scores, evidence, and outcome tracking
|
||||
- ~~[#180](https://github.com/BradGroux/veritas-kanban/issues/180) — Agent Output Evaluation & Scoring Framework~~ — Weighted criteria profiles with composite scoring
|
||||
- ~~[#181](https://github.com/BradGroux/veritas-kanban/issues/181) — Behavioral Drift Detection & Alerting~~ — Metric baselines with configurable alert thresholds
|
||||
- ~~[#182](https://github.com/BradGroux/veritas-kanban/issues/182) — User Feedback Loop with Sentiment Analytics~~ — Feedback collection with sentiment tagging and aggregate analytics
|
||||
- ~~[#183](https://github.com/BradGroux/veritas-kanban/issues/183) — Draggable & Resizable Dashboard Widget Grid~~ — Drag-and-drop layout with persistence
|
||||
- ~~[#184](https://github.com/BradGroux/veritas-kanban/issues/184) — Prompt Template Registry with Version Control~~ — Versioned templates with rollback and usage tracking
|
||||
- ~~[#185](https://github.com/BradGroux/veritas-kanban/issues/185) — Global System Health Status Bar~~ — Five health levels across system, agents, and operations signals
|
||||
- ~~[#186](https://github.com/BradGroux/veritas-kanban/issues/186) — Upgrade to shadcn/ui CLI v4.0~~ — All components updated with Tailwind v4 integration
|
||||
Longer-lived product and architecture direction is recorded separately:
|
||||
|
||||
### Backlog
|
||||
- [v6 agent runtime control plane](docs/architecture/V6-AGENT-RUNTIME-CONTROL-PLANE.md)
|
||||
- [phase capability profiles](docs/architecture/PHASE-CAPABILITY-PROFILES.md)
|
||||
- [tool control plane v1](docs/architecture/TOOL-CONTROL-PLANE-V1.md)
|
||||
- [post-GA desktop agent workbench](docs/DESKTOP-AGENT-WORKBENCH.md)
|
||||
- [post-GA native mobile offline decision](docs/architecture/ADR-0003-post-ga-native-mobile-offline.md)
|
||||
- [post-GA cloud sync and hosted SaaS decision](docs/architecture/ADR-0004-post-ga-cloud-sync-hosted-saas.md)
|
||||
|
||||
- [WCAG 2.1 AA accessibility](https://github.com/BradGroux/veritas-kanban/issues/1) — Full keyboard navigation, screen reader support, color contrast
|
||||
- [Example video](https://github.com/BradGroux/veritas-kanban/issues/68) — Hosted walkthrough video on YouTube or Vimeo
|
||||
|
||||
### Shipped in v3.3.x
|
||||
|
||||
- ~~[Task Dependencies Graph](https://github.com/BradGroux/veritas-kanban/issues/122)~~ — Bidirectional dependency model with cycle detection, recursive tree API, visual badges
|
||||
- ~~[Crash-Recovery Checkpointing](https://github.com/BradGroux/veritas-kanban/issues/123)~~ — Save/resume/clear agent state with auto-sanitization of secrets, 1MB limit, 24h expiry
|
||||
- ~~[Observational Memory](https://github.com/BradGroux/veritas-kanban/issues/124)~~ — Per-task observations with importance scoring (1-10), full-text search, timeline view
|
||||
- ~~[Agent Filter](https://github.com/BradGroux/veritas-kanban/issues/125)~~ — Query tasks by agent name with `?agent=name` parameter
|
||||
- ~~[Sprint Management CLI + MCP](https://github.com/BradGroux/veritas-kanban/issues/161)~~ — Full sprint CRUD from command line and MCP (list, create, update, delete, close, suggestions)
|
||||
- ~~[Task↔Agent State Sync](https://github.com/BradGroux/veritas-kanban/issues/155)~~ — Bi-directional sync engine keeping task state consistent with agent execution
|
||||
- ~~Orchestrator Delegation Enforcement~~ — Full enforcement gate with delegation violation reporting
|
||||
- ~~Express 5 + Vite 7 + Tailwind 4 + Zod 4 migration~~ — Major dependency upgrades
|
||||
- ~~[SSRF Webhook Protection](https://github.com/BradGroux/veritas-kanban/issues/165)~~ — Server-side request forgery safeguards
|
||||
- ~~[WebSocket Broadcast Batching](https://github.com/BradGroux/veritas-kanban/issues/167)~~ — Prevents event loop blocking under high-frequency updates
|
||||
|
||||
### Shipped in v3.2.0
|
||||
|
||||
- ~~[Markdown Editor](https://github.com/BradGroux/veritas-kanban/pull/118)~~ — Rich editing toolbar, live preview, keyboard shortcuts (Ctrl+B/I/K) for task descriptions and comments
|
||||
- ~~[Shared Resources Registry](https://github.com/BradGroux/veritas-kanban/pull/119)~~ — Reusable resources (prompts, guidelines, templates) mountable across projects
|
||||
- ~~[Documentation Freshness](https://github.com/BradGroux/veritas-kanban/pull/120)~~ — Staleness tracking with freshness scores, alerts, and auto-review task creation
|
||||
- ~~[Docker Auth Persistence](https://github.com/BradGroux/veritas-kanban/issues/116)~~ — Fixed auth state wiped on container rebuild; added automatic migration
|
||||
|
||||
### Shipped in v2.1.2
|
||||
|
||||
- ~~[Docker Path Resolution](https://github.com/BradGroux/veritas-kanban/issues/102)~~ — Fixed WORKDIR resolution for `.veritas-kanban` directory in containerized deployments
|
||||
|
||||
### Shipped in v2.1.1
|
||||
|
||||
- ~~[Reverse Proxy Support](https://github.com/BradGroux/veritas-kanban/issues/100)~~ — Added `TRUST_PROXY` environment variable for nginx, Caddy, Traefik, and other reverse proxies
|
||||
- ~~[Prompts Registry](https://github.com/BradGroux/veritas-kanban/issues/101)~~ — Centralized prompt templates with versioning and agent-specific customization
|
||||
|
||||
### Shipped in v2.0.0
|
||||
|
||||
- ~~[Dashboard widget toggles](https://github.com/BradGroux/veritas-kanban/issues/92)~~ · ~~[Multi-agent dashboard](https://github.com/BradGroux/veritas-kanban/issues/28)~~ · ~~[Multi-agent task assignment](https://github.com/BradGroux/veritas-kanban/issues/29)~~ · ~~[@Mention notifications](https://github.com/BradGroux/veritas-kanban/issues/30)~~ · ~~[Agent permission levels](https://github.com/BradGroux/veritas-kanban/issues/31)~~ · ~~[Agent self-reporting](https://github.com/BradGroux/veritas-kanban/issues/52)~~ · ~~[CLI usage reporting](https://github.com/BradGroux/veritas-kanban/issues/50)~~ · ~~[Markdown rendering](https://github.com/BradGroux/veritas-kanban/issues/63)~~ · ~~[Cost prediction](https://github.com/BradGroux/veritas-kanban/issues/54)~~ · ~~[Error learning](https://github.com/BradGroux/veritas-kanban/issues/91)~~ · ~~[Task lifecycle hooks](https://github.com/BradGroux/veritas-kanban/issues/72)~~ · ~~[Documentation freshness](https://github.com/BradGroux/veritas-kanban/issues/74)~~ · ~~[Where Time Went](https://github.com/BradGroux/veritas-kanban/issues/57)~~ · ~~[Activity Clock](https://github.com/BradGroux/veritas-kanban/issues/58)~~ · ~~[Hourly Activity](https://github.com/BradGroux/veritas-kanban/issues/59)~~ · ~~[Wall Time Toggle](https://github.com/BradGroux/veritas-kanban/issues/60)~~ · ~~[Session Metrics](https://github.com/BradGroux/veritas-kanban/issues/61)~~ · ~~[Production binding](https://github.com/BradGroux/veritas-kanban/issues/55)~~
|
||||
|
||||
### Shipped in v1.6.0
|
||||
|
||||
- ~~[Model Usage schema & API](https://github.com/BradGroux/veritas-kanban/issues/47)~~ · ~~[Global usage aggregation](https://github.com/BradGroux/veritas-kanban/issues/48)~~ · ~~[Dashboard Model Usage](https://github.com/BradGroux/veritas-kanban/issues/49)~~ · ~~[Standup with cost](https://github.com/BradGroux/veritas-kanban/issues/51)~~ · ~~[Per-model cost tables](https://github.com/BradGroux/veritas-kanban/issues/53)~~ · ~~[Dashboard filter bar](https://github.com/BradGroux/veritas-kanban/issues/56)~~ · ~~[Health endpoints](https://github.com/BradGroux/veritas-kanban/issues/82)~~
|
||||
|
||||
### Shipped in v1.1.0–v1.3.0
|
||||
|
||||
- ~~[API response envelope](https://github.com/BradGroux/veritas-kanban/issues/2)~~ · ~~[Circuit breaker](https://github.com/BradGroux/veritas-kanban/issues/3)~~ · ~~[Load testing (k6)](https://github.com/BradGroux/veritas-kanban/issues/4)~~ · ~~[Prometheus/OTel](https://github.com/BradGroux/veritas-kanban/issues/5)~~ · ~~[Storage abstraction](https://github.com/BradGroux/veritas-kanban/issues/6)~~ · ~~[GitHub Issues sync](https://github.com/BradGroux/veritas-kanban/issues/21)~~ · ~~[Activity feed](https://github.com/BradGroux/veritas-kanban/issues/33)~~ · ~~[Daily standup](https://github.com/BradGroux/veritas-kanban/issues/34)~~
|
||||
Use issues for current work, architecture records for durable direction, and the changelog and releases for shipped work.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
70
SECURITY.md
70
SECURITY.md
|
|
@ -27,6 +27,76 @@ Instead, email us at:
|
|||
|
||||
We will work with you to understand the issue and coordinate disclosure.
|
||||
|
||||
## Repository Secret Hygiene
|
||||
|
||||
Runtime authentication state must never be committed. In particular, any
|
||||
`.veritas-kanban/security.json` file may contain password and recovery-key
|
||||
hashes for an enabled installation. The repository ignores these files and
|
||||
enforces the rule locally and in CI:
|
||||
|
||||
```bash
|
||||
pnpm check:security-artifacts
|
||||
```
|
||||
|
||||
Deleting a sensitive file in a later commit does not remove it from Git
|
||||
history. If authentication material is ever tracked, treat it as exposed:
|
||||
|
||||
1. Determine whether it belongs to a real installation.
|
||||
2. Rotate or invalidate the affected password, recovery key, sessions, and
|
||||
related credentials before relying on repository cleanup.
|
||||
3. Review reachable history, forks, and clones and make an explicit decision
|
||||
about coordinated history rewriting versus retaining the invalidated blob.
|
||||
4. Record sensitive evidence in a private security advisory, not a public
|
||||
issue, commit message, test fixture, or log.
|
||||
|
||||
GitHub secret scanning and push protection should remain enabled for the
|
||||
repository. The tracked-file guard complements those services because generic
|
||||
password and recovery-key hashes may not match provider-specific signatures.
|
||||
|
||||
## CI Supply Chain Integrity
|
||||
|
||||
Every external GitHub Action and reusable workflow reference must use a full
|
||||
40-character commit SHA followed by a readable release comment. Local actions
|
||||
under `./.github/actions/` are reviewed with the repository and do not need a
|
||||
remote revision. Docker actions must use a complete SHA-256 image digest.
|
||||
|
||||
The same policy is enforced locally and in CI:
|
||||
|
||||
```bash
|
||||
pnpm check:actions-pinned
|
||||
```
|
||||
|
||||
Dependabot retains the `github-actions` ecosystem entry so reviewed updates can
|
||||
advance both the immutable commit and its release comment.
|
||||
|
||||
## Continuous Security Gates
|
||||
|
||||
The `Security Gates` workflow runs CodeQL and gitleaks for pull requests, main
|
||||
branch updates, and a weekly schedule. CodeQL uses the extended JavaScript and
|
||||
TypeScript security query suite. Repository merge protection blocks CodeQL
|
||||
errors and high-or-critical security alerts. Gitleaks scans the current tree,
|
||||
accepts only the exact reviewed fingerprints in `.gitleaksignore`, and proves
|
||||
that a newly introduced synthetic secret is still rejected.
|
||||
|
||||
Brad Groux owns Dependabot and GitHub security alert triage. New dependency,
|
||||
code-scanning, or secret-scanning alerts must be reviewed privately within two
|
||||
working days. Confirm exploitability and affected releases before opening a
|
||||
public issue. Track confirmed vulnerabilities in a private GitHub security
|
||||
advisory, prioritize critical and high findings for the next safe patch, and
|
||||
record false positives at the narrowest available fingerprint or path. Do not
|
||||
disable a detector class to clear a gate.
|
||||
|
||||
Dependabot vulnerability alerts and security updates, GitHub secret scanning,
|
||||
and push protection must remain enabled. Security-update pull requests use the
|
||||
existing `BradGroux` reviewer assignment in `.github/dependabot.yml`.
|
||||
|
||||
Run the repository controls locally with:
|
||||
|
||||
```bash
|
||||
pnpm check:security-gates
|
||||
pnpm check:gitleaks
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
This policy applies to:
|
||||
|
|
|
|||
BIN
assets/brand/veritas-kanban-app-icon.png
Normal file
BIN
assets/brand/veritas-kanban-app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 951 KiB |
BIN
assets/brand/veritas-kanban-icon-source.jpg
Normal file
BIN
assets/brand/veritas-kanban-icon-source.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@veritas-kanban/cli",
|
||||
"version": "3.3.3",
|
||||
"version": "6.1.2",
|
||||
"description": "CLI for Veritas Kanban task management",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -9,17 +9,18 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run --maxWorkers=4",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@veritas-kanban/shared": "workspace:*",
|
||||
"commander": "^14.0.3",
|
||||
"chalk": "^5.3.0"
|
||||
"commander": "^15.0.0",
|
||||
"chalk": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"typescript": "^6.0.3",
|
||||
"tsx": "^4.19.0"
|
||||
"tsx": "^4.23.12"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Brad Groux <brad@digitalmeld.io>",
|
||||
|
|
|
|||
400
cli/src/__tests__/acp.test.ts
Normal file
400
cli/src/__tests__/acp.test.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { Buffer } from 'node:buffer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
RunApprovalRequest,
|
||||
RunEventEnvelope,
|
||||
RunEventPage,
|
||||
Task,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { AcpServerView, readAcpStatus, type AcpApiClient } from '../commands/acp.js';
|
||||
|
||||
function request(id: number, method: string, params: unknown): string {
|
||||
return JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
||||
}
|
||||
|
||||
function event(
|
||||
sequence: number,
|
||||
kind: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): RunEventEnvelope {
|
||||
return {
|
||||
schemaVersion: 'run-event/v1',
|
||||
eventId: `event_${sequence}`,
|
||||
taskId: 'task_1',
|
||||
runId: 'attempt_1',
|
||||
attemptId: 'attempt_1',
|
||||
sequence,
|
||||
receivedAt: '2026-07-24T12:00:00.000Z',
|
||||
kind,
|
||||
source: { provider: 'codex-cli', adapter: 'codex-cli', agent: 'codex' },
|
||||
redaction: { status: 'none', fields: [], originalBytes: 1, persistedBytes: 1 },
|
||||
payload,
|
||||
payloadHash: `sha256:${'a'.repeat(64)}`,
|
||||
} as RunEventEnvelope;
|
||||
}
|
||||
|
||||
function task(attempt?: Task['attempt']): Task {
|
||||
return {
|
||||
id: 'task_1',
|
||||
title: 'ACP task',
|
||||
description: 'Use the ACP view',
|
||||
type: 'code',
|
||||
status: 'in-progress',
|
||||
priority: 'high',
|
||||
project: 'veritas-kanban',
|
||||
created: '2026-07-24T12:00:00.000Z',
|
||||
updated: '2026-07-24T12:00:00.000Z',
|
||||
git: {
|
||||
repo: 'veritas-kanban',
|
||||
branch: 'feat/acp',
|
||||
baseBranch: 'main',
|
||||
worktreePath: '/tmp/task_1',
|
||||
},
|
||||
...(attempt ? { attempt, attempts: [attempt] } : {}),
|
||||
} as Task;
|
||||
}
|
||||
|
||||
const approval: RunApprovalRequest = {
|
||||
schemaVersion: 'run-approval/v1',
|
||||
id: 'runapproval_123456789012',
|
||||
workspaceId: 'local',
|
||||
taskId: 'task_1',
|
||||
attemptId: 'attempt_1',
|
||||
provider: 'codex-cli',
|
||||
agentId: 'codex',
|
||||
requestKind: 'approval',
|
||||
actionClass: 'shell',
|
||||
action: 'Run tests',
|
||||
actionHash: `sha256:${'b'.repeat(64)}`,
|
||||
details: 'pnpm test',
|
||||
resourceScope: ['/tmp/task_1'],
|
||||
riskClass: 'medium',
|
||||
evidenceRevision: `sha256:${'c'.repeat(64)}`,
|
||||
providerRequestId: 'provider_approval_1',
|
||||
mobileSafe: true,
|
||||
status: 'pending',
|
||||
revision: 1,
|
||||
createdAt: '2026-07-24T12:00:00.000Z',
|
||||
updatedAt: '2026-07-24T12:00:00.000Z',
|
||||
expiresAt: '2026-07-24T12:30:00.000Z',
|
||||
};
|
||||
|
||||
describe('vk ACP server view', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('streams one provider-neutral turn through events and the durable approval broker', async () => {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const decisions: unknown[] = [];
|
||||
let eventRead = 0;
|
||||
const fakeApi = vi.fn(async (requestPath: string, options?: RequestInit) => {
|
||||
if (requestPath === '/api/auth/context') {
|
||||
return { role: 'admin', workspaceId: 'local', permissions: ['*'] };
|
||||
}
|
||||
if (requestPath === '/api/tasks') return [task()];
|
||||
if (requestPath.endsWith('/status')) return { running: false };
|
||||
if (requestPath.endsWith('/conversation/fresh')) {
|
||||
expect(JSON.parse(String(options?.body))).toMatchObject({
|
||||
message: 'Implement the scoped task',
|
||||
agent: 'codex',
|
||||
});
|
||||
return { attemptId: 'attempt_1' };
|
||||
}
|
||||
if (requestPath.includes('/attempts/attempt_1/events?')) {
|
||||
eventRead += 1;
|
||||
return eventRead === 1
|
||||
? page([
|
||||
event(1, 'message.delta', { summary: 'Working on it.' }),
|
||||
event(2, 'tool.started', { summary: 'Run tests' }),
|
||||
event(3, 'approval.requested', { approvalId: approval.id }),
|
||||
])
|
||||
: page([
|
||||
event(4, 'tool.completed', { summary: 'Tests passed', success: true }),
|
||||
event(5, 'run.completed'),
|
||||
]);
|
||||
}
|
||||
if (requestPath === `/api/run-approvals/${approval.id}`) return approval;
|
||||
if (requestPath === `/api/run-approvals/${approval.id}/decision`) {
|
||||
decisions.push(JSON.parse(String(options?.body)));
|
||||
return { ...approval, status: 'approved' };
|
||||
}
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
agent: 'codex',
|
||||
pollIntervalMs: 1,
|
||||
now: () => Date.parse('2026-07-24T12:00:00.000Z'),
|
||||
write: (record) => writes.push(record as unknown as Record<string, unknown>),
|
||||
});
|
||||
|
||||
await server.acceptLine(
|
||||
request(1, 'initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
clientInfo: { name: 'fixture', version: '1.0.0' },
|
||||
})
|
||||
);
|
||||
await server.acceptLine(
|
||||
request(2, 'session/new', {
|
||||
cwd: '/tmp/task_1',
|
||||
mcpServers: [],
|
||||
_meta: { 'veritas/taskId': 'task_1' },
|
||||
})
|
||||
);
|
||||
const sessionId = String((writes.at(-1)?.result as Record<string, unknown>).sessionId);
|
||||
const prompt = server.acceptLine(
|
||||
request(3, 'session/prompt', {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'Implement the scoped task' }],
|
||||
})
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(writes.some((record) => record.method === 'session/request_permission')).toBe(true);
|
||||
});
|
||||
const permission = writes.find((record) => record.method === 'session/request_permission');
|
||||
await server.acceptLine(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: permission?.id,
|
||||
result: { outcome: { outcome: 'selected', optionId: 'allow_once' } },
|
||||
})
|
||||
);
|
||||
await prompt;
|
||||
|
||||
expect(decisions).toEqual([
|
||||
{
|
||||
decision: 'approved',
|
||||
expectedRevision: 1,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: 'ACP client selected allow once.',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
writes.filter((record) => record.method === 'session/update').map((record) => record.params)
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
sessionId,
|
||||
update: expect.objectContaining({ sessionUpdate: 'agent_message_chunk' }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sessionId,
|
||||
update: expect.objectContaining({ sessionUpdate: 'tool_call' }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sessionId,
|
||||
update: expect.objectContaining({ sessionUpdate: 'tool_call_update' }),
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(writes.at(-1)).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
result: { stopReason: 'end_turn' },
|
||||
});
|
||||
});
|
||||
|
||||
it('loads and replays a durable attempt, then cancels it without a stop fallback', async () => {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const calls: Array<{ path: string; body?: unknown }> = [];
|
||||
const attempt = {
|
||||
id: 'attempt_1',
|
||||
agent: 'codex',
|
||||
status: 'running',
|
||||
started: '2026-07-24T12:00:00.000Z',
|
||||
conversation: {
|
||||
schemaVersion: 'conversation-lifecycle/v1',
|
||||
mode: 'fresh',
|
||||
intent: 'fresh',
|
||||
state: 'active',
|
||||
contextWindow: {
|
||||
posture: 'healthy',
|
||||
measuredAt: '2026-07-24T12:00:00.000Z',
|
||||
},
|
||||
createdAt: '2026-07-24T12:00:00.000Z',
|
||||
updatedAt: '2026-07-24T12:00:00.000Z',
|
||||
},
|
||||
} as Task['attempt'];
|
||||
const fakeApi = vi.fn(async (requestPath: string, options?: RequestInit) => {
|
||||
calls.push({
|
||||
path: requestPath,
|
||||
...(options?.body ? { body: JSON.parse(String(options.body)) } : {}),
|
||||
});
|
||||
if (requestPath === '/api/tasks') return [task(attempt)];
|
||||
if (requestPath.includes('/events?')) {
|
||||
return page([event(2, 'message.delta', { summary: 'replayed' })]);
|
||||
}
|
||||
if (requestPath.endsWith('/conversation/interrupt')) {
|
||||
return { delivered: true };
|
||||
}
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
boundTaskId: 'task_1',
|
||||
write: (record) => writes.push(record as unknown as Record<string, unknown>),
|
||||
});
|
||||
const sessionId = `vkacp_${Buffer.from('task_1').toString('base64url')}`;
|
||||
|
||||
await server.acceptLine(
|
||||
request(1, 'session/load', {
|
||||
sessionId,
|
||||
cwd: '/tmp/task_1',
|
||||
mcpServers: [],
|
||||
_meta: { 'veritas/afterSequence': 1 },
|
||||
})
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(writes.some((record) => record.method === 'session/update')).toBe(true);
|
||||
});
|
||||
await server.acceptLine(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/cancel',
|
||||
params: { sessionId },
|
||||
})
|
||||
);
|
||||
|
||||
expect(calls).toContainEqual({
|
||||
path: '/api/agents/task_1/conversation/interrupt',
|
||||
body: { attemptId: 'attempt_1' },
|
||||
});
|
||||
expect(calls.some((call) => call.path.endsWith('/stop'))).toBe(false);
|
||||
});
|
||||
|
||||
it('disconnects the protocol view without stopping or interrupting the durable run', async () => {
|
||||
const calls: string[] = [];
|
||||
const fakeApi = vi.fn(async (requestPath: string) => {
|
||||
calls.push(requestPath);
|
||||
if (requestPath === '/api/tasks') return [task()];
|
||||
if (requestPath.endsWith('/status')) return { running: false };
|
||||
if (requestPath.endsWith('/conversation/fresh')) return { attemptId: 'attempt_1' };
|
||||
if (requestPath.includes('/events?')) return page([]);
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
boundTaskId: 'task_1',
|
||||
pollIntervalMs: 1,
|
||||
write: vi.fn(),
|
||||
});
|
||||
const sessionId = `vkacp_${Buffer.from('task_1').toString('base64url')}`;
|
||||
|
||||
await server.acceptLine(request(1, 'session/new', { cwd: '/tmp/task_1', mcpServers: [] }));
|
||||
const prompt = server.acceptLine(
|
||||
request(2, 'session/prompt', {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'Keep the durable run alive' }],
|
||||
})
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(calls.some((call) => call.includes('/events?'))).toBe(true);
|
||||
});
|
||||
server.disconnect();
|
||||
await prompt;
|
||||
|
||||
expect(calls.some((call) => call.endsWith('/stop'))).toBe(false);
|
||||
expect(calls.some((call) => call.endsWith('/conversation/interrupt'))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the same ACP client contract for two configured providers', async () => {
|
||||
const launchedAgents: unknown[] = [];
|
||||
|
||||
for (const agent of ['codex', 'claude']) {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const fakeApi = vi.fn(async (requestPath: string, options?: RequestInit) => {
|
||||
if (requestPath === '/api/tasks') return [task()];
|
||||
if (requestPath.endsWith('/status')) return { running: false };
|
||||
if (requestPath.endsWith('/conversation/fresh')) {
|
||||
launchedAgents.push(JSON.parse(String(options?.body)).agent);
|
||||
return { attemptId: 'attempt_1' };
|
||||
}
|
||||
if (requestPath.includes('/events?')) return page([event(1, 'run.completed')]);
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
agent,
|
||||
boundTaskId: 'task_1',
|
||||
write: (record) => writes.push(record as unknown as Record<string, unknown>),
|
||||
});
|
||||
|
||||
await server.acceptLine(request(1, 'session/new', { cwd: '/tmp/task_1', mcpServers: [] }));
|
||||
const sessionId = String((writes.at(-1)?.result as Record<string, unknown>).sessionId);
|
||||
await server.acceptLine(
|
||||
request(2, 'session/prompt', {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'Use the selected provider' }],
|
||||
})
|
||||
);
|
||||
expect(writes.at(-1)).toMatchObject({
|
||||
id: 2,
|
||||
result: { stopReason: 'end_turn' },
|
||||
});
|
||||
}
|
||||
|
||||
expect(launchedAgents).toEqual(['codex', 'claude']);
|
||||
});
|
||||
|
||||
it('fails malformed, unsupported, and client-owned tool-catalog requests closed', async () => {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const fakeApi = vi.fn(async (requestPath: string) => {
|
||||
if (requestPath === '/api/tasks') return [task()];
|
||||
throw new Error(`Unexpected API request: ${requestPath}`);
|
||||
}) as AcpApiClient;
|
||||
const server = new AcpServerView({
|
||||
api: fakeApi,
|
||||
boundTaskId: 'task_1',
|
||||
write: (record) => writes.push(record as unknown as Record<string, unknown>),
|
||||
});
|
||||
|
||||
await server.acceptLine('{bad');
|
||||
await server.acceptLine(request(2, 'unknown/method', {}));
|
||||
await server.acceptLine(
|
||||
request(3, 'session/new', {
|
||||
cwd: '/tmp/task_1',
|
||||
mcpServers: [{ name: 'unowned', command: 'node', args: [], env: [] }],
|
||||
})
|
||||
);
|
||||
|
||||
expect(writes.map((record) => (record.error as Record<string, unknown>)?.code)).toEqual([
|
||||
-32700, -32601, -32003,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports API-backed readiness without claiming provider capabilities', async () => {
|
||||
const ready = await readAcpStatus(
|
||||
vi.fn(async () => ({ role: 'admin', workspaceId: 'local' })) as AcpApiClient
|
||||
);
|
||||
expect(ready).toMatchObject({
|
||||
protocolVersion: 1,
|
||||
transport: 'stdio',
|
||||
ready: true,
|
||||
providerNeutral: true,
|
||||
durableRuns: true,
|
||||
role: 'admin',
|
||||
workspaceId: 'local',
|
||||
});
|
||||
|
||||
const blocked = await readAcpStatus(
|
||||
vi.fn(async () => {
|
||||
throw new Error('API unavailable');
|
||||
}) as AcpApiClient
|
||||
);
|
||||
expect(blocked).toMatchObject({ ready: false, error: 'API unavailable' });
|
||||
});
|
||||
});
|
||||
|
||||
function page(events: RunEventEnvelope[]): RunEventPage {
|
||||
return {
|
||||
schemaVersion: 'run-event/v1',
|
||||
taskId: 'task_1',
|
||||
attemptId: 'attempt_1',
|
||||
events,
|
||||
nextCursor: events.at(-1)?.sequence ?? 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
438
cli/src/__tests__/admission.test.ts
Normal file
438
cli/src/__tests__/admission.test.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { Command } from 'commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const api = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../utils/api.js', () => ({ api }));
|
||||
|
||||
import { registerAdmissionCommands } from '../commands/admission.js';
|
||||
|
||||
describe('vk admission commands', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
it('lists reservations as JSON with all operator filters preserved', async () => {
|
||||
api.mockResolvedValue({
|
||||
generatedAt: '2026-07-25T10:00:00.000Z',
|
||||
reservations: [{ id: 'admission_1', state: 'active' }],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'list',
|
||||
'--workspace',
|
||||
'workspace-a',
|
||||
'--workflow-run',
|
||||
'run_1234567890_abcdef',
|
||||
'--workflow-step',
|
||||
'execute',
|
||||
'--root-reservation',
|
||||
'admission_root',
|
||||
'--root-objective',
|
||||
'objective-a',
|
||||
'--node',
|
||||
'node-child',
|
||||
'--parent-node',
|
||||
'node-root',
|
||||
'--state',
|
||||
'active',
|
||||
'released',
|
||||
'--limit',
|
||||
'25',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
'/api/admission?workspaceId=workspace-a&workflowRunId=run_1234567890_abcdef&workflowStepId=execute&rootReservationId=admission_root&rootObjectiveId=objective-a&nodeId=node-child&parentNodeId=node-root&state=active&state=released&limit=25'
|
||||
);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toEqual({
|
||||
generatedAt: '2026-07-25T10:00:00.000Z',
|
||||
reservations: [{ id: 'admission_1', state: 'active' }],
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('inspects one reservation as JSON', async () => {
|
||||
api.mockResolvedValue({ id: 'admission_1', state: 'released' });
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync(['node', 'vk', 'admission', 'get', 'admission_1', '--json']);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/admission_1');
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toEqual({
|
||||
id: 'admission_1',
|
||||
state: 'released',
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('inspects an aggregate execution tree as JSON', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-budget-summary/v1',
|
||||
rootObjectiveId: 'objective-a',
|
||||
contributors: [],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'tree',
|
||||
'objective-a',
|
||||
'--limit',
|
||||
'25',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/tree/objective-a?limit=25');
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
rootObjectiveId: 'objective-a',
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('shows durable execution-tree control in human output', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-budget-summary/v1',
|
||||
rootObjectiveId: 'objective-a',
|
||||
control: {
|
||||
schemaVersion: 'execution-tree-control/v1',
|
||||
rootObjectiveId: 'objective-a',
|
||||
state: 'cancelled',
|
||||
trigger: 'operator',
|
||||
reason: 'Operator stopped runaway expansion.',
|
||||
idempotencyKey: 'sha256:cancelled',
|
||||
recordedAt: '2026-07-25T12:00:00.000Z',
|
||||
},
|
||||
committed: {
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
toolCalls: 0,
|
||||
runtimeSeconds: 0,
|
||||
idleRuntimeSeconds: 0,
|
||||
costUsd: 0,
|
||||
retries: 0,
|
||||
fanOut: 0,
|
||||
},
|
||||
reserved: {
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
toolCalls: 0,
|
||||
runtimeSeconds: 0,
|
||||
idleRuntimeSeconds: 0,
|
||||
costUsd: 0,
|
||||
retries: 0,
|
||||
fanOut: 0,
|
||||
},
|
||||
policies: [],
|
||||
contributors: [],
|
||||
contributorCount: 0,
|
||||
truncated: false,
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync(['node', 'vk', 'admission', 'tree', 'objective-a']);
|
||||
|
||||
expect(output.mock.calls.map(([line]) => String(line)).join('\n')).toContain(
|
||||
'control=cancelled trigger=operator'
|
||||
);
|
||||
expect(output.mock.calls.map(([line]) => String(line)).join('\n')).toContain(
|
||||
'Operator stopped runaway expansion.'
|
||||
);
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('cancels one queued launch with a stable idempotency identity', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-cancellation/v1',
|
||||
scope: 'queued-launch',
|
||||
queueEntry: { id: 'admission_queue_1', state: 'terminal' },
|
||||
reservationReleased: true,
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'queue',
|
||||
'cancel',
|
||||
'admission_queue_1',
|
||||
'--reason',
|
||||
'Operator cancelled the queued launch.',
|
||||
'--idempotency-key',
|
||||
'cancel-queue-entry-123',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/queue/admission_queue_1/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: 'Operator cancelled the queued launch.',
|
||||
idempotencyKey: 'cancel-queue-entry-123',
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
scope: 'queued-launch',
|
||||
queueEntry: { state: 'terminal' },
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('cancels an execution tree and reports remaining verified runs', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-cancellation/v1',
|
||||
scope: 'execution-tree',
|
||||
rootObjectiveId: 'objective-a',
|
||||
queueEntriesCancelled: 2,
|
||||
interruptedAttempts: 1,
|
||||
runningAttempts: [],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'cancel-tree',
|
||||
'objective-a',
|
||||
'--reason',
|
||||
'Operator cancelled runaway expansion.',
|
||||
'--idempotency-key',
|
||||
'cancel-execution-tree-123',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/tree/objective-a/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: 'Operator cancelled runaway expansion.',
|
||||
idempotencyKey: 'cancel-execution-tree-123',
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
scope: 'execution-tree',
|
||||
queueEntriesCancelled: 2,
|
||||
interruptedAttempts: 1,
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('resumes an eligible execution tree with a stable idempotency identity', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'execution-tree-control/v1',
|
||||
rootObjectiveId: 'objective-a',
|
||||
state: 'resumed',
|
||||
resumedAt: '2026-07-25T12:00:00.000Z',
|
||||
resumeReason: 'Operator confirmed pressure cleared.',
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'resume-tree',
|
||||
'objective-a',
|
||||
'--reason',
|
||||
'Operator confirmed pressure cleared.',
|
||||
'--idempotency-key',
|
||||
'resume-execution-tree-123',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/tree/objective-a/resume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: 'Operator confirmed pressure cleared.',
|
||||
idempotencyKey: 'resume-execution-tree-123',
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
state: 'resumed',
|
||||
rootObjectiveId: 'objective-a',
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('lists the admission queue as JSON with all operator filters preserved', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'admission-queue-list/v1',
|
||||
generatedAt: '2026-07-25T12:00:00.000Z',
|
||||
conditional: true,
|
||||
depth: {
|
||||
global: { current: 2, limit: 1_000 },
|
||||
workspaces: [],
|
||||
},
|
||||
pagination: { page: 2, limit: 25, total: 26, hasMore: false },
|
||||
entries: [{ id: 'admission_queue_1', state: 'queued', position: 26 }],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'queue',
|
||||
'list',
|
||||
'--workspace',
|
||||
'workspace-a',
|
||||
'--root-objective',
|
||||
'objective-a',
|
||||
'--node',
|
||||
'node-a',
|
||||
'--source',
|
||||
'workflow',
|
||||
'--state',
|
||||
'queued',
|
||||
'requeued',
|
||||
'--priority',
|
||||
'3',
|
||||
'--limiting-scope',
|
||||
'provider',
|
||||
'--min-age',
|
||||
'60000',
|
||||
'--max-age',
|
||||
'3600000',
|
||||
'--page',
|
||||
'2',
|
||||
'--limit',
|
||||
'25',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
'/api/admission/queue?workspaceId=workspace-a&rootObjectiveId=objective-a&nodeId=node-a&source=workflow&state=queued&state=requeued&priority=3&limitingScope=provider&minAgeMs=60000&maxAgeMs=3600000&page=2&limit=25'
|
||||
);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
schemaVersion: 'admission-queue-list/v1',
|
||||
conditional: true,
|
||||
entries: [{ id: 'admission_queue_1', position: 26 }],
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('inspects one admission queue entry as JSON', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'admission-queue-inspection/v1',
|
||||
generatedAt: '2026-07-25T12:00:00.000Z',
|
||||
conditional: true,
|
||||
depth: {
|
||||
global: { current: 1, limit: 1_000 },
|
||||
workspaces: [],
|
||||
},
|
||||
entry: {
|
||||
schemaVersion: 'admission-queue-inspection/v1',
|
||||
id: 'admission_queue_1',
|
||||
state: 'leased',
|
||||
readiness: 'reserved',
|
||||
},
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'admission',
|
||||
'queue',
|
||||
'get',
|
||||
'admission_queue_1',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/admission/queue/admission_queue_1');
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
schemaVersion: 'admission-queue-inspection/v1',
|
||||
entry: { id: 'admission_queue_1', readiness: 'reserved' },
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('prints compact conditional queue output without an exact start promise', async () => {
|
||||
api.mockResolvedValue({
|
||||
schemaVersion: 'admission-queue-list/v1',
|
||||
generatedAt: '2026-07-25T12:00:00.000Z',
|
||||
conditional: true,
|
||||
depth: {
|
||||
global: { current: 1, limit: 1_000 },
|
||||
workspaces: [],
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
snapshotTruncated: false,
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
schemaVersion: 'admission-queue-inspection/v1',
|
||||
id: 'admission_queue_1',
|
||||
state: 'queued',
|
||||
position: 1,
|
||||
rawPriority: 1,
|
||||
effectivePriority: 2,
|
||||
agePromotion: 1,
|
||||
ageMs: 60_000,
|
||||
readiness: 'conditional',
|
||||
lease: { posture: 'none' },
|
||||
limitingPolicies: [],
|
||||
conditionalStartFactors: ['capacity-recheck'],
|
||||
launch: {
|
||||
source: 'direct',
|
||||
target: 'direct',
|
||||
taskKey: `sha256:${'a'.repeat(64)}`,
|
||||
rootTaskKey: `sha256:${'b'.repeat(64)}`,
|
||||
workspaceKey: `sha256:${'c'.repeat(64)}`,
|
||||
provider: 'codex-cli',
|
||||
hostKey: `sha256:${'d'.repeat(64)}`,
|
||||
},
|
||||
retry: {
|
||||
count: 0,
|
||||
maximum: 3,
|
||||
availableAt: '2026-07-25T12:00:00.000Z',
|
||||
},
|
||||
createdAt: '2026-07-25T11:59:00.000Z',
|
||||
updatedAt: '2026-07-25T11:59:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerAdmissionCommands(program);
|
||||
|
||||
await program.parseAsync(['node', 'vk', 'admission', 'queue', 'list', '--limit', '1']);
|
||||
|
||||
const rendered = output.mock.calls.map(([line]) => String(line)).join('\n');
|
||||
expect(rendered).toContain('priority=1->2 readiness=conditional');
|
||||
expect(rendered).toContain('Conditional snapshot at 2026-07-25T12:00:00.000Z');
|
||||
expect(rendered).not.toMatch(/\bETA\b|starts? at|start time/i);
|
||||
output.mockRestore();
|
||||
});
|
||||
});
|
||||
513
cli/src/__tests__/agents-runtime-capabilities.test.ts
Normal file
513
cli/src/__tests__/agents-runtime-capabilities.test.ts
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
|
||||
const { mockApi, mockFindTask } = vi.hoisted(() => ({
|
||||
mockApi: vi.fn(),
|
||||
mockFindTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/api.js', () => ({ api: mockApi }));
|
||||
vi.mock('../utils/find.js', () => ({ findTask: mockFindTask }));
|
||||
|
||||
import { registerAgentCommands } from '../commands/agents.js';
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
function expectLaunchBody(expected: Record<string, unknown>): void {
|
||||
const [url, request] = mockApi.mock.calls.at(-1) as [string, { method: string; body: string }];
|
||||
const { idempotencyKey, ...body } = JSON.parse(request.body) as Record<string, unknown>;
|
||||
expect(url).toBe('/api/agents/task_1/start');
|
||||
expect(request.method).toBe('POST');
|
||||
expect(idempotencyKey).toMatch(/^vk-cli:task_1:[0-9a-f-]{36}$/);
|
||||
expect(body).toEqual(expected);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('vk agent runtime capability controls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFindTask.mockResolvedValue({
|
||||
id: 'task_1',
|
||||
type: 'code',
|
||||
git: { worktreePath: '/tmp/task_1' },
|
||||
});
|
||||
mockApi.mockImplementation(async (url: string) =>
|
||||
url.endsWith('/status')
|
||||
? { running: true, attemptId: 'attempt_1' }
|
||||
: { attemptId: 'attempt_1' }
|
||||
);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('forwards required runtime capabilities to the launch API', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'start',
|
||||
'task_1',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--phase',
|
||||
'implement',
|
||||
'--parent-attempt',
|
||||
'attempt_parent',
|
||||
'--require-capability',
|
||||
'tool.mcp',
|
||||
'output.structured',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expectLaunchBody({
|
||||
agent: 'codex',
|
||||
phase: 'implement',
|
||||
requiredRuntimeCapabilities: ['tool.mcp', 'output.structured'],
|
||||
parentAttemptId: 'attempt_parent',
|
||||
});
|
||||
});
|
||||
|
||||
it('previews one explicit phase against exact parent launch evidence', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'launch-preview',
|
||||
'task_1',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--phase',
|
||||
'plan',
|
||||
'--parent-attempt',
|
||||
'attempt_parent',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/launch-preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agent: 'codex',
|
||||
profileId: undefined,
|
||||
phase: 'plan',
|
||||
requiredRuntimeCapabilities: undefined,
|
||||
commitPolicy: undefined,
|
||||
parentAttemptId: 'attempt_parent',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an explicit run commit policy', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
['start', 'task_1', '--agent', 'codex', '--commit-policy', 'forbidden', '--json'],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expectLaunchBody({
|
||||
agent: 'codex',
|
||||
commitPolicy: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces authoritative fail-closed stop errors from the API', async () => {
|
||||
mockApi
|
||||
.mockResolvedValueOnce({ running: true, attemptId: 'attempt_1' })
|
||||
.mockRejectedValueOnce(
|
||||
new Error('Provider runtime does not support stop run: run.stop is unsupported.')
|
||||
);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((
|
||||
_code?: number | string | null
|
||||
) => {
|
||||
throw new Error('process.exit called');
|
||||
}) as typeof process.exit);
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
try {
|
||||
await expect(program.parseAsync(['stop', 'task_1'], { from: 'user' })).rejects.toThrow(
|
||||
'process.exit called'
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('run.stop is unsupported'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('binds stop requests to the attempt returned by status', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(['stop', 'task_1', '--json'], { from: 'user' });
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(1, '/api/agents/task_1/status');
|
||||
expect(mockApi).toHaveBeenNthCalledWith(2, '/api/agents/task_1/stop', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: 'attempt_1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('binds recovery cancellation to the exact persisted parent attempt', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
['agent:cancel-recovery', 'task_1', '--attempt', 'attempt_parent', '--json'],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/recovery/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: 'attempt_parent' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('reads durable phase state for one exact attempt', async () => {
|
||||
mockApi.mockResolvedValueOnce({ current: null, history: [] });
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
['agent:phase', 'task_1', '--attempt', 'attempt_1', '--limit', '25', '--json'],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/phase?attemptId=attempt_1&limit=25');
|
||||
});
|
||||
|
||||
it('binds the first phase transition to exact evidence and manifest provenance', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-phase-cli-'));
|
||||
temporaryRoots.push(root);
|
||||
const fromPath = path.join(root, 'from.json');
|
||||
const targetPath = path.join(root, 'target.json');
|
||||
const fromEvidence = { digest: `sha256:${'1'.repeat(64)}` };
|
||||
const targetEvidence = { digest: `sha256:${'2'.repeat(64)}` };
|
||||
await fs.writeFile(fromPath, JSON.stringify(fromEvidence));
|
||||
await fs.writeFile(targetPath, JSON.stringify(targetEvidence));
|
||||
mockApi.mockResolvedValueOnce({ current: null, history: [] }).mockResolvedValueOnce({
|
||||
status: 'applied',
|
||||
current: null,
|
||||
targetEvidenceDigest: targetEvidence.digest,
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agent:transition-phase',
|
||||
'task_1',
|
||||
'--attempt',
|
||||
'attempt_1',
|
||||
'--operation',
|
||||
'phase-op-1',
|
||||
'--from-evidence',
|
||||
fromPath,
|
||||
'--target-evidence',
|
||||
targetPath,
|
||||
'--manifest',
|
||||
`sha256:${'3'.repeat(64)}`,
|
||||
'--reason',
|
||||
'Approved plan is ready.',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(2, '/api/agents/task_1/phase/transitions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: 'attempt_1',
|
||||
operationId: 'phase-op-1',
|
||||
expectedSequence: 0,
|
||||
expectedPhaseEvidenceDigest: fromEvidence.digest,
|
||||
expectedManifestDigest: `sha256:${'3'.repeat(64)}`,
|
||||
reason: 'Approved plan is ready.',
|
||||
fromEvidence,
|
||||
targetEvidence,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects partially numeric phase approval lifetimes before transition', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-phase-cli-'));
|
||||
temporaryRoots.push(root);
|
||||
const fromPath = path.join(root, 'from.json');
|
||||
const targetPath = path.join(root, 'target.json');
|
||||
await fs.writeFile(fromPath, JSON.stringify({ digest: `sha256:${'1'.repeat(64)}` }));
|
||||
await fs.writeFile(targetPath, JSON.stringify({ digest: `sha256:${'2'.repeat(64)}` }));
|
||||
mockApi.mockResolvedValueOnce({ current: null, history: [] });
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as typeof process.exit);
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
[
|
||||
'agent:transition-phase',
|
||||
'task_1',
|
||||
'--attempt',
|
||||
'attempt_1',
|
||||
'--operation',
|
||||
'phase-op-1',
|
||||
'--from-evidence',
|
||||
fromPath,
|
||||
'--target-evidence',
|
||||
targetPath,
|
||||
'--manifest',
|
||||
`sha256:${'3'.repeat(64)}`,
|
||||
'--reason',
|
||||
'Approved plan is ready.',
|
||||
'--approval-ttl-ms',
|
||||
'1000x',
|
||||
],
|
||||
{ from: 'user' }
|
||||
)
|
||||
).rejects.toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--approval-ttl-ms must be an integer')
|
||||
);
|
||||
expect(mockApi).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('decides an exact phase approval with revision and action-hash guards', async () => {
|
||||
const approval = {
|
||||
id: 'runapproval_000000000001',
|
||||
revision: 4,
|
||||
actionHash: 'a'.repeat(64),
|
||||
status: 'pending',
|
||||
};
|
||||
mockApi.mockResolvedValueOnce(approval).mockResolvedValueOnce({
|
||||
...approval,
|
||||
revision: 5,
|
||||
status: 'approved',
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agent:decide-phase-approval',
|
||||
approval.id,
|
||||
'--decision',
|
||||
'approve',
|
||||
'--note',
|
||||
'Expansion reviewed.',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(2, `/api/run-approvals/${approval.id}/decision`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
decision: 'approved',
|
||||
expectedRevision: 4,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: 'Expansion reviewed.',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a native history fork from an explicit source attempt and turn', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agent:fork',
|
||||
'task_1',
|
||||
'--source-attempt',
|
||||
'attempt_parent',
|
||||
'--message',
|
||||
'Explore the alternate fix',
|
||||
'--fork-turn',
|
||||
'turn_7',
|
||||
'--phase',
|
||||
'explore',
|
||||
'--require-capability',
|
||||
'tool.mcp',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
const [url, request] = mockApi.mock.calls.at(-1) as [string, { method: string; body: string }];
|
||||
const { idempotencyKey, ...body } = JSON.parse(request.body) as Record<string, unknown>;
|
||||
|
||||
expect(url).toBe('/api/agents/task_1/conversation/fork');
|
||||
expect(request.method).toBe('POST');
|
||||
expect(idempotencyKey).toMatch(
|
||||
/^vk-cli:task_1:conversation:fork:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
);
|
||||
expect(body).toEqual({
|
||||
sourceAttemptId: 'attempt_parent',
|
||||
message: 'Explore the alternate fix',
|
||||
forkTurnId: 'turn_7',
|
||||
phase: 'explore',
|
||||
requiredRuntimeCapabilities: ['tool.mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('binds compact controls to the exact active attempt', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(['agent:compact', 'task_1', '--attempt', 'attempt_1', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/conversation/compact', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: 'attempt_1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards attempt and manifest provenance when completing a run', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
const digest = `sha256:${'a'.repeat(64)}`;
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'agents:complete',
|
||||
'task_1',
|
||||
'--attempt-id',
|
||||
'attempt_1',
|
||||
'--manifest-digest',
|
||||
digest,
|
||||
'--summary',
|
||||
'Done',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: 'attempt_1',
|
||||
providerRuntimeManifestDigest: digest,
|
||||
success: true,
|
||||
summary: 'Done',
|
||||
error: undefined,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('scans the exact task workspace execution inventory', async () => {
|
||||
mockApi.mockResolvedValueOnce({
|
||||
inventory: {
|
||||
identity: { digest: `sha256:${'1'.repeat(64)}` },
|
||||
digest: `sha256:${'2'.repeat(64)}`,
|
||||
projectPolicy: { maximumTrust: 'restricted' },
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(['workspace-trust', 'scan', 'task_1', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/workspace-trust');
|
||||
});
|
||||
|
||||
it('records and revokes exact-inventory workspace decisions', async () => {
|
||||
mockApi.mockResolvedValue({
|
||||
id: 'workspace-decision-1',
|
||||
mode: 'trusted',
|
||||
});
|
||||
const digest = `sha256:${'3'.repeat(64)}`;
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'workspace-trust',
|
||||
'decide',
|
||||
'task_1',
|
||||
'--mode',
|
||||
'trusted',
|
||||
'--inventory',
|
||||
digest,
|
||||
'--reason',
|
||||
'Reviewed exact inventory',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/workspace-trust/decisions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode: 'trusted',
|
||||
inventoryDigest: digest,
|
||||
reason: 'Reviewed exact inventory',
|
||||
expiresAt: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
'workspace-trust',
|
||||
'revoke',
|
||||
'task_1',
|
||||
'--inventory',
|
||||
digest,
|
||||
'--reason',
|
||||
'Authorization withdrawn',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/workspace-trust/revoke', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
inventoryDigest: digest,
|
||||
reason: 'Authorization withdrawn',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
229
cli/src/__tests__/api-permissions.test.ts
Normal file
229
cli/src/__tests__/api-permissions.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ClientPermissionError, createGuardedApiClient } from '../utils/api.js';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('CLI API permission preflight', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('blocks mutating commands before calling the target endpoint when the token is read-only', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['task:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: 'blocked' }),
|
||||
})
|
||||
).rejects.toBeInstanceOf(ClientPermissionError);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
|
||||
});
|
||||
|
||||
it('allows read commands when the token has the mapped read permission', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['task:read'],
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: 'task_1', title: 'allowed' }]));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
const tasks = await api<{ id: string; title: string }[]>('/api/tasks');
|
||||
|
||||
expect(tasks).toEqual([{ id: 'task_1', title: 'allowed' }]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('http://vk.test/api/tasks');
|
||||
});
|
||||
|
||||
it('blocks read-only agent approval requests even when agent reads are allowed', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['agent:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/agents/permissions/approvals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agentId: 'agent_1', action: 'create_task' }),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['task:write'],
|
||||
path: '/api/agents/permissions/approvals',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
|
||||
});
|
||||
|
||||
it('requires agent write permission to start a fresh conversation', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['agent:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/agents/task_1/conversation/fresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message: 'blocked' }),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['agent:write'],
|
||||
path: '/api/agents/task_1/conversation/fresh',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('requires task write permission for delegated workspace intake', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['workspace:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/workspace-capabilities/intake', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: 'blocked' }),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['task:write'],
|
||||
path: '/api/workspace-capabilities/intake',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
|
||||
});
|
||||
|
||||
it('requires workflow execute permission for scheduler run actions', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['workflow:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/scheduler/items/workflow%3Aweekly/run', {
|
||||
method: 'POST',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['workflow:execute'],
|
||||
path: '/api/scheduler/items/workflow%3Aweekly/run',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
|
||||
});
|
||||
|
||||
it('requires workflow execute permission for queue monitor run actions', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['workflow:read'],
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
|
||||
await expect(
|
||||
api('/api/queue-monitors/veritas-backlog/run', {
|
||||
method: 'POST',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['workflow:execute'],
|
||||
path: '/api/queue-monitors/veritas-backlog/run',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context');
|
||||
});
|
||||
|
||||
it('requires write permission for SQLite journal preview and admin permission for apply', async () => {
|
||||
const readOnlyContext = {
|
||||
role: 'read-only',
|
||||
isLocalhost: false,
|
||||
permissions: ['backup:read'],
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(readOnlyContext));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const api = createGuardedApiClient('http://vk.test', 'reader-key');
|
||||
await expect(
|
||||
api('/api/maintenance/sqlite/journal/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targetMode: 'wal' }),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['backup:write'],
|
||||
path: '/api/maintenance/sqlite/journal/preview',
|
||||
});
|
||||
|
||||
await expect(
|
||||
api('/api/maintenance/sqlite/journal/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ previewId: 'blocked' }),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
required: ['admin:manage'],
|
||||
path: '/api/maintenance/sqlite/journal/apply',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
393
cli/src/__tests__/doctor.test.ts
Normal file
393
cli/src/__tests__/doctor.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { formatDoctorReport, runDoctorChecks } from '../commands/doctor.js';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, headers: HeadersInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function doctorFetch(routes: Record<string, Response>) {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const response = routes[`${url.pathname}${url.search}`] ?? routes[url.pathname];
|
||||
if (!response) {
|
||||
return jsonResponse({ error: `No fixture for ${url.pathname}` }, 404);
|
||||
}
|
||||
return response.clone();
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function compatibilityResponse(supportStatuses: unknown[]): Response {
|
||||
return jsonResponse({
|
||||
schemaVersion: 'harness-compatibility-matrix/v1',
|
||||
generatedAt: '2026-06-04T07:00:00.000Z',
|
||||
probeRevision: 14,
|
||||
digest: 'a'.repeat(64),
|
||||
tierDefinitions: {},
|
||||
records: [
|
||||
{
|
||||
profileId: 'openai-codex-app-server',
|
||||
testedVersions: ['codex-cli 0.145.0'],
|
||||
sourceAvailability: 'open-source',
|
||||
certification: { status: 'not-run' },
|
||||
},
|
||||
],
|
||||
supportStatuses,
|
||||
});
|
||||
}
|
||||
|
||||
const baseRoutes: Record<string, Response> = {
|
||||
'/api/health': jsonResponse({ ok: true, version: '4.3.2', uptimeMs: 1000 }),
|
||||
'/api/auth/context': jsonResponse({
|
||||
role: 'admin',
|
||||
authMethod: 'localhost-bypass',
|
||||
isLocalhost: true,
|
||||
permissions: ['*'],
|
||||
}),
|
||||
'/api/tasks?view=summary&limit=1': jsonResponse([]),
|
||||
'/api/config/agents': jsonResponse([
|
||||
{
|
||||
type: 'codex',
|
||||
name: 'Codex',
|
||||
command: 'codex',
|
||||
enabled: true,
|
||||
provider: 'codex-cli',
|
||||
},
|
||||
]),
|
||||
'/api/config/harness-compatibility': compatibilityResponse([
|
||||
{
|
||||
agentType: 'codex',
|
||||
profileId: 'openai-codex-cli',
|
||||
adapterId: 'codex-cli',
|
||||
transport: 'process-jsonl',
|
||||
supportTier: 'configured',
|
||||
reason: 'Certification evidence is not current.',
|
||||
failureClass: 'none',
|
||||
checkedAt: '2026-06-04T07:00:00.000Z',
|
||||
enabled: true,
|
||||
executableFound: true,
|
||||
authenticated: true,
|
||||
diagnosticCommands: ['codex --version', 'codex login status'],
|
||||
remediation: ['Run vk doctor.'],
|
||||
},
|
||||
]),
|
||||
'/api/agents/routing': jsonResponse({
|
||||
enabled: true,
|
||||
defaultAgent: 'codex',
|
||||
fallbackOnFailure: true,
|
||||
rules: [],
|
||||
}),
|
||||
'/api/settings/features': jsonResponse({
|
||||
notifications: { enabled: false },
|
||||
hooks: { enabled: false },
|
||||
squadWebhook: { enabled: false },
|
||||
}),
|
||||
'/api/prompt-registry': jsonResponse([{ id: 'prompt_one' }]),
|
||||
'/api/settings/codex/health': jsonResponse({
|
||||
ready: { overall: true, cli: true, sdk: false, cloud: false },
|
||||
cli: { installed: true, authenticated: true },
|
||||
recommendations: [],
|
||||
}),
|
||||
'/api/integrations/communication/adapters/buzz-default/health': jsonResponse(
|
||||
{ error: 'not found' },
|
||||
404
|
||||
),
|
||||
};
|
||||
|
||||
describe('vk doctor', () => {
|
||||
it('returns a clean report when core setup checks pass', async () => {
|
||||
const report = await runDoctorChecks(
|
||||
{
|
||||
apiBase: 'http://vk.test',
|
||||
cwd: '/repo',
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
fetch: doctorFetch(baseRoutes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk' ? '/repo/cli/dist/index.js' : `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-06-04T07:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.summary.fail).toBe(0);
|
||||
expect(report.checks.find((check) => check.id === 'agents')).toMatchObject({
|
||||
status: 'pass',
|
||||
});
|
||||
expect(report.checks.find((check) => check.id === 'harness-support')).toMatchObject({
|
||||
status: 'warn',
|
||||
details: expect.objectContaining({
|
||||
configured: 1,
|
||||
}),
|
||||
});
|
||||
expect(formatDoctorReport(report)).toContain('Doctor result: clean');
|
||||
});
|
||||
|
||||
it('fails for duplicate task IDs and missing enabled agent executables', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/tasks?view=summary&limit=1': jsonResponse([], 200, {
|
||||
'x-veritas-task-identity-conflicts': '2',
|
||||
}),
|
||||
'/api/config/agents': jsonResponse([
|
||||
{
|
||||
type: 'missing-agent',
|
||||
name: 'Missing Agent',
|
||||
command: 'missing-agent',
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
'/api/agents/routing': jsonResponse({
|
||||
enabled: true,
|
||||
defaultAgent: 'missing-agent',
|
||||
fallbackOnFailure: true,
|
||||
rules: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{ apiBase: 'http://vk.test', cwd: '/repo', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 0,
|
||||
resolveCommand: async (command) => (command === 'vk' ? '/repo/cli/dist/index.js' : null),
|
||||
now: () => new Date('2026-06-04T07:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === 'tasks')).toMatchObject({
|
||||
status: 'fail',
|
||||
});
|
||||
expect(report.checks.find((check) => check.id === 'agents')).toMatchObject({
|
||||
status: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for an enabled unsupported harness and preserves safe remediation', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/config/harness-compatibility': compatibilityResponse([
|
||||
{
|
||||
agentType: 'claude-code',
|
||||
profileId: 'claude-code',
|
||||
transport: 'process-jsonl',
|
||||
supportTier: 'unsupported',
|
||||
reason: 'No executable adapter is registered.',
|
||||
failureClass: 'adapter-unavailable',
|
||||
checkedAt: '2026-06-04T07:00:00.000Z',
|
||||
enabled: true,
|
||||
executableFound: true,
|
||||
authenticated: true,
|
||||
diagnosticCommands: ['claude --version'],
|
||||
remediation: ['Disable this profile or install a supported adapter.'],
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{ apiBase: 'http://vk.test', cwd: '/repo', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk' ? '/repo/cli/dist/index.js' : `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-06-04T07:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === 'harness-support')).toMatchObject({
|
||||
status: 'fail',
|
||||
details: {
|
||||
blocking: [
|
||||
expect.objectContaining({
|
||||
profileId: 'claude-code',
|
||||
diagnosticCommands: ['claude --version'],
|
||||
remediation: ['Disable this profile or install a supported adapter.'],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts local paths and webhook secrets from support-safe JSON', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/settings/features': jsonResponse({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
webhookUrl: 'https://hooks.example.test/path/secret-token',
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
onCompleted: {
|
||||
enabled: true,
|
||||
webhook: 'https://hooks.example.test/hook/private-token',
|
||||
},
|
||||
},
|
||||
squadWebhook: {
|
||||
enabled: true,
|
||||
mode: 'openclaw',
|
||||
openclawGatewayUrl: 'http://127.0.0.1:18789',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{
|
||||
apiBase: 'http://vk.test',
|
||||
cwd: '/Users/bradgroux/Projects/veritas-kanban',
|
||||
showPaths: false,
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/Users/bradgroux/Projects/veritas-kanban',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk'
|
||||
? '/Users/bradgroux/Projects/veritas-kanban/cli/dist/index.js'
|
||||
: `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-06-04T07:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(report);
|
||||
expect(serialized).not.toContain('/Users/bradgroux');
|
||||
expect(serialized).not.toContain('secret-token');
|
||||
expect(serialized).not.toContain('private-token');
|
||||
expect(serialized).toContain('[redacted path]');
|
||||
expect(report.checks.find((check) => check.id === 'notifications')).toMatchObject({
|
||||
status: 'warn',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports exact Buzz health in human and JSON output', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/integrations/communication/adapters/buzz-default/health': jsonResponse({
|
||||
adapterId: 'buzz-default',
|
||||
status: 'healthy',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
detail:
|
||||
'Buzz relay identity, configured signing identity, membership posture, and read capabilities are compatible.',
|
||||
reasonCode: 'ok',
|
||||
buzz: {
|
||||
schemaVersion: 'buzz-compatibility/v1',
|
||||
probeRevision: 1,
|
||||
testedRelease: '0.4.24',
|
||||
testedCommit: '710ed9fff57878a1d69f809b80a6ee0416c53fc4',
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
detail: 'compatible',
|
||||
configuredRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayHttpUrl: 'https://relay.example.test',
|
||||
resolvedRelayWebSocketUrl: 'wss://relay.example.test',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
observedCommunity: 'relay.example.test',
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
checks: {
|
||||
relayIdentity: 'verified',
|
||||
communityBinding: 'verified',
|
||||
configuredIdentity: 'verified',
|
||||
authentication: 'verified',
|
||||
membership: 'verified',
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
commands: [],
|
||||
evidenceKey: 'safe-evidence',
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{ apiBase: 'http://vk.test', cwd: '/repo', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk' ? '/repo/cli/dist/index.js' : `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-07-23T18:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.checks.find((check) => check.id === 'buzz')).toMatchObject({
|
||||
status: 'pass',
|
||||
details: {
|
||||
status: 'healthy',
|
||||
reasonCode: 'ok',
|
||||
expectedCommunity: 'relay.example.test',
|
||||
observedCommunity: 'relay.example.test',
|
||||
publicKeyFingerprint: 'abc123abc123',
|
||||
testedRelease: '0.4.24',
|
||||
buzz: {
|
||||
checks: {
|
||||
channelRead: 'verified',
|
||||
messageRead: 'verified',
|
||||
},
|
||||
evidenceKey: 'safe-evidence',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(formatDoctorReport(report)).toContain('[PASS] Buzz compatibility');
|
||||
});
|
||||
|
||||
it('fails doctor for an enabled Buzz membership denial with remediation', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/integrations/communication/adapters/buzz-default/health': jsonResponse({
|
||||
adapterId: 'buzz-default',
|
||||
status: 'not_member',
|
||||
configured: true,
|
||||
canSend: false,
|
||||
canReceiveReplies: false,
|
||||
checkedAt: '2026-07-23T18:00:00.000Z',
|
||||
detail: 'Buzz authenticated the identity but denied relay membership.',
|
||||
reasonCode: 'relay_membership_required',
|
||||
remediation: 'Add the public identity as a relay member.',
|
||||
}),
|
||||
};
|
||||
|
||||
const report = await runDoctorChecks(
|
||||
{ apiBase: 'http://vk.test', cwd: '/repo', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: doctorFetch(routes),
|
||||
env: {},
|
||||
findProjectRoot: async () => '/repo',
|
||||
countPromptTemplateFiles: async () => 1,
|
||||
resolveCommand: async (command) =>
|
||||
command === 'vk' ? '/repo/cli/dist/index.js' : `/usr/bin/${command}`,
|
||||
now: () => new Date('2026-07-23T18:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === 'buzz')).toMatchObject({
|
||||
status: 'fail',
|
||||
details: {
|
||||
status: 'not_member',
|
||||
reasonCode: 'relay_membership_required',
|
||||
},
|
||||
remediation: 'Add the public identity as a relay member.',
|
||||
});
|
||||
});
|
||||
});
|
||||
214
cli/src/__tests__/goals.test.ts
Normal file
214
cli/src/__tests__/goals.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { Command } from 'commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const api = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../utils/api.js', () => ({ api }));
|
||||
|
||||
import { registerGoalCommands } from '../commands/goals.js';
|
||||
|
||||
const GOAL_ID = 'goal_0123456789abcdef';
|
||||
|
||||
describe('vk goals commands', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
it('lists goals as JSON with bounded filters', async () => {
|
||||
api.mockResolvedValue({
|
||||
generatedAt: '2026-07-26T02:00:00.000Z',
|
||||
goals: [{ id: GOAL_ID, state: 'blocked' }],
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'list',
|
||||
'--state',
|
||||
'active',
|
||||
'blocked',
|
||||
'--root-task',
|
||||
'task-865',
|
||||
'--limit',
|
||||
'25',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
'/api/goals?state=active&state=blocked&rootTaskId=task-865&limit=25'
|
||||
);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
goals: [{ id: GOAL_ID, state: 'blocked' }],
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('creates an evidence-gated task goal', async () => {
|
||||
api.mockResolvedValue({ id: GOAL_ID, state: 'active', revision: 1 });
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'create',
|
||||
'--objective',
|
||||
'Deliver durable controls.',
|
||||
'--acceptance',
|
||||
'REST passes',
|
||||
'CLI passes',
|
||||
'--requirement',
|
||||
'focused-tests|test|Focused tests pass.',
|
||||
'--root-task',
|
||||
'task-865',
|
||||
'--mode',
|
||||
'automatic',
|
||||
'--max-turns',
|
||||
'20',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/goals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
objective: 'Deliver durable controls.',
|
||||
constraints: [],
|
||||
acceptanceCriteria: ['REST passes', 'CLI passes'],
|
||||
root: { kind: 'task', taskId: 'task-865' },
|
||||
continuation: { mode: 'automatic', maxTurns: 20 },
|
||||
completionRequirements: [
|
||||
{
|
||||
id: 'focused-tests',
|
||||
verificationKind: 'test',
|
||||
description: 'Focused tests pass.',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('transitions with exact revision and structured completion evidence', async () => {
|
||||
api.mockResolvedValue({ id: GOAL_ID, state: 'complete', revision: 3 });
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'transition',
|
||||
GOAL_ID,
|
||||
'--revision',
|
||||
'2',
|
||||
'--state',
|
||||
'complete',
|
||||
'--reason',
|
||||
'All verification passed.',
|
||||
'--evidence-json',
|
||||
'[{"requirementId":"focused-tests","evidenceId":"ci-1082","summary":"Passed."}]',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(`/api/goals/${GOAL_ID}/transition`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: 2,
|
||||
state: 'complete',
|
||||
reason: 'All verification passed.',
|
||||
blocker: undefined,
|
||||
completionEvidence: [
|
||||
{
|
||||
requirementId: 'focused-tests',
|
||||
evidenceId: 'ci-1082',
|
||||
summary: 'Passed.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('links a run to the continuation chain', async () => {
|
||||
api.mockResolvedValue({ id: GOAL_ID, state: 'active', revision: 4 });
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'link-run',
|
||||
GOAL_ID,
|
||||
'--revision',
|
||||
'3',
|
||||
'--task',
|
||||
'task-865',
|
||||
'--attempt',
|
||||
'attempt-3',
|
||||
'--conversation',
|
||||
'conversation-3',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(`/api/goals/${GOAL_ID}/runs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: 3,
|
||||
taskId: 'task-865',
|
||||
attemptId: 'attempt-3',
|
||||
conversationId: 'conversation-3',
|
||||
}),
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
|
||||
it('approves and dispatches a bounded conversation rollover', async () => {
|
||||
api.mockResolvedValue({
|
||||
action: 'dispatched',
|
||||
goal: { id: GOAL_ID, revision: 8 },
|
||||
continuation: {
|
||||
id: 'continuation-rollover',
|
||||
kind: 'rollover',
|
||||
state: 'dispatched',
|
||||
resultAttemptId: 'attempt-8',
|
||||
},
|
||||
});
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerGoalCommands(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'vk',
|
||||
'goals',
|
||||
'rollover',
|
||||
GOAL_ID,
|
||||
'--revision',
|
||||
'7',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(api).toHaveBeenCalledWith(`/api/goals/${GOAL_ID}/rollover`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: 7,
|
||||
}),
|
||||
});
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
action: 'dispatched',
|
||||
continuation: { kind: 'rollover', resultAttemptId: 'attempt-8' },
|
||||
});
|
||||
output.mockRestore();
|
||||
});
|
||||
});
|
||||
245
cli/src/__tests__/prompts.test.ts
Normal file
245
cli/src/__tests__/prompts.test.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { PromptTemplate } from '@veritas-kanban/shared';
|
||||
import { runPromptTemplateImport } from '../commands/prompts.js';
|
||||
|
||||
interface ApiCall {
|
||||
method: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function makeTemplate(
|
||||
input: Partial<PromptTemplate> & Pick<PromptTemplate, 'id' | 'name' | 'content'>
|
||||
): PromptTemplate {
|
||||
return {
|
||||
category: 'agent',
|
||||
variables: [],
|
||||
created: '2026-06-04T08:00:00.000Z',
|
||||
updated: '2026-06-04T08:00:00.000Z',
|
||||
currentVersionId: `${input.id}_v1`,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function promptRegistryFetch(initialTemplates: PromptTemplate[] = []) {
|
||||
const templates = [...initialTemplates];
|
||||
const calls: ApiCall[] = [];
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
const method = init?.method ?? 'GET';
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
calls.push({ method, path: url.pathname, body });
|
||||
|
||||
if (url.pathname === '/api/prompt-registry' && method === 'GET') {
|
||||
return jsonResponse(templates);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/prompt-registry' && method === 'POST') {
|
||||
templates.push(makeTemplate(body as PromptTemplate));
|
||||
return jsonResponse(templates.at(-1), 201);
|
||||
}
|
||||
|
||||
const patchMatch = url.pathname.match(/^\/api\/prompt-registry\/([^/]+)$/);
|
||||
if (patchMatch && method === 'PATCH') {
|
||||
const id = patchMatch[1] as string;
|
||||
const index = templates.findIndex((template) => template.id === id);
|
||||
if (index === -1) return jsonResponse({ error: 'Template not found' }, 404);
|
||||
templates[index] = {
|
||||
...templates[index],
|
||||
...(body as Partial<PromptTemplate>),
|
||||
updated: '2026-06-04T08:05:00.000Z',
|
||||
};
|
||||
return jsonResponse(templates[index]);
|
||||
}
|
||||
|
||||
return jsonResponse({ error: `No fixture for ${method} ${url.pathname}` }, 404);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
return { fetch: fetchMock, calls, templates };
|
||||
}
|
||||
|
||||
describe('vk prompts import', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'vk-prompts-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('plans filename-derived templates deterministically in dry-run mode', async () => {
|
||||
await writeFile(
|
||||
path.join(tmpDir, 'worker-handoff.md'),
|
||||
'# Worker Handoff\n\nHello {{agent_name}}.',
|
||||
'utf-8'
|
||||
);
|
||||
await writeFile(path.join(tmpDir, 'README.md'), '# Registry docs', 'utf-8');
|
||||
const api = promptRegistryFetch();
|
||||
|
||||
const report = await runPromptTemplateImport(
|
||||
{
|
||||
sourceDir: tmpDir,
|
||||
apiBase: 'http://vk.test',
|
||||
dryRun: true,
|
||||
},
|
||||
{ fetch: api.fetch, env: {} }
|
||||
);
|
||||
|
||||
expect(report.counts).toMatchObject({ total: 1, created: 1, updated: 0, unchanged: 0 });
|
||||
expect(report.items).toEqual([
|
||||
{
|
||||
status: 'created',
|
||||
file: 'worker-handoff.md',
|
||||
id: 'worker-handoff',
|
||||
name: 'Worker Handoff',
|
||||
},
|
||||
]);
|
||||
expect(api.calls.map((call) => `${call.method} ${call.path}`)).toEqual([
|
||||
'GET /api/prompt-registry',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates templates with stable frontmatter IDs when not dry-running', async () => {
|
||||
await writeFile(
|
||||
path.join(tmpDir, 'review.md'),
|
||||
[
|
||||
'---',
|
||||
'id: cross-model-review',
|
||||
'name: Cross Model Review',
|
||||
'category: evaluation',
|
||||
'description: Opposite-model review checklist',
|
||||
'---',
|
||||
'# Ignored Heading',
|
||||
'',
|
||||
'Review {{task_id}}.',
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
const api = promptRegistryFetch();
|
||||
|
||||
const report = await runPromptTemplateImport(
|
||||
{
|
||||
sourceDir: tmpDir,
|
||||
apiBase: 'http://vk.test',
|
||||
},
|
||||
{ fetch: api.fetch, env: {} }
|
||||
);
|
||||
|
||||
expect(report.counts).toMatchObject({ created: 1, updated: 0, conflict: 0, malformed: 0 });
|
||||
expect(api.calls.map((call) => `${call.method} ${call.path}`)).toEqual([
|
||||
'GET /api/prompt-registry',
|
||||
'POST /api/prompt-registry',
|
||||
]);
|
||||
expect(api.calls[1]?.body).toMatchObject({
|
||||
id: 'cross-model-review',
|
||||
name: 'Cross Model Review',
|
||||
category: 'evaluation',
|
||||
description: 'Opposite-model review checklist',
|
||||
content: '# Ignored Heading\n\nReview {{task_id}}.',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports runtime drift as a conflict unless force mode is enabled', async () => {
|
||||
await writeFile(
|
||||
path.join(tmpDir, 'bug-triage.md'),
|
||||
'# Bug Triage\n\nNew content for {{issue}}.',
|
||||
'utf-8'
|
||||
);
|
||||
const existing = makeTemplate({
|
||||
id: 'bug-triage',
|
||||
name: 'Bug Triage',
|
||||
content: '# Bug Triage\n\nOld content for {{issue}}.',
|
||||
});
|
||||
|
||||
const conflict = await runPromptTemplateImport(
|
||||
{
|
||||
sourceDir: tmpDir,
|
||||
apiBase: 'http://vk.test',
|
||||
dryRun: true,
|
||||
},
|
||||
{ fetch: promptRegistryFetch([existing]).fetch, env: {} }
|
||||
);
|
||||
|
||||
expect(conflict.counts).toMatchObject({ conflict: 1, updated: 0 });
|
||||
expect(conflict.items[0]).toMatchObject({
|
||||
status: 'conflict',
|
||||
id: 'bug-triage',
|
||||
changedFields: ['content'],
|
||||
});
|
||||
|
||||
const api = promptRegistryFetch([existing]);
|
||||
const forced = await runPromptTemplateImport(
|
||||
{
|
||||
sourceDir: tmpDir,
|
||||
apiBase: 'http://vk.test',
|
||||
force: true,
|
||||
},
|
||||
{ fetch: api.fetch, env: {} }
|
||||
);
|
||||
|
||||
expect(forced.counts).toMatchObject({ conflict: 0, updated: 1 });
|
||||
expect(api.calls.map((call) => `${call.method} ${call.path}`)).toEqual([
|
||||
'GET /api/prompt-registry',
|
||||
'PATCH /api/prompt-registry/bug-triage',
|
||||
]);
|
||||
expect(api.calls[1]?.body).toMatchObject({
|
||||
content: '# Bug Triage\n\nNew content for {{issue}}.',
|
||||
changelog: 'Sync from bug-triage.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unchanged, malformed frontmatter, and name conflicts without writes', async () => {
|
||||
await writeFile(path.join(tmpDir, 'same.md'), '# Same Prompt\n\nSame body.', 'utf-8');
|
||||
await writeFile(path.join(tmpDir, 'bad.md'), '---\nid bad\n---\nBad body.', 'utf-8');
|
||||
await writeFile(path.join(tmpDir, 'duplicate.md'), '# Existing Prompt\n\nNew body.', 'utf-8');
|
||||
const api = promptRegistryFetch([
|
||||
makeTemplate({
|
||||
id: 'same',
|
||||
name: 'Same Prompt',
|
||||
content: '# Same Prompt\n\nSame body.',
|
||||
}),
|
||||
makeTemplate({
|
||||
id: 'runtime-existing',
|
||||
name: 'Existing Prompt',
|
||||
content: '# Existing Prompt\n\nRuntime body.',
|
||||
}),
|
||||
]);
|
||||
|
||||
const report = await runPromptTemplateImport(
|
||||
{
|
||||
sourceDir: tmpDir,
|
||||
apiBase: 'http://vk.test',
|
||||
},
|
||||
{ fetch: api.fetch, env: {} }
|
||||
);
|
||||
|
||||
expect(report.counts).toMatchObject({
|
||||
total: 3,
|
||||
unchanged: 1,
|
||||
malformed: 1,
|
||||
conflict: 1,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
});
|
||||
expect(report.items.map((item) => item.status).sort()).toEqual([
|
||||
'conflict',
|
||||
'malformed',
|
||||
'unchanged',
|
||||
]);
|
||||
expect(api.calls.map((call) => `${call.method} ${call.path}`)).toEqual([
|
||||
'GET /api/prompt-registry',
|
||||
]);
|
||||
});
|
||||
});
|
||||
304
cli/src/__tests__/snapshot.test.ts
Normal file
304
cli/src/__tests__/snapshot.test.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { buildRuntimeSnapshot, formatRuntimeSnapshotMarkdown } from '../commands/snapshot.js';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, headers: HeadersInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotFetch(routes: Record<string, Response>) {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const response = routes[`${url.pathname}${url.search}`] ?? routes[url.pathname];
|
||||
if (!response) {
|
||||
return jsonResponse({ error: `No fixture for ${url.pathname}` }, 404);
|
||||
}
|
||||
return response.clone();
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const baseRoutes: Record<string, Response> = {
|
||||
'/api/health': jsonResponse({ ok: true, version: '4.3.2', uptimeMs: 1000 }),
|
||||
'/api/config/repos': jsonResponse([
|
||||
{
|
||||
name: 'veritas-kanban',
|
||||
path: '/Users/bradgroux/Projects/veritas-kanban',
|
||||
defaultBranch: 'main',
|
||||
},
|
||||
]),
|
||||
'/api/projects': jsonResponse([
|
||||
{ id: 'vk', label: 'Veritas Kanban', isHidden: false },
|
||||
{ id: 'ops', label: 'Ops', isHidden: true },
|
||||
]),
|
||||
'/api/sprints': jsonResponse([{ id: 'v5', label: 'v5 GA', isHidden: false }]),
|
||||
'/api/config/agents': jsonResponse([
|
||||
{
|
||||
type: 'codex',
|
||||
name: 'Codex',
|
||||
command: '/Users/bradgroux/.local/bin/codex --api-key sk-testsecret1234567890',
|
||||
enabled: true,
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
},
|
||||
{
|
||||
type: 'hermes',
|
||||
name: 'Hermes',
|
||||
command: 'hermes',
|
||||
enabled: false,
|
||||
provider: 'hermes',
|
||||
model: 'planner',
|
||||
},
|
||||
]),
|
||||
'/api/agent/status': jsonResponse({
|
||||
status: 'working',
|
||||
activeTask: 'task-1',
|
||||
activeTaskTitle: 'Do not export active task title',
|
||||
subAgentCount: 1,
|
||||
activeAgents: [
|
||||
{
|
||||
agent: 'codex',
|
||||
status: 'working',
|
||||
taskId: 'task-1',
|
||||
taskTitle: 'Do not export active agent title',
|
||||
startedAt: '2026-06-04T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
agent: 'reviewer',
|
||||
status: 'thinking',
|
||||
taskId: 'task-2',
|
||||
taskTitle: 'Do not export reviewer title',
|
||||
startedAt: '2026-06-04T08:01:00.000Z',
|
||||
},
|
||||
],
|
||||
lastUpdated: '2026-06-04T08:02:00.000Z',
|
||||
}),
|
||||
'/api/agents/routing': jsonResponse({
|
||||
enabled: true,
|
||||
defaultAgent: 'codex',
|
||||
defaultModel: 'gpt-5',
|
||||
fallbackOnFailure: true,
|
||||
rules: [
|
||||
{
|
||||
id: 'rule-codex',
|
||||
name: 'Codex default',
|
||||
agent: 'codex',
|
||||
model: 'gpt-5',
|
||||
fallback: 'hermes',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
'/api/settings/features': jsonResponse({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
webhookUrl: 'https://hooks.example.test/path/secret-token',
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
onCompleted: {
|
||||
enabled: true,
|
||||
webhook: 'https://hooks.example.test/hook/private-token',
|
||||
},
|
||||
},
|
||||
squadWebhook: {
|
||||
enabled: true,
|
||||
mode: 'openclaw',
|
||||
openclawGatewayUrl: 'http://127.0.0.1:18789/gateway/private-path',
|
||||
},
|
||||
}),
|
||||
'/api/prompt-registry': jsonResponse([
|
||||
{ id: 'build_plan', name: 'Build Plan', category: 'planning', version: 3 },
|
||||
]),
|
||||
'/api/tasks?view=summary': jsonResponse(
|
||||
[
|
||||
{
|
||||
id: 'task-1',
|
||||
status: 'todo',
|
||||
priority: 'high',
|
||||
type: 'feature',
|
||||
title: 'Do not export this title',
|
||||
description: 'Contains sk-testsecret1234567890 and vk_private1234567890',
|
||||
},
|
||||
{ id: 'task-2', status: 'todo', priority: 'low', type: 'bug' },
|
||||
{ id: 'task-3', status: 'done', priority: 'high', type: 'feature' },
|
||||
],
|
||||
200,
|
||||
{ 'x-veritas-task-identity-conflicts': '2' }
|
||||
),
|
||||
'/api/maintenance/summary': jsonResponse({
|
||||
mode: 'local',
|
||||
storageMode: 'sqlite',
|
||||
health: [
|
||||
{
|
||||
id: 'storage',
|
||||
state: 'fail',
|
||||
detail: 'Path /Users/bradgroux/Projects/veritas-kanban/server/storage failed',
|
||||
},
|
||||
{ id: 'logs', state: 'warn', detail: 'Token sk-testsecret1234567890 was redacted' },
|
||||
],
|
||||
logs: [{ id: 'server', exists: true, redacted: true }],
|
||||
}),
|
||||
};
|
||||
|
||||
describe('vk snapshot', () => {
|
||||
it('builds a redacted runtime snapshot with core support sections', async () => {
|
||||
const snapshot = await buildRuntimeSnapshot(
|
||||
{
|
||||
apiBase: 'http://vk.test',
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
fetch: snapshotFetch(baseRoutes),
|
||||
env: {},
|
||||
now: () => new Date('2026-06-04T08:00:00.000Z'),
|
||||
gitSha: async () => 'abc1234',
|
||||
cliVersion: async () => '4.3.2',
|
||||
}
|
||||
);
|
||||
|
||||
expect(snapshot.redacted).toBe(true);
|
||||
expect(snapshot.app).toMatchObject({
|
||||
cliVersion: '4.3.2',
|
||||
serverVersion: '4.3.2',
|
||||
gitSha: 'abc1234',
|
||||
apiBase: 'http://vk.test',
|
||||
apiReachable: true,
|
||||
});
|
||||
expect(snapshot.projects.repos[0]).toMatchObject({
|
||||
name: 'veritas-kanban',
|
||||
path: '[redacted path]',
|
||||
defaultBranch: 'main',
|
||||
});
|
||||
expect(snapshot.agents).toMatchObject({ total: 2, enabled: 1 });
|
||||
expect(snapshot.agents.status).toMatchObject({
|
||||
state: 'working',
|
||||
subAgentCount: 1,
|
||||
activeAgents: 2,
|
||||
activeAgentsByStatus: { working: 1, thinking: 1 },
|
||||
activeTaskPresent: true,
|
||||
lastUpdated: '2026-06-04T08:02:00.000Z',
|
||||
});
|
||||
expect(snapshot.routing).toMatchObject({
|
||||
enabled: true,
|
||||
defaultAgent: 'codex',
|
||||
defaultModel: 'gpt-5',
|
||||
fallbackOnFailure: true,
|
||||
ruleCount: 1,
|
||||
});
|
||||
expect(snapshot.prompts.count).toBe(1);
|
||||
expect(snapshot.tasks).toMatchObject({
|
||||
total: 3,
|
||||
byStatus: { todo: 2, done: 1 },
|
||||
byPriority: { high: 2, low: 1 },
|
||||
byType: { feature: 2, bug: 1 },
|
||||
duplicateIdentityConflicts: 2,
|
||||
});
|
||||
expect(snapshot.notifications).toMatchObject({
|
||||
notificationsEnabled: true,
|
||||
notificationWebhookConfigured: true,
|
||||
squadWebhookEnabled: true,
|
||||
squadWebhookMode: 'openclaw',
|
||||
squadWebhookDestinationConfigured: true,
|
||||
lifecycleHooksEnabled: true,
|
||||
lifecycleHookActions: 1,
|
||||
});
|
||||
expect(snapshot.health.failingChecks).toHaveLength(1);
|
||||
expect(snapshot.health.warningChecks).toHaveLength(1);
|
||||
expect(snapshot.accessIssues).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not leak task content, local paths, tokens, or webhook URLs', async () => {
|
||||
const snapshot = await buildRuntimeSnapshot(
|
||||
{
|
||||
apiBase: 'https://vk.example.test/api/private-token?key=secret-token',
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
fetch: snapshotFetch(baseRoutes),
|
||||
env: { VK_API_KEY: 'vk_private1234567890' },
|
||||
now: () => new Date('2026-06-04T08:00:00.000Z'),
|
||||
gitSha: async () => 'abc1234',
|
||||
cliVersion: async () => '4.3.2',
|
||||
}
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
|
||||
expect(snapshot.app.apiBase).toBe('https://vk.example.test');
|
||||
expect(serialized).not.toContain('/Users/bradgroux');
|
||||
expect(serialized).not.toContain('secret-token');
|
||||
expect(serialized).not.toContain('private-token');
|
||||
expect(serialized).not.toContain('sk-testsecret1234567890');
|
||||
expect(serialized).not.toContain('vk_private1234567890');
|
||||
expect(serialized).not.toContain('Do not export this title');
|
||||
expect(serialized).not.toContain('Do not export active task title');
|
||||
expect(serialized).not.toContain('Do not export active agent title');
|
||||
expect(serialized).not.toContain('Contains sk-');
|
||||
expect(serialized).not.toContain('https://hooks.example.test/path');
|
||||
expect(serialized).toContain('[redacted-local-path]');
|
||||
expect(serialized).toContain('sk-[REDACTED]');
|
||||
});
|
||||
|
||||
it('records partial API failures as sanitized access issues', async () => {
|
||||
const routes = {
|
||||
...baseRoutes,
|
||||
'/api/maintenance/summary': jsonResponse(
|
||||
{
|
||||
error: {
|
||||
message:
|
||||
'Failed at https://hooks.example.test/path/private-token for /Users/bradgroux/app',
|
||||
},
|
||||
},
|
||||
500
|
||||
),
|
||||
};
|
||||
|
||||
const snapshot = await buildRuntimeSnapshot(
|
||||
{
|
||||
apiBase: 'http://vk.test',
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
fetch: snapshotFetch(routes),
|
||||
env: {},
|
||||
now: () => new Date('2026-06-04T08:00:00.000Z'),
|
||||
gitSha: async () => null,
|
||||
cliVersion: async () => '4.3.2',
|
||||
}
|
||||
);
|
||||
|
||||
expect(snapshot.health.maintenanceAvailable).toBe(false);
|
||||
expect(snapshot.accessIssues).toEqual([
|
||||
{
|
||||
section: 'maintenance',
|
||||
status: 500,
|
||||
error: 'Failed at https://hooks.example.test/[redacted] for [redacted-local-path]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats a paste-ready markdown snapshot', async () => {
|
||||
const snapshot = await buildRuntimeSnapshot(
|
||||
{ apiBase: 'http://vk.test', timeoutMs: 1000 },
|
||||
{
|
||||
fetch: snapshotFetch(baseRoutes),
|
||||
env: {},
|
||||
now: () => new Date('2026-06-04T08:00:00.000Z'),
|
||||
gitSha: async () => 'abc1234',
|
||||
cliVersion: async () => '4.3.2',
|
||||
}
|
||||
);
|
||||
|
||||
const markdown = formatRuntimeSnapshotMarkdown(snapshot);
|
||||
|
||||
expect(markdown).toContain('# Veritas Runtime Snapshot');
|
||||
expect(markdown).toContain('- CLI version: 4.3.2');
|
||||
expect(markdown).toContain('- Total: 3');
|
||||
expect(markdown).toContain('- Global status: working');
|
||||
expect(markdown).toContain('- Runtime templates: 1');
|
||||
expect(markdown).not.toContain('/Users/bradgroux');
|
||||
expect(markdown).not.toContain('secret-token');
|
||||
});
|
||||
});
|
||||
119
cli/src/__tests__/sqlite-maintenance.test.ts
Normal file
119
cli/src/__tests__/sqlite-maintenance.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { registerSqliteCommands } from '../commands/sqlite.js';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('vk sqlite journal', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
function program(): Command {
|
||||
const command = new Command();
|
||||
command.exitOverride();
|
||||
registerSqliteCommands(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
it('previews the exact target and emits stable JSON without ANSI output', async () => {
|
||||
const preview = {
|
||||
schemaVersion: 'sqlite-journal-preview/v1',
|
||||
id: '98af3a58-1b8b-41b3-8162-dfdb1f257740',
|
||||
token: 'a'.repeat(64),
|
||||
createdAt: '2026-07-15T00:00:00.000Z',
|
||||
expiresAt: '2026-07-15T00:15:00.000Z',
|
||||
targetMode: 'delete',
|
||||
currentMode: 'wal',
|
||||
databaseLocation: 'configured',
|
||||
filesystemType: 'apfs',
|
||||
filesystemPosture: 'supported-local',
|
||||
ownershipState: 'server-open',
|
||||
activeConnectionCount: 4,
|
||||
sidecars: [],
|
||||
backupLocation: 'adjacent-secure-directory',
|
||||
singleHost: true,
|
||||
overrideRequired: true,
|
||||
risks: ['restart required'],
|
||||
restartRequired: true,
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ role: 'admin', isLocalhost: false, permissions: ['*'] })
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(preview));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
await program().parseAsync(
|
||||
[
|
||||
'sqlite',
|
||||
'journal',
|
||||
'preview',
|
||||
'--target',
|
||||
'delete',
|
||||
'--single-host',
|
||||
'--override-reason',
|
||||
'Approved rollback mode',
|
||||
'--expires-at',
|
||||
'2026-07-16T00:00:00.000Z',
|
||||
'--json',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
'http://localhost:3001/api/maintenance/sqlite/journal/preview'
|
||||
);
|
||||
const request = fetchMock.mock.calls[1][1] as RequestInit;
|
||||
expect(JSON.parse(String(request.body))).toEqual({
|
||||
targetMode: 'delete',
|
||||
singleHost: true,
|
||||
overrideReason: 'Approved rollback mode',
|
||||
expiresAt: '2026-07-16T00:00:00.000Z',
|
||||
});
|
||||
const rendered = String(output.mock.calls[0][0]);
|
||||
expect(JSON.parse(rendered)).toEqual(preview);
|
||||
expect(rendered).not.toContain(String.fromCharCode(27));
|
||||
});
|
||||
|
||||
it('refuses apply locally without matching confirmation and acknowledgement', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await program().parseAsync(
|
||||
[
|
||||
'sqlite',
|
||||
'journal',
|
||||
'apply',
|
||||
'--preview-id',
|
||||
'98af3a58-1b8b-41b3-8162-dfdb1f257740',
|
||||
'--preview-token',
|
||||
'a'.repeat(64),
|
||||
'--confirm',
|
||||
'60f2dd7e-35e8-4630-b0cc-687a4c013677',
|
||||
],
|
||||
{ from: 'user' }
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
65
cli/src/__tests__/task-execution-policy.test.ts
Normal file
65
cli/src/__tests__/task-execution-policy.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
|
||||
const { mockApi, mockFindTask } = vi.hoisted(() => ({
|
||||
mockApi: vi.fn(),
|
||||
mockFindTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/api.js', () => ({ api: mockApi }));
|
||||
vi.mock('../utils/find.js', () => ({ findTask: mockFindTask }));
|
||||
|
||||
import { registerTaskCommands } from '../commands/tasks.js';
|
||||
|
||||
describe('vk task execution policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApi.mockResolvedValue({
|
||||
id: 'task_1',
|
||||
title: 'Policy task',
|
||||
type: 'code',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
created: '2026-07-16T00:00:00.000Z',
|
||||
updated: '2026-07-16T00:00:00.000Z',
|
||||
});
|
||||
mockFindTask.mockResolvedValue({ id: 'task_1' });
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('forwards a task commit policy on create', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerTaskCommands(program);
|
||||
|
||||
await program.parseAsync(['create', 'Policy task', '--commit-policy', 'forbidden', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: 'Policy task',
|
||||
type: 'code',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
executionPolicy: { commitPolicy: 'forbidden' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a task commit policy on update', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerTaskCommands(program);
|
||||
|
||||
await program.parseAsync(['update', 'task_1', '--commit-policy', 'required', '--json'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/tasks/task_1', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ executionPolicy: { commitPolicy: 'required' } }),
|
||||
});
|
||||
});
|
||||
});
|
||||
846
cli/src/commands/acp.ts
Normal file
846
cli/src/commands/acp.ts
Normal file
|
|
@ -0,0 +1,846 @@
|
|||
import { Buffer } from 'node:buffer';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import type { Command } from 'commander';
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpJsonRpcId,
|
||||
AcpJsonRpcMessage,
|
||||
AcpPromptResponse,
|
||||
ClientAuthContext,
|
||||
RunApprovalRequest,
|
||||
RunEventEnvelope,
|
||||
RunEventPage,
|
||||
Task,
|
||||
TaskAttempt,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { ACP_PROTOCOL_VERSION } from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
const MAX_PROTOCOL_LINE_BYTES = 1024 * 1024;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 250;
|
||||
const ACP_SERVER_NAME = 'Veritas Kanban';
|
||||
const ACP_SERVER_VERSION = '6.0.0';
|
||||
|
||||
export const ACP_SERVER_METHODS = [
|
||||
'initialize',
|
||||
'session/new',
|
||||
'session/load',
|
||||
'session/resume',
|
||||
'session/prompt',
|
||||
'session/cancel',
|
||||
] as const;
|
||||
|
||||
export type AcpApiClient = <T>(requestPath: string, options?: RequestInit) => Promise<T>;
|
||||
|
||||
export interface AcpServerViewOptions {
|
||||
api?: AcpApiClient;
|
||||
write: (record: AcpJsonRpcMessage) => void;
|
||||
boundTaskId?: string;
|
||||
agent?: string;
|
||||
profileId?: string;
|
||||
pollIntervalMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
interface ViewSession {
|
||||
sessionId: string;
|
||||
taskId: string;
|
||||
cwd: string;
|
||||
attemptId?: string;
|
||||
cursor: number;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
interface MethodOutcome {
|
||||
result: unknown;
|
||||
afterResponse?: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface PendingClientRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export interface AcpStatus {
|
||||
schemaVersion: 'veritas-acp-server-status/v1';
|
||||
protocolVersion: typeof ACP_PROTOCOL_VERSION;
|
||||
transport: 'stdio';
|
||||
ready: boolean;
|
||||
methods: readonly string[];
|
||||
durableRuns: true;
|
||||
providerNeutral: true;
|
||||
role?: string;
|
||||
workspaceId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class AcpServerView {
|
||||
private readonly apiClient: AcpApiClient;
|
||||
private readonly writeRecord: (record: AcpJsonRpcMessage) => void;
|
||||
private readonly boundTaskId?: string;
|
||||
private readonly agent?: string;
|
||||
private readonly profileId?: string;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly now: () => number;
|
||||
private readonly sessions = new Map<string, ViewSession>();
|
||||
private readonly pendingClientRequests = new Map<string | number, PendingClientRequest>();
|
||||
private nextClientRequestId = 1;
|
||||
private disconnected = false;
|
||||
|
||||
constructor(options: AcpServerViewOptions) {
|
||||
this.apiClient = options.api ?? api;
|
||||
this.writeRecord = (record) => {
|
||||
if (!this.disconnected) options.write(record);
|
||||
};
|
||||
this.boundTaskId = options.boundTaskId;
|
||||
this.agent = options.agent;
|
||||
this.profileId = options.profileId;
|
||||
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
for (const pending of this.pendingClientRequests.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('ACP client disconnected.'));
|
||||
}
|
||||
this.pendingClientRequests.clear();
|
||||
}
|
||||
|
||||
async acceptLine(line: string): Promise<void> {
|
||||
if (Buffer.byteLength(line, 'utf8') > MAX_PROTOCOL_LINE_BYTES) {
|
||||
this.writeError(null, -32600, 'ACP record exceeds the 1 MiB limit.');
|
||||
return;
|
||||
}
|
||||
let record: unknown;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
this.writeError(null, -32700, 'Invalid JSON.');
|
||||
return;
|
||||
}
|
||||
if (!isRecord(record) || record.jsonrpc !== '2.0') {
|
||||
this.writeError(null, -32600, 'Invalid JSON-RPC record.');
|
||||
return;
|
||||
}
|
||||
if ('result' in record || 'error' in record) {
|
||||
this.acceptClientResponse(record);
|
||||
return;
|
||||
}
|
||||
if (typeof record.method !== 'string') {
|
||||
this.writeError(
|
||||
validId(record.id) ? record.id : null,
|
||||
-32600,
|
||||
'JSON-RPC method is required.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const id = validId(record.id) ? record.id : undefined;
|
||||
if (id === undefined) {
|
||||
await this.handleNotification(record.method, record.params);
|
||||
return;
|
||||
}
|
||||
await this.handleRequest(id, record.method, record.params);
|
||||
}
|
||||
|
||||
private async handleRequest(id: AcpJsonRpcId, method: string, params: unknown): Promise<void> {
|
||||
try {
|
||||
const outcome = await this.dispatch(method, params);
|
||||
this.writeRecord({ jsonrpc: '2.0', id, result: outcome.result });
|
||||
if (outcome.afterResponse) void outcome.afterResponse();
|
||||
} catch (error) {
|
||||
const rpcError = error instanceof AcpViewError ? error : AcpViewError.internal(error);
|
||||
this.writeError(id, rpcError.code, rpcError.message, rpcError.data);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNotification(method: string, params: unknown): Promise<void> {
|
||||
if (method !== 'session/cancel') return;
|
||||
try {
|
||||
const input = requiredRecord(params, 'session/cancel params');
|
||||
const session = this.requireSession(requiredString(input.sessionId, 'sessionId'));
|
||||
if (!session.attemptId) return;
|
||||
await this.apiClient(
|
||||
`/api/agents/${encodeURIComponent(session.taskId)}/conversation/interrupt`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: session.attemptId }),
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// Notifications have no response. Durable run state remains authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(method: string, params: unknown): Promise<MethodOutcome> {
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
return this.initialize(params);
|
||||
case 'session/new':
|
||||
return this.newSession(params);
|
||||
case 'session/load':
|
||||
case 'session/resume':
|
||||
return this.loadSession(params);
|
||||
case 'session/prompt':
|
||||
return this.prompt(params);
|
||||
default:
|
||||
throw new AcpViewError(-32601, `Unsupported ACP method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async initialize(params: unknown): Promise<MethodOutcome> {
|
||||
const input = requiredRecord(params, 'initialize params');
|
||||
if (input.protocolVersion !== ACP_PROTOCOL_VERSION) {
|
||||
throw new AcpViewError(-32602, 'Unsupported ACP protocol version.', {
|
||||
expected: ACP_PROTOCOL_VERSION,
|
||||
received: input.protocolVersion,
|
||||
});
|
||||
}
|
||||
await this.apiClient<ClientAuthContext>('/api/auth/context');
|
||||
return {
|
||||
result: {
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
||||
mcpCapabilities: { http: false, sse: false },
|
||||
sessionCapabilities: { resume: {} },
|
||||
},
|
||||
agentInfo: {
|
||||
name: ACP_SERVER_NAME,
|
||||
title: ACP_SERVER_NAME,
|
||||
version: ACP_SERVER_VERSION,
|
||||
},
|
||||
_meta: {
|
||||
'veritas/providerNeutral': true,
|
||||
'veritas/durableRuns': true,
|
||||
'veritas/supportedMethods': ACP_SERVER_METHODS,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async newSession(params: unknown): Promise<MethodOutcome> {
|
||||
const input = requiredRecord(params, 'session/new params');
|
||||
rejectClientMcp(input.mcpServers);
|
||||
const metadata = optionalRecord(input._meta);
|
||||
const taskReference =
|
||||
this.boundTaskId ??
|
||||
optionalString(metadata['veritas/taskId']) ??
|
||||
optionalString(metadata.veritasTaskId);
|
||||
if (!taskReference) {
|
||||
throw new AcpViewError(
|
||||
-32602,
|
||||
'Bind the server with --task or pass _meta["veritas/taskId"].'
|
||||
);
|
||||
}
|
||||
const task = await this.resolveTask(taskReference);
|
||||
const cwd = requiredString(input.cwd, 'cwd');
|
||||
this.assertTaskWorktree(task, cwd);
|
||||
const sessionId = sessionIdForTask(task.id);
|
||||
this.sessions.set(sessionId, {
|
||||
sessionId,
|
||||
taskId: task.id,
|
||||
cwd,
|
||||
cursor: 0,
|
||||
busy: false,
|
||||
});
|
||||
return {
|
||||
result: {
|
||||
sessionId,
|
||||
_meta: { 'veritas/taskId': task.id },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadSession(params: unknown): Promise<MethodOutcome> {
|
||||
const input = requiredRecord(params, 'session/load params');
|
||||
rejectClientMcp(input.mcpServers);
|
||||
const sessionId = requiredString(input.sessionId, 'sessionId');
|
||||
const taskId = taskIdFromSession(sessionId);
|
||||
if (this.boundTaskId) {
|
||||
const boundTask = await this.resolveTask(this.boundTaskId);
|
||||
if (boundTask.id !== taskId) {
|
||||
throw new AcpViewError(-32003, 'ACP session is outside the bound task scope.');
|
||||
}
|
||||
}
|
||||
const task = await this.resolveTask(taskId);
|
||||
const cwd = requiredString(input.cwd, 'cwd');
|
||||
this.assertTaskWorktree(task, cwd);
|
||||
const metadata = optionalRecord(input._meta);
|
||||
const requestedAttemptId = optionalString(metadata['veritas/attemptId']);
|
||||
const attempt = requestedAttemptId
|
||||
? findTaskAttempt(task, requestedAttemptId)
|
||||
: latestTaskAttempt(task);
|
||||
if (requestedAttemptId && !attempt) {
|
||||
throw new AcpViewError(-32602, 'Requested Veritas attempt was not found.');
|
||||
}
|
||||
const afterSequence = optionalNonNegativeInteger(metadata['veritas/afterSequence']) ?? 0;
|
||||
const session: ViewSession = {
|
||||
sessionId,
|
||||
taskId: task.id,
|
||||
cwd,
|
||||
attemptId: attempt?.id,
|
||||
cursor: afterSequence,
|
||||
busy: false,
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
return {
|
||||
result: {},
|
||||
...(attempt
|
||||
? {
|
||||
afterResponse: async () => {
|
||||
await this.replayAvailable(session);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async prompt(params: unknown): Promise<MethodOutcome> {
|
||||
const input = requiredRecord(params, 'session/prompt params');
|
||||
const session = this.requireSession(requiredString(input.sessionId, 'sessionId'));
|
||||
if (session.busy) throw new AcpViewError(-32004, 'An ACP prompt is already active.');
|
||||
const message = promptText(input.prompt);
|
||||
session.busy = true;
|
||||
try {
|
||||
const task = await this.resolveTask(session.taskId);
|
||||
this.assertTaskWorktree(task, session.cwd);
|
||||
const status = await this.apiClient<{ running: boolean; attemptId?: string }>(
|
||||
`/api/agents/${encodeURIComponent(task.id)}/status`
|
||||
);
|
||||
if (status.running) {
|
||||
throw new AcpViewError(-32004, 'The scoped Veritas task already has an active turn.', {
|
||||
attemptId: status.attemptId,
|
||||
});
|
||||
}
|
||||
const source = session.attemptId
|
||||
? findTaskAttempt(task, session.attemptId)
|
||||
: latestTaskAttempt(task);
|
||||
const result = source?.conversation
|
||||
? await this.apiClient<{ attemptId: string }>(
|
||||
`/api/agents/${encodeURIComponent(task.id)}/conversation/follow-up`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sourceAttemptId: source.id,
|
||||
message,
|
||||
profileId: this.profileId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
: await this.apiClient<{ attemptId: string }>(
|
||||
`/api/agents/${encodeURIComponent(task.id)}/conversation/fresh`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
agent: this.profileId ? undefined : this.agent,
|
||||
profileId: this.profileId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
session.attemptId = result.attemptId;
|
||||
session.cursor = 0;
|
||||
const response = await this.streamUntilTerminal(session);
|
||||
return { result: response };
|
||||
} finally {
|
||||
session.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async streamUntilTerminal(session: ViewSession): Promise<AcpPromptResponse> {
|
||||
for (;;) {
|
||||
if (this.disconnected) {
|
||||
throw new AcpViewError(-32006, 'ACP client disconnected from the durable run.');
|
||||
}
|
||||
const page = await this.readEvents(session);
|
||||
for (const event of page.events) {
|
||||
const terminal = await this.projectEvent(session, event);
|
||||
session.cursor = Math.max(session.cursor, event.sequence);
|
||||
if (terminal) return terminal;
|
||||
}
|
||||
if (page.hasMore) continue;
|
||||
await delay(this.pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private async replayAvailable(session: ViewSession): Promise<void> {
|
||||
if (!session.attemptId) return;
|
||||
for (;;) {
|
||||
if (this.disconnected) return;
|
||||
const page = await this.readEvents(session);
|
||||
for (const event of page.events) {
|
||||
await this.projectEvent(session, event);
|
||||
session.cursor = Math.max(session.cursor, event.sequence);
|
||||
}
|
||||
if (!page.hasMore) return;
|
||||
}
|
||||
}
|
||||
|
||||
private readEvents(session: ViewSession): Promise<RunEventPage> {
|
||||
if (!session.attemptId) throw new AcpViewError(-32002, 'ACP session has no Veritas attempt.');
|
||||
const query = new URLSearchParams({
|
||||
afterSequence: String(session.cursor),
|
||||
limit: '250',
|
||||
});
|
||||
return this.apiClient<RunEventPage>(
|
||||
`/api/agents/${encodeURIComponent(session.taskId)}/attempts/${encodeURIComponent(
|
||||
session.attemptId
|
||||
)}/events?${query.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
private async projectEvent(
|
||||
session: ViewSession,
|
||||
event: RunEventEnvelope
|
||||
): Promise<AcpPromptResponse | undefined> {
|
||||
if (event.kind === 'approval.requested') {
|
||||
await this.relayApproval(session, event);
|
||||
return undefined;
|
||||
}
|
||||
const update = eventToSessionUpdate(event);
|
||||
if (update) {
|
||||
this.writeRecord({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId: session.sessionId,
|
||||
update,
|
||||
_meta: {
|
||||
'veritas/eventId': event.eventId,
|
||||
'veritas/sequence': event.sequence,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (event.kind === 'run.completed') {
|
||||
return {
|
||||
stopReason: 'end_turn',
|
||||
...(event.payload.usage ? { usage: event.payload.usage } : {}),
|
||||
_meta: { 'veritas/eventId': event.eventId, 'veritas/sequence': event.sequence },
|
||||
};
|
||||
}
|
||||
if (event.kind === 'run.interrupted') {
|
||||
return {
|
||||
stopReason: 'cancelled',
|
||||
_meta: { 'veritas/eventId': event.eventId, 'veritas/sequence': event.sequence },
|
||||
};
|
||||
}
|
||||
if (event.kind === 'run.failed') {
|
||||
return {
|
||||
stopReason: 'refusal',
|
||||
_meta: { 'veritas/eventId': event.eventId, 'veritas/sequence': event.sequence },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async relayApproval(session: ViewSession, event: RunEventEnvelope): Promise<void> {
|
||||
const approvalId = optionalString(event.payload.approvalId);
|
||||
if (!approvalId) throw new AcpViewError(-32005, 'Approval event is missing its durable ID.');
|
||||
const approval = await this.apiClient<RunApprovalRequest>(
|
||||
`/api/run-approvals/${encodeURIComponent(approvalId)}`
|
||||
);
|
||||
const expiresIn = Math.max(1, Date.parse(approval.expiresAt) - this.now());
|
||||
let response: unknown;
|
||||
let timedOut = false;
|
||||
try {
|
||||
response = await this.requestClient(
|
||||
'session/request_permission',
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: approval.providerRequestId,
|
||||
title: approval.action,
|
||||
name: approval.actionClass,
|
||||
kind: approval.actionClass,
|
||||
status: 'pending',
|
||||
rawInput: {
|
||||
details: approval.details,
|
||||
resourceScope: approval.resourceScope,
|
||||
riskClass: approval.riskClass,
|
||||
policyReason: approval.policyReason,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
|
||||
{ optionId: 'reject_once', name: 'Reject', kind: 'reject_once' },
|
||||
],
|
||||
},
|
||||
expiresIn
|
||||
);
|
||||
} catch {
|
||||
timedOut = true;
|
||||
}
|
||||
const outcome = optionalRecord(optionalRecord(response).outcome);
|
||||
const selected = optionalString(outcome.outcome);
|
||||
const selectedOption = optionalString(outcome.optionId);
|
||||
const approved = selected === 'selected' && selectedOption === 'allow_once';
|
||||
await this.apiClient(`/api/run-approvals/${encodeURIComponent(approval.id)}/decision`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
decision: approved ? 'approved' : 'rejected',
|
||||
expectedRevision: approval.revision,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: timedOut
|
||||
? 'ACP client permission request timed out.'
|
||||
: approved
|
||||
? 'ACP client selected allow once.'
|
||||
: 'ACP client denied or cancelled the request.',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private requestClient(method: string, params: unknown, timeoutMs: number): Promise<unknown> {
|
||||
const id = `vk-client-${this.nextClientRequestId++}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingClientRequests.delete(id);
|
||||
reject(new Error('ACP client request timed out.'));
|
||||
}, timeoutMs);
|
||||
this.pendingClientRequests.set(id, { resolve, reject, timer });
|
||||
this.writeRecord({ jsonrpc: '2.0', id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
private acceptClientResponse(record: Record<string, unknown>): void {
|
||||
if (!validId(record.id)) return;
|
||||
const pending = this.pendingClientRequests.get(record.id);
|
||||
if (!pending) return;
|
||||
this.pendingClientRequests.delete(record.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (isRecord(record.error)) {
|
||||
pending.reject(
|
||||
new Error(optionalString(record.error.message) ?? 'ACP client request failed.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.resolve(record.result);
|
||||
}
|
||||
|
||||
private async resolveTask(reference: string): Promise<Task> {
|
||||
const tasks = await this.apiClient<Task[]>('/api/tasks');
|
||||
const exact = tasks.find((task) => task.id === reference);
|
||||
const suffixMatches = exact ? [] : tasks.filter((task) => task.id.endsWith(reference));
|
||||
const task = exact ?? (suffixMatches.length === 1 ? suffixMatches[0] : undefined);
|
||||
if (!task) {
|
||||
throw new AcpViewError(
|
||||
-32003,
|
||||
suffixMatches.length > 1 ? 'Task reference is ambiguous.' : 'Task was not found.'
|
||||
);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private assertTaskWorktree(task: Task, cwd: string): void {
|
||||
const worktree = task.git?.worktreePath;
|
||||
if (!worktree) throw new AcpViewError(-32003, 'Task has no active worktree.');
|
||||
if (path.resolve(worktree) !== path.resolve(cwd)) {
|
||||
throw new AcpViewError(-32003, 'ACP cwd does not match the task worktree.');
|
||||
}
|
||||
}
|
||||
|
||||
private requireSession(sessionId: string): ViewSession {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) throw new AcpViewError(-32002, 'ACP session is not loaded in this process.');
|
||||
return session;
|
||||
}
|
||||
|
||||
private writeError(id: AcpJsonRpcId | null, code: number, message: string, data?: unknown): void {
|
||||
this.writeRecord({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
...(data === undefined ? {} : { data }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAcpStatus(apiClient: AcpApiClient = api): Promise<AcpStatus> {
|
||||
try {
|
||||
const context = await apiClient<ClientAuthContext>('/api/auth/context');
|
||||
return {
|
||||
schemaVersion: 'veritas-acp-server-status/v1',
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
transport: 'stdio',
|
||||
ready: true,
|
||||
methods: ACP_SERVER_METHODS,
|
||||
durableRuns: true,
|
||||
providerNeutral: true,
|
||||
role: context.role,
|
||||
workspaceId: context.workspaceId,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
schemaVersion: 'veritas-acp-server-status/v1',
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
transport: 'stdio',
|
||||
ready: false,
|
||||
methods: ACP_SERVER_METHODS,
|
||||
durableRuns: true,
|
||||
providerNeutral: true,
|
||||
error: boundedError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function runAcpStdioServer(options: {
|
||||
stdin?: Readable;
|
||||
stdout?: Writable;
|
||||
stderr?: Writable;
|
||||
boundTaskId?: string;
|
||||
agent?: string;
|
||||
profileId?: string;
|
||||
api?: AcpApiClient;
|
||||
pollIntervalMs?: number;
|
||||
}): void {
|
||||
const input = options.stdin ?? process.stdin;
|
||||
const output = options.stdout ?? process.stdout;
|
||||
const errors = options.stderr ?? process.stderr;
|
||||
const server = new AcpServerView({
|
||||
...(options.api ? { api: options.api } : {}),
|
||||
boundTaskId: options.boundTaskId,
|
||||
agent: options.agent,
|
||||
profileId: options.profileId,
|
||||
pollIntervalMs: options.pollIntervalMs,
|
||||
write: (record) => {
|
||||
output.write(`${JSON.stringify(record)}\n`);
|
||||
},
|
||||
});
|
||||
input.setEncoding('utf8');
|
||||
const lines = readline.createInterface({ input });
|
||||
lines.on('line', (line) => {
|
||||
void server.acceptLine(line);
|
||||
});
|
||||
lines.on('error', (error) => {
|
||||
errors.write(`ACP stdio input failed: ${boundedError(error)}\n`);
|
||||
});
|
||||
lines.on('close', () => {
|
||||
server.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAcpCommands(program: Command): void {
|
||||
const acp = program.command('acp').description('Agent Client Protocol server view');
|
||||
acp
|
||||
.command('status')
|
||||
.description('Report ACP server-view readiness')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const status = await readAcpStatus();
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
} else {
|
||||
console.log(
|
||||
`${status.ready ? 'ready' : 'not ready'}: ACP v${status.protocolVersion} over ${status.transport}`
|
||||
);
|
||||
if (status.error) console.error(status.error);
|
||||
}
|
||||
if (!status.ready) process.exitCode = 1;
|
||||
});
|
||||
|
||||
acp
|
||||
.command('serve')
|
||||
.description('Serve the provider-neutral Veritas ACP view over stdio')
|
||||
.requiredOption('--stdio', 'Use newline-delimited JSON-RPC over stdio')
|
||||
.option('--task <taskId>', 'Bind this process to one Veritas task')
|
||||
.option('--agent <agent>', 'Agent for a fresh scoped conversation')
|
||||
.option('--profile <profileId>', 'Agent profile for a fresh scoped conversation')
|
||||
.action(
|
||||
(options: { stdio: boolean; task?: string; agent?: string; profile?: string }): void => {
|
||||
runAcpStdioServer({
|
||||
boundTaskId: options.task,
|
||||
agent: options.agent,
|
||||
profileId: options.profile,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function eventToSessionUpdate(event: RunEventEnvelope): Record<string, unknown> | undefined {
|
||||
const summary = eventSummary(event);
|
||||
switch (event.kind) {
|
||||
case 'message.delta':
|
||||
return {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: summary },
|
||||
};
|
||||
case 'reasoning.delta':
|
||||
return {
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: summary },
|
||||
};
|
||||
case 'tool.started':
|
||||
return {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: event.itemId ?? event.eventId,
|
||||
title: summary,
|
||||
kind: optionalString(event.payload.actionClass) ?? 'other',
|
||||
status: 'in_progress',
|
||||
rawInput: event.payload.input,
|
||||
};
|
||||
case 'tool.completed':
|
||||
return {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: event.itemId ?? event.eventId,
|
||||
status: event.payload.success === false ? 'failed' : 'completed',
|
||||
content: summary ? [{ type: 'content', content: { type: 'text', text: summary } }] : [],
|
||||
};
|
||||
case 'progress':
|
||||
return {
|
||||
sessionUpdate: 'plan',
|
||||
entries: [{ content: summary, priority: 'medium', status: 'in_progress' }],
|
||||
};
|
||||
case 'approval.resolved':
|
||||
return {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: optionalString(event.payload.approvalId) ?? event.eventId,
|
||||
status: event.payload.status === 'approved' ? 'completed' : 'failed',
|
||||
content: summary ? [{ type: 'content', content: { type: 'text', text: summary } }] : [],
|
||||
};
|
||||
case 'run.failed':
|
||||
return summary
|
||||
? {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: summary },
|
||||
}
|
||||
: undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function eventSummary(event: RunEventEnvelope): string {
|
||||
return (
|
||||
optionalString(event.payload.summary) ??
|
||||
optionalString(event.payload.message) ??
|
||||
optionalString(event.payload.error) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function promptText(value: unknown): string {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new AcpViewError(-32602, 'session/prompt requires at least one text block.');
|
||||
}
|
||||
const blocks = value as AcpContentBlock[];
|
||||
const unsupported = blocks.find((block) => !isRecord(block) || block.type !== 'text');
|
||||
if (unsupported) {
|
||||
throw new AcpViewError(-32602, 'The Veritas ACP server view accepts text prompts only.');
|
||||
}
|
||||
const text = blocks
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (!text || text.length > 20_000) {
|
||||
throw new AcpViewError(-32602, 'ACP prompt must contain 1 to 20,000 text characters.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function rejectClientMcp(value: unknown): void {
|
||||
if (value === undefined) return;
|
||||
if (!Array.isArray(value)) throw new AcpViewError(-32602, 'mcpServers must be an array.');
|
||||
if (value.length > 0) {
|
||||
throw new AcpViewError(
|
||||
-32003,
|
||||
'ACP clients cannot override the immutable Veritas run tool catalog.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIdForTask(taskId: string): string {
|
||||
return `vkacp_${Buffer.from(taskId, 'utf8').toString('base64url')}`;
|
||||
}
|
||||
|
||||
function taskIdFromSession(sessionId: string): string {
|
||||
if (!/^vkacp_[A-Za-z0-9_-]+$/.test(sessionId)) {
|
||||
throw new AcpViewError(-32602, 'Invalid Veritas ACP session ID.');
|
||||
}
|
||||
try {
|
||||
const taskId = Buffer.from(sessionId.slice('vkacp_'.length), 'base64url').toString('utf8');
|
||||
if (!taskId || Buffer.byteLength(taskId, 'utf8') > 200) throw new Error('invalid');
|
||||
return taskId;
|
||||
} catch {
|
||||
throw new AcpViewError(-32602, 'Invalid Veritas ACP session ID.');
|
||||
}
|
||||
}
|
||||
|
||||
function latestTaskAttempt(task: Task): TaskAttempt | undefined {
|
||||
const attempts = [task.attempt, ...(task.attempts ?? [])].filter(
|
||||
(attempt): attempt is TaskAttempt => Boolean(attempt)
|
||||
);
|
||||
return attempts.sort((left, right) => {
|
||||
const leftTime = Date.parse(left.started ?? left.ended ?? '') || 0;
|
||||
const rightTime = Date.parse(right.started ?? right.ended ?? '') || 0;
|
||||
return rightTime - leftTime;
|
||||
})[0];
|
||||
}
|
||||
|
||||
function findTaskAttempt(task: Task, attemptId: string): TaskAttempt | undefined {
|
||||
return [task.attempt, ...(task.attempts ?? [])]
|
||||
.filter((attempt): attempt is TaskAttempt => Boolean(attempt))
|
||||
.find((attempt) => attempt.id === attemptId);
|
||||
}
|
||||
|
||||
function requiredRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new AcpViewError(-32602, `${label} must be an object.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
const result = optionalString(value);
|
||||
if (!result) throw new AcpViewError(-32602, `${label} must be a non-empty string.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function optionalNonNegativeInteger(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validId(value: unknown): value is AcpJsonRpcId {
|
||||
return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value));
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function boundedError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.replace(/[\r\n\t]+/g, ' ').slice(0, 1_000);
|
||||
}
|
||||
|
||||
class AcpViewError extends Error {
|
||||
constructor(
|
||||
readonly code: number,
|
||||
message: string,
|
||||
readonly data?: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static internal(error: unknown): AcpViewError {
|
||||
return new AcpViewError(-32000, boundedError(error));
|
||||
}
|
||||
}
|
||||
409
cli/src/commands/admission.ts
Normal file
409
cli/src/commands/admission.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import { Command } from 'commander';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
import type {
|
||||
AdmissionExecutionTreeCancellationResult,
|
||||
AdmissionLaunchSource,
|
||||
AdmissionQueueGetResponse,
|
||||
AdmissionQueueInspectionEntry,
|
||||
AdmissionQueueListResponse,
|
||||
AdmissionQueueState,
|
||||
AdmissionQueuedCancellationResult,
|
||||
AdmissionReservation,
|
||||
AdmissionReservationState,
|
||||
AdmissionScope,
|
||||
ExecutionTreeBudgetSummary,
|
||||
ExecutionTreeControl,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
interface AdmissionListResponse {
|
||||
generatedAt: string;
|
||||
reservations: AdmissionReservation[];
|
||||
}
|
||||
|
||||
export function registerAdmissionCommands(program: Command): void {
|
||||
const admission = program
|
||||
.command('admission')
|
||||
.description('Inspect durable execution admission reservations');
|
||||
|
||||
const queue = admission.command('queue').description('Inspect the durable admission queue');
|
||||
|
||||
queue
|
||||
.command('list')
|
||||
.description('List queued, leased, dispatched, or terminal admission entries')
|
||||
.option('--workspace <id>', 'Filter by workspace')
|
||||
.option('--root-objective <id>', 'Filter by execution-tree root objective')
|
||||
.option('--node <id>', 'Filter by execution-tree node')
|
||||
.option('--source <sources...>', 'Filter by launch source')
|
||||
.option('--state <states...>', 'Filter by queue state')
|
||||
.option('--priority <level>', 'Filter by raw numeric priority')
|
||||
.option('--limiting-scope <scopes...>', 'Filter by limiting scope')
|
||||
.option('--min-age <milliseconds>', 'Minimum queue age in milliseconds')
|
||||
.option('--max-age <milliseconds>', 'Maximum queue age in milliseconds')
|
||||
.option('--page <number>', 'Result page', '1')
|
||||
.option('--limit <count>', 'Maximum entries per page', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (options.workspace) query.set('workspaceId', options.workspace);
|
||||
if (options.rootObjective) query.set('rootObjectiveId', options.rootObjective);
|
||||
if (options.node) query.set('nodeId', options.node);
|
||||
for (const source of (options.source ?? []) as AdmissionLaunchSource[]) {
|
||||
query.append('source', source);
|
||||
}
|
||||
for (const state of (options.state ?? []) as AdmissionQueueState[]) {
|
||||
query.append('state', state);
|
||||
}
|
||||
if (options.priority) query.set('priority', options.priority);
|
||||
for (const scope of (options.limitingScope ?? []) as AdmissionScope[]) {
|
||||
query.append('limitingScope', scope);
|
||||
}
|
||||
if (options.minAge) query.set('minAgeMs', options.minAge);
|
||||
if (options.maxAge) query.set('maxAgeMs', options.maxAge);
|
||||
query.set('page', options.page);
|
||||
query.set('limit', options.limit);
|
||||
const result = await api<AdmissionQueueListResponse>(
|
||||
`/api/admission/queue?${query.toString()}`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.entries.length === 0) {
|
||||
console.log(chalk.dim('No admission queue entries matched.'));
|
||||
return;
|
||||
}
|
||||
for (const entry of result.entries) printQueueEntry(entry);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Conditional snapshot at ${result.generatedAt}; ${result.depth.global.current}/${result.depth.global.limit} global queue slots used.`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
queue
|
||||
.command('cancel <id>')
|
||||
.description('Cancel one queued launch before provider dispatch')
|
||||
.requiredOption('--reason <text>', 'Operator reason for cancellation')
|
||||
.option('--idempotency-key <key>', 'Stable identity for safe retries')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const result = await api<AdmissionQueuedCancellationResult>(
|
||||
`/api/admission/queue/${encodeURIComponent(id)}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: options.reason,
|
||||
idempotencyKey: options.idempotencyKey ?? `vk-cli:queue-cancel:${id}:${randomUUID()}`,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Cancelled queued launch ${result.queueEntry.id}`));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`State: ${result.queueEntry.state}; reservation released: ${result.reservationReleased}`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
queue
|
||||
.command('get <id>')
|
||||
.description('Inspect one admission queue entry')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const result = await api<AdmissionQueueGetResponse>(
|
||||
`/api/admission/queue/${encodeURIComponent(id)}`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printQueueEntry(result.entry, true);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Conditional snapshot at ${result.generatedAt}; capacity, policy, arrivals, and leases may change position.`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('list')
|
||||
.description('List active or recently terminal admission reservations')
|
||||
.option('--workspace <id>', 'Filter by workspace')
|
||||
.option('--task <id>', 'Filter by task')
|
||||
.option('--root-task <id>', 'Filter by root task')
|
||||
.option('--provider <provider>', 'Filter by provider')
|
||||
.option('--host <id>', 'Filter by launch host')
|
||||
.option('--workflow-run <id>', 'Filter by workflow run')
|
||||
.option('--workflow-step <id>', 'Filter by workflow step')
|
||||
.option('--root-reservation <id>', 'Filter by workflow root reservation')
|
||||
.option('--root-objective <id>', 'Filter by execution-tree root objective')
|
||||
.option('--node <id>', 'Filter by execution-tree node')
|
||||
.option('--parent-node <id>', 'Filter by execution-tree parent node')
|
||||
.option('--state <states...>', 'Filter by state (active, released, expired)')
|
||||
.option('--limit <count>', 'Maximum records', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (options.workspace) query.set('workspaceId', options.workspace);
|
||||
if (options.task) query.set('taskId', options.task);
|
||||
if (options.rootTask) query.set('rootTaskId', options.rootTask);
|
||||
if (options.provider) query.set('provider', options.provider);
|
||||
if (options.host) query.set('hostId', options.host);
|
||||
if (options.workflowRun) query.set('workflowRunId', options.workflowRun);
|
||||
if (options.workflowStep) query.set('workflowStepId', options.workflowStep);
|
||||
if (options.rootReservation) query.set('rootReservationId', options.rootReservation);
|
||||
if (options.rootObjective) query.set('rootObjectiveId', options.rootObjective);
|
||||
if (options.node) query.set('nodeId', options.node);
|
||||
if (options.parentNode) query.set('parentNodeId', options.parentNode);
|
||||
for (const state of (options.state ?? []) as AdmissionReservationState[]) {
|
||||
query.append('state', state);
|
||||
}
|
||||
query.set('limit', options.limit);
|
||||
const result = await api<AdmissionListResponse>(`/api/admission?${query.toString()}`);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.reservations.length === 0) {
|
||||
console.log(chalk.dim('No admission reservations matched.'));
|
||||
return;
|
||||
}
|
||||
for (const reservation of result.reservations) printReservation(reservation);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('tree <root-objective-id>')
|
||||
.description('Inspect aggregate usage and reservations for one execution tree')
|
||||
.option('--limit <count>', 'Maximum contributors', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (rootObjectiveId, options) => {
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: options.limit });
|
||||
const result = await api<ExecutionTreeBudgetSummary>(
|
||||
`/api/admission/tree/${encodeURIComponent(rootObjectiveId)}?${query.toString()}`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printExecutionTreeSummary(result);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('cancel-tree <root-objective-id>')
|
||||
.description('Cancel queued and verified running work for one execution tree')
|
||||
.requiredOption('--reason <text>', 'Operator reason for cancellation')
|
||||
.option('--idempotency-key <key>', 'Stable identity for safe retries')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (rootObjectiveId, options) => {
|
||||
try {
|
||||
const result = await api<AdmissionExecutionTreeCancellationResult>(
|
||||
`/api/admission/tree/${encodeURIComponent(rootObjectiveId)}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: options.reason,
|
||||
idempotencyKey:
|
||||
options.idempotencyKey ?? `vk-cli:tree-cancel:${rootObjectiveId}:${randomUUID()}`,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Cancelled execution tree ${result.rootObjectiveId}`));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Queued: ${result.queueEntriesCancelled}; interrupted: ${result.interruptedAttempts}; remaining verified runs: ${result.runningAttempts.length}`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('resume-tree <root-objective-id>')
|
||||
.description('Resume an eligible execution tree after its fan-out breaker pauses')
|
||||
.requiredOption('--reason <text>', 'Operator reason for resuming expansion')
|
||||
.option('--idempotency-key <key>', 'Stable identity for safe retries')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (rootObjectiveId, options) => {
|
||||
try {
|
||||
const result = await api<ExecutionTreeControl>(
|
||||
`/api/admission/tree/${encodeURIComponent(rootObjectiveId)}/resume`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reason: options.reason,
|
||||
idempotencyKey:
|
||||
options.idempotencyKey ?? `vk-cli:tree-resume:${rootObjectiveId}:${randomUUID()}`,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Resumed execution tree ${result.rootObjectiveId}`));
|
||||
console.log(chalk.dim(`Recorded: ${result.resumedAt}; reason: ${result.resumeReason}`));
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
admission
|
||||
.command('get <id>')
|
||||
.description('Inspect one admission reservation')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const result = await api<AdmissionReservation>(`/api/admission/${encodeURIComponent(id)}`);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printReservation(result, true);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function printQueueEntry(entry: AdmissionQueueInspectionEntry, verbose = false): void {
|
||||
console.log(
|
||||
`${entry.position ?? '-'} ${entry.state} ${chalk.bold(entry.id)} priority=${entry.rawPriority}->${entry.effectivePriority} readiness=${entry.readiness}`
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` source=${entry.launch.source} target=${entry.launch.target} age=${entry.ageMs}ms lease=${entry.lease.posture}`
|
||||
)
|
||||
);
|
||||
if (verbose) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` retries=${entry.retry.count}/${entry.retry.maximum} available=${entry.retry.availableAt}`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(` conditional=${entry.conditionalStartFactors.join(',') || 'capacity-recheck'}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printReservation(reservation: AdmissionReservation, verbose = false): void {
|
||||
const state =
|
||||
reservation.state === 'active'
|
||||
? chalk.green(reservation.state)
|
||||
: reservation.state === 'released'
|
||||
? chalk.blue(reservation.state)
|
||||
: chalk.yellow(reservation.state);
|
||||
console.log(
|
||||
`${state} ${chalk.bold(reservation.id)} task=${reservation.request.taskId} provider=${reservation.request.provider}`
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` workspace=${reservation.request.workspaceId} root=${reservation.request.rootTaskId} host=${reservation.request.hostId}`
|
||||
)
|
||||
);
|
||||
if (reservation.request.workflowRunId) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` workflow=${reservation.request.workflowRunId} step=${reservation.request.workflowStepId ?? 'root'} root-reservation=${reservation.request.rootReservationId ?? reservation.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (reservation.request.executionTree) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` objective=${reservation.request.executionTree.rootObjectiveId} node=${reservation.request.executionTree.nodeId} parent=${reservation.request.executionTree.parentNodeId ?? 'root'} edge=${reservation.request.executionTree.edge}`
|
||||
)
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` capacity runs=${reservation.request.requested.runSlots} processes=${reservation.request.requested.processSlots} memory=${reservation.request.requested.estimatedMemoryMb}MB`
|
||||
)
|
||||
);
|
||||
if (verbose || reservation.state === 'active') {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` attempt=${reservation.attemptId ?? 'unbound'} lease=${reservation.lease.expiresAt} revision=${reservation.revision}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (reservation.release) {
|
||||
console.log(
|
||||
chalk.dim(` released=${reservation.release.reason} at ${reservation.release.releasedAt}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printExecutionTreeSummary(summary: ExecutionTreeBudgetSummary): void {
|
||||
console.log(chalk.bold(`Execution tree ${summary.rootObjectiveId}`));
|
||||
if (summary.control) {
|
||||
const color = summary.control.state === 'resumed' ? chalk.green : chalk.red;
|
||||
console.log(
|
||||
color(
|
||||
` control=${summary.control.state} trigger=${summary.control.trigger} recorded=${summary.control.recordedAt}`
|
||||
)
|
||||
);
|
||||
console.log(chalk.dim(` reason=${summary.control.reason}`));
|
||||
if (summary.control.resumedAt) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` resumed=${summary.control.resumedAt} resume-reason=${summary.control.resumeReason}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
` committed tokens=${summary.committed.totalTokens} cost=$${summary.committed.costUsd.toFixed(4)} tools=${summary.committed.toolCalls} runtime=${summary.committed.runtimeSeconds}s retries=${summary.committed.retries} fan-out=${summary.committed.fanOut}`
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` reserved tokens=${summary.reserved.totalTokens} cost=$${summary.reserved.costUsd.toFixed(4)} tools=${summary.reserved.toolCalls} runtime=${summary.reserved.runtimeSeconds}s retries=${summary.reserved.retries} fan-out=${summary.reserved.fanOut}`
|
||||
)
|
||||
);
|
||||
for (const status of summary.policies) {
|
||||
console.log(
|
||||
`${status.blocksNextLaunch ? chalk.red('blocked') : chalk.green('available')} ${status.policy.name} (${status.policy.scope}:${status.policy.scopeId})`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` contributors=${summary.contributorCount}${summary.truncated ? ` (showing ${summary.contributors.length})` : ''}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function printError(error: unknown): void {
|
||||
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
|
@ -1,8 +1,190 @@
|
|||
import { Command } from 'commander';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { api } from '../utils/api.js';
|
||||
import { findTask } from '../utils/find.js';
|
||||
import type { Task } from '../utils/types.js';
|
||||
import type {
|
||||
AgentProfileExportResult,
|
||||
AgentProfilePackageFormat,
|
||||
AgentProfilePackageSummary,
|
||||
AgentProfileValidationResult,
|
||||
ConversationLifecycleRecord,
|
||||
ConversationLifecycleResult,
|
||||
PhaseCapabilityEvidence,
|
||||
PhaseTransitionRecord,
|
||||
PhaseTransitionResult,
|
||||
RunApprovalRequest,
|
||||
RunRecoveryRecord,
|
||||
RunLaunchManifestPreview,
|
||||
RunPhaseAuthoritySnapshot,
|
||||
WorkspaceExecutionTrustDecision,
|
||||
WorkspaceExecutionTrustDecisionMode,
|
||||
WorkspaceExecutionTrustScanResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
type ConversationTurnAction = 'resume' | 'follow-up' | 'fork';
|
||||
type ConversationControlAction = 'interrupt' | 'compact' | 'archive' | 'close';
|
||||
|
||||
interface ConversationTurnOptions {
|
||||
sourceAttempt: string;
|
||||
message: string;
|
||||
forkTurn?: string;
|
||||
profile?: string;
|
||||
phase?: string;
|
||||
requireCapability?: string[];
|
||||
commitPolicy?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface ConversationControlOptions {
|
||||
attempt: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface PhaseTransitionOptions {
|
||||
attempt: string;
|
||||
operation: string;
|
||||
targetEvidence: string;
|
||||
fromEvidence?: string;
|
||||
manifest?: string;
|
||||
reason: string;
|
||||
approvalId?: string;
|
||||
approvalTtlMs?: string;
|
||||
overrideUntil?: string;
|
||||
overrideReason?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function inferProfileFormat(filePath: string): AgentProfilePackageFormat {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
return extension === '.json' ? 'json' : 'yaml';
|
||||
}
|
||||
|
||||
async function resolveTaskId(id: string): Promise<string> {
|
||||
const task = await findTask(id);
|
||||
if (!task) throw new Error(`Task not found: ${id}`);
|
||||
return task.id;
|
||||
}
|
||||
|
||||
function readPhaseEvidence(filePath: string): PhaseCapabilityEvidence {
|
||||
return JSON.parse(readFileSync(path.resolve(filePath), 'utf8')) as PhaseCapabilityEvidence;
|
||||
}
|
||||
|
||||
function printConversationResult(
|
||||
action: string,
|
||||
result: {
|
||||
attemptId: string;
|
||||
delivered?: boolean;
|
||||
note?: string;
|
||||
conversation?: ConversationLifecycleRecord;
|
||||
},
|
||||
json?: boolean
|
||||
): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Conversation ${action}`));
|
||||
console.log(chalk.dim(`Attempt ID: ${result.attemptId}`));
|
||||
if (result.conversation?.conversationId) {
|
||||
console.log(chalk.dim(`Conversation ID: ${result.conversation.conversationId}`));
|
||||
}
|
||||
if (result.note) console.log(chalk.dim(result.note));
|
||||
}
|
||||
|
||||
function phaseIdentityLabel(record: PhaseTransitionRecord): string {
|
||||
return phaseEvidenceIdentityLabel(record.effectiveEvidence);
|
||||
}
|
||||
|
||||
function phaseEvidenceIdentityLabel(evidence: PhaseCapabilityEvidence): string {
|
||||
const identity = evidence.identity;
|
||||
return identity.mode === 'legacy'
|
||||
? 'legacy'
|
||||
: `${identity.phase} (${identity.profileId}@${identity.profileVersion})`;
|
||||
}
|
||||
|
||||
function registerConversationTurnCommand(
|
||||
program: Command,
|
||||
action: ConversationTurnAction,
|
||||
description: string
|
||||
): void {
|
||||
const command = program
|
||||
.command(`agent:${action} <id>`)
|
||||
.description(description)
|
||||
.requiredOption('--source-attempt <attemptId>', 'Terminal attempt with durable conversation')
|
||||
.requiredOption('-m, --message <text>', 'Prompt for the new turn')
|
||||
.option('-p, --profile <profileId>', 'Agent profile package to launch')
|
||||
.option('--phase <phase>', 'Execution phase (explore, plan, implement, verify, publish)')
|
||||
.option(
|
||||
'--require-capability <capabilities...>',
|
||||
'Require provider runtime capabilities before launch'
|
||||
)
|
||||
.option(
|
||||
'--commit-policy <policy>',
|
||||
'Commit policy for this run (forbidden, allowed, or required)'
|
||||
)
|
||||
.option('--json', 'Output as JSON');
|
||||
|
||||
if (action === 'fork') {
|
||||
command.option('--fork-turn <turnId>', 'Provider turn boundary to fork from');
|
||||
}
|
||||
|
||||
command.action(async (id: string, options: ConversationTurnOptions) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<{
|
||||
attemptId: string;
|
||||
conversation?: ConversationLifecycleRecord;
|
||||
}>(`/api/agents/${taskId}/conversation/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sourceAttemptId: options.sourceAttempt,
|
||||
message: options.message,
|
||||
...(action === 'fork' && options.forkTurn ? { forkTurnId: options.forkTurn } : {}),
|
||||
profileId: options.profile,
|
||||
phase: options.phase,
|
||||
requiredRuntimeCapabilities: options.requireCapability,
|
||||
commitPolicy: options.commitPolicy,
|
||||
idempotencyKey: `vk-cli:${taskId}:conversation:${action}:${randomUUID()}`,
|
||||
}),
|
||||
});
|
||||
printConversationResult(action, result, options.json);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerConversationControlCommand(
|
||||
program: Command,
|
||||
action: ConversationControlAction,
|
||||
description: string
|
||||
): void {
|
||||
program
|
||||
.command(`agent:${action} <id>`)
|
||||
.description(description)
|
||||
.requiredOption('--attempt <attemptId>', 'Exact active attempt ID')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: ConversationControlOptions) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<ConversationLifecycleResult>(
|
||||
`/api/agents/${taskId}/conversation/${action}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: options.attempt }),
|
||||
}
|
||||
);
|
||||
printConversationResult(action, result, options.json);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAgentCommands(program: Command): void {
|
||||
// Start agent on task
|
||||
|
|
@ -14,6 +196,17 @@ export function registerAgentCommands(program: Command): void {
|
|||
'Agent to use (claude-code, amp, copilot, gemini)',
|
||||
'claude-code'
|
||||
)
|
||||
.option('-p, --profile <profileId>', 'Agent profile package to launch')
|
||||
.option('--phase <phase>', 'Execution phase (explore, plan, implement, verify, publish)')
|
||||
.option(
|
||||
'--require-capability <capabilities...>',
|
||||
'Require provider runtime capabilities before launch'
|
||||
)
|
||||
.option(
|
||||
'--commit-policy <policy>',
|
||||
'Commit policy for this run (forbidden, allowed, or required)'
|
||||
)
|
||||
.option('--parent-attempt <attemptId>', 'Compare launch inputs with a parent attempt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
|
|
@ -36,13 +229,21 @@ export function registerAgentCommands(program: Command): void {
|
|||
|
||||
const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent: options.agent }),
|
||||
body: JSON.stringify({
|
||||
agent: options.profile ? undefined : options.agent,
|
||||
profileId: options.profile,
|
||||
phase: options.phase,
|
||||
requiredRuntimeCapabilities: options.requireCapability,
|
||||
commitPolicy: options.commitPolicy,
|
||||
parentAttemptId: options.parentAttempt,
|
||||
idempotencyKey: `vk-cli:${task.id}:${randomUUID()}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Agent started: ${options.agent}`));
|
||||
console.log(chalk.green(`✓ Agent started: ${options.profile || options.agent}`));
|
||||
console.log(chalk.dim(`Attempt ID: ${result.attemptId}`));
|
||||
console.log(chalk.dim(`Working in: ${task.git.worktreePath}`));
|
||||
}
|
||||
|
|
@ -52,6 +253,309 @@ export function registerAgentCommands(program: Command): void {
|
|||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('launch-preview <id>')
|
||||
.description('Preview the immutable effective launch manifest without starting an agent')
|
||||
.option('-a, --agent <agent>', 'Agent to use', 'codex')
|
||||
.option('-p, --profile <profileId>', 'Agent profile package to preview')
|
||||
.option('--phase <phase>', 'Execution phase (explore, plan, implement, verify, publish)')
|
||||
.option(
|
||||
'--require-capability <capabilities...>',
|
||||
'Require provider runtime capabilities before launch'
|
||||
)
|
||||
.option(
|
||||
'--commit-policy <policy>',
|
||||
'Commit policy for this run (forbidden, allowed, or required)'
|
||||
)
|
||||
.option('--parent-attempt <attemptId>', 'Compare launch inputs with a parent attempt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const task = await findTask(id);
|
||||
if (!task) throw new Error(`Task not found: ${id}`);
|
||||
const preview = await api<RunLaunchManifestPreview>(
|
||||
`/api/agents/${task.id}/launch-preview`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
agent: options.profile ? undefined : options.agent,
|
||||
profileId: options.profile,
|
||||
phase: options.phase,
|
||||
requiredRuntimeCapabilities: options.requireCapability,
|
||||
commitPolicy: options.commitPolicy,
|
||||
parentAttemptId: options.parentAttempt,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(preview, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('Run launch manifest'));
|
||||
console.log(` Digest: ${preview.manifest.digest}`);
|
||||
console.log(` Provider: ${preview.manifest.providerRuntime.provider}`);
|
||||
console.log(` Model: ${preview.manifest.runtime.model ?? 'provider default'}`);
|
||||
console.log(
|
||||
` Phase: ${
|
||||
preview.manifest.phase?.evidence.identity.mode === 'profile'
|
||||
? preview.manifest.phase.evidence.identity.phase
|
||||
: 'legacy'
|
||||
}`
|
||||
);
|
||||
if (preview.manifest.phase) {
|
||||
console.log(` Phase evidence: ${preview.manifest.phase.evidence.digest}`);
|
||||
}
|
||||
console.log(` Workspace trust: ${preview.manifest.workspaceTrust.status}`);
|
||||
console.log(chalk.dim(` ${preview.manifest.workspaceTrust.source}`));
|
||||
console.log(
|
||||
` Enforceable: ${preview.manifest.enforcement.enforceable ? chalk.green('yes') : chalk.red('no')}`
|
||||
);
|
||||
for (const blocker of preview.manifest.enforcement.blockers) {
|
||||
console.log(chalk.red(` Blocker ${blocker.code}: ${blocker.detail}`));
|
||||
}
|
||||
if (preview.drift) {
|
||||
console.log(
|
||||
` Parent drift: ${preview.drift.material ? chalk.yellow('material') : chalk.green('none')}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const workspaceTrust = program
|
||||
.command('workspace-trust')
|
||||
.description('Inspect and manage repository execution trust');
|
||||
|
||||
workspaceTrust
|
||||
.command('scan <id>')
|
||||
.description('Scan repository-controlled instructions and executable configuration')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<WorkspaceExecutionTrustScanResult>(
|
||||
`/api/agents/${taskId}/workspace-trust`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('Workspace execution trust'));
|
||||
console.log(` Identity: ${result.inventory.identity.digest}`);
|
||||
console.log(` Inventory: ${result.inventory.digest}`);
|
||||
console.log(` Project maximum: ${result.inventory.projectPolicy.maximumTrust}`);
|
||||
console.log(` Current decision: ${result.currentDecision?.mode ?? chalk.yellow('none')}`);
|
||||
if (result.inventory.entries.length === 0) {
|
||||
console.log(chalk.dim(' No recognized repository-controlled components found.'));
|
||||
return;
|
||||
}
|
||||
for (const entry of result.inventory.entries) {
|
||||
console.log(
|
||||
` ${entry.posture === 'executable' ? chalk.red('!') : chalk.yellow('•')} ${entry.relativePath}`
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${entry.kind}; ${entry.posture}; ${entry.requestedCapabilities.join(', ')}`
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaceTrust
|
||||
.command('decide <id>')
|
||||
.description('Record trust, restricted, or denied for an exact scanned inventory')
|
||||
.requiredOption('--mode <mode>', 'Decision mode: trusted, restricted, or denied')
|
||||
.requiredOption('--inventory <digest>', 'Exact inventory digest from workspace-trust scan')
|
||||
.requiredOption('--reason <text>', 'Reason for the decision')
|
||||
.option('--expires-at <timestamp>', 'Optional ISO-8601 expiry')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const mode = options.mode as WorkspaceExecutionTrustDecisionMode;
|
||||
if (!['trusted', 'restricted', 'denied'].includes(mode)) {
|
||||
throw new Error('Mode must be trusted, restricted, or denied.');
|
||||
}
|
||||
const taskId = await resolveTaskId(id);
|
||||
const decision = await api<WorkspaceExecutionTrustDecision>(
|
||||
`/api/agents/${taskId}/workspace-trust/decisions`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
inventoryDigest: options.inventory,
|
||||
reason: options.reason,
|
||||
expiresAt: options.expiresAt,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(decision, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`✓ Workspace decision recorded: ${decision.mode}`));
|
||||
console.log(chalk.dim(`Decision ID: ${decision.id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaceTrust
|
||||
.command('revoke <id>')
|
||||
.description('Revoke the current workspace execution trust decision')
|
||||
.requiredOption('--inventory <digest>', 'Exact current inventory digest')
|
||||
.requiredOption('--reason <text>', 'Reason for revocation')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const decision = await api<WorkspaceExecutionTrustDecision>(
|
||||
`/api/agents/${taskId}/workspace-trust/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
inventoryDigest: options.inventory,
|
||||
reason: options.reason,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(decision, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green('✓ Workspace execution trust decision revoked'));
|
||||
console.log(chalk.dim(`Decision ID: ${decision.id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const profiles = program
|
||||
.command('profiles')
|
||||
.description('Manage reusable agent profile packages');
|
||||
|
||||
profiles
|
||||
.command('list')
|
||||
.description('List imported agent profile packages')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const rows = await api<AgentProfilePackageSummary[]>('/api/config/agent-profiles');
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
console.log(chalk.dim('No agent profile packages installed'));
|
||||
return;
|
||||
}
|
||||
for (const profile of rows) {
|
||||
console.log(
|
||||
`${profile.enabled ? chalk.green('●') : chalk.gray('○')} ${chalk.bold(profile.id)} ${chalk.dim(profile.version)}`
|
||||
);
|
||||
console.log(` ${profile.displayName} — ${profile.role}`);
|
||||
console.log(
|
||||
` agent=${profile.runtime.agent}${profile.runtime.model ? ` model=${profile.runtime.model}` : ''}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
profiles
|
||||
.command('validate <file>')
|
||||
.description('Validate an agent profile package YAML or JSON file')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const result = await api<AgentProfileValidationResult>(
|
||||
'/api/config/agent-profiles/validate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, format: inferProfileFormat(file), source: file }),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.valid) {
|
||||
console.log(chalk.green(`✓ Valid profile package: ${result.profile?.id}`));
|
||||
} else {
|
||||
console.log(chalk.red('Invalid profile package'));
|
||||
for (const issue of result.issues) {
|
||||
console.log(chalk.dim(` ${issue.path}: ${issue.message}`));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
profiles
|
||||
.command('import <file>')
|
||||
.description('Import or replace an agent profile package')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const result = await api<{
|
||||
profile: { id: string; displayName: string; version: string };
|
||||
created: boolean;
|
||||
}>('/api/config/agent-profiles/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, format: inferProfileFormat(file), source: file }),
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
chalk.green(
|
||||
`✓ ${result.created ? 'Imported' : 'Updated'} ${result.profile.displayName} (${result.profile.id}@${result.profile.version})`
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
profiles
|
||||
.command('export <profileId>')
|
||||
.description('Export an agent profile package')
|
||||
.option('-f, --format <format>', 'yaml or json', 'yaml')
|
||||
.option('-o, --output <file>', 'Write export to a file')
|
||||
.action(async (profileId, options) => {
|
||||
try {
|
||||
const format = options.format === 'json' ? 'json' : 'yaml';
|
||||
const result = await api<AgentProfileExportResult>(
|
||||
`/api/config/agent-profiles/${encodeURIComponent(profileId)}/export?format=${format}`
|
||||
);
|
||||
if (options.output) {
|
||||
writeFileSync(options.output, result.content, 'utf-8');
|
||||
console.log(chalk.green(`✓ Exported ${profileId} to ${options.output}`));
|
||||
} else {
|
||||
process.stdout.write(result.content);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Stop agent
|
||||
program
|
||||
.command('stop <id>')
|
||||
|
|
@ -66,7 +570,16 @@ export function registerAgentCommands(program: Command): void {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
await api(`/api/agents/${task.id}/stop`, { method: 'POST' });
|
||||
const status = await api<{ running: boolean; attemptId?: string }>(
|
||||
`/api/agents/${task.id}/status`
|
||||
);
|
||||
if (!status.running || !status.attemptId) {
|
||||
throw new Error('No active agent attempt is available to stop');
|
||||
}
|
||||
await api(`/api/agents/${task.id}/stop`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: status.attemptId }),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ stopped: true }));
|
||||
|
|
@ -79,6 +592,302 @@ export function registerAgentCommands(program: Command): void {
|
|||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('agent:recovery <id>')
|
||||
.description('Show the latest durable retry or fallback decision for a task')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<{ recovery: RunRecoveryRecord | null }>(
|
||||
`/api/agents/${taskId}/recovery`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (!result.recovery) {
|
||||
console.log(chalk.dim('No recovery decision is recorded for this task'));
|
||||
} else {
|
||||
console.log(chalk.yellow(`Recovery: ${result.recovery.state}`));
|
||||
console.log(` Action: ${result.recovery.action}`);
|
||||
console.log(` Attempt: ${result.recovery.parentRunId}`);
|
||||
console.log(` Sequence: ${result.recovery.sequence}`);
|
||||
console.log(` Reason: ${result.recovery.reason}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('agent:phase <id>')
|
||||
.description('Show the active durable phase and transition history for an exact run')
|
||||
.requiredOption('--attempt <attemptId>', 'Exact attempt ID')
|
||||
.option('--limit <count>', 'Maximum transition records', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: { attempt: string; limit: string; json?: boolean }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<{
|
||||
phase: RunPhaseAuthoritySnapshot | null;
|
||||
current: PhaseTransitionRecord | null;
|
||||
history: PhaseTransitionRecord[];
|
||||
}>(
|
||||
`/api/agents/${taskId}/phase?attemptId=${encodeURIComponent(options.attempt)}&limit=${encodeURIComponent(options.limit)}`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (!result.phase) {
|
||||
console.log(chalk.dim('This legacy run has no phase authority evidence'));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.cyan(`Phase: ${phaseEvidenceIdentityLabel(result.phase.effectiveEvidence)}`)
|
||||
);
|
||||
console.log(chalk.dim(`Sequence: ${result.phase.transitionSequence}`));
|
||||
console.log(chalk.dim(`Evidence: ${result.phase.effectiveEvidence.digest}`));
|
||||
console.log(chalk.dim(`Manifest: ${result.phase.manifestDigest}`));
|
||||
if (result.current?.emergencyOverride) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`Emergency override expires: ${result.current.emergencyOverride.expiresAt}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
program
|
||||
.command('agent:transition-phase <id>')
|
||||
.description('Apply or request approval for one compare-and-set phase transition')
|
||||
.requiredOption('--attempt <attemptId>', 'Exact active attempt ID')
|
||||
.requiredOption('--operation <id>', 'Stable idempotency key for this transition')
|
||||
.requiredOption('--target-evidence <file>', 'Compiled target phase evidence JSON')
|
||||
.requiredOption('--reason <text>', 'Operator reason')
|
||||
.option('--from-evidence <file>', 'Initial phase evidence JSON for the first transition')
|
||||
.option('--manifest <digest>', 'Launch manifest digest for the first transition')
|
||||
.option('--approval-id <id>', 'Exact approval returned by the prior request')
|
||||
.option('--approval-ttl-ms <milliseconds>', 'Approval request lifetime')
|
||||
.option('--override-until <timestamp>', 'Emergency override expiry, at most 24 hours')
|
||||
.option('--override-reason <text>', 'Emergency override justification')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: PhaseTransitionOptions): Promise<void> => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const state = await api<{
|
||||
phase: RunPhaseAuthoritySnapshot | null;
|
||||
current: PhaseTransitionRecord | null;
|
||||
history: PhaseTransitionRecord[];
|
||||
}>(`/api/agents/${taskId}/phase?attemptId=${encodeURIComponent(options.attempt)}`);
|
||||
const fromEvidence = options.fromEvidence
|
||||
? readPhaseEvidence(options.fromEvidence)
|
||||
: undefined;
|
||||
const priorEvidence =
|
||||
state.phase?.effectiveEvidence ?? state.current?.effectiveEvidence ?? fromEvidence;
|
||||
if (!priorEvidence) {
|
||||
throw new Error('The first transition requires --from-evidence');
|
||||
}
|
||||
const manifestDigest =
|
||||
state.phase?.manifestDigest ?? state.current?.manifestDigest ?? options.manifest;
|
||||
if (!manifestDigest) {
|
||||
throw new Error('The first transition requires --manifest');
|
||||
}
|
||||
if (
|
||||
(options.overrideUntil && !options.overrideReason) ||
|
||||
(!options.overrideUntil && options.overrideReason)
|
||||
) {
|
||||
throw new Error('--override-until and --override-reason must be used together');
|
||||
}
|
||||
const approvalTtlMs =
|
||||
options.approvalTtlMs && /^\d+$/.test(options.approvalTtlMs)
|
||||
? Number(options.approvalTtlMs)
|
||||
: undefined;
|
||||
if (options.approvalTtlMs && !Number.isSafeInteger(approvalTtlMs)) {
|
||||
throw new Error('--approval-ttl-ms must be an integer');
|
||||
}
|
||||
const result = await api<PhaseTransitionResult>(`/api/agents/${taskId}/phase/transitions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: options.attempt,
|
||||
operationId: options.operation,
|
||||
expectedSequence: state.current?.sequence ?? 0,
|
||||
expectedPhaseEvidenceDigest: priorEvidence.digest,
|
||||
expectedManifestDigest: manifestDigest,
|
||||
reason: options.reason,
|
||||
...(state.current ? {} : { fromEvidence: priorEvidence }),
|
||||
targetEvidence: readPhaseEvidence(options.targetEvidence),
|
||||
approvalId: options.approvalId,
|
||||
approvalTtlMs,
|
||||
...(options.overrideUntil && options.overrideReason
|
||||
? {
|
||||
emergencyOverride: {
|
||||
expiresAt: options.overrideUntil,
|
||||
justification: options.overrideReason,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.status === 'approval-required' && result.approval) {
|
||||
console.log(chalk.yellow('Phase expansion requires approval'));
|
||||
console.log(` Approval: ${result.approval.id}`);
|
||||
console.log(` Revision: ${result.approval.revision}`);
|
||||
console.log(` Action hash: ${result.approval.actionHash}`);
|
||||
console.log(
|
||||
chalk.dim('Approve it, then retry this command with the same --operation value.')
|
||||
);
|
||||
} else if (result.record) {
|
||||
console.log(chalk.green(`✓ Phase transitioned to ${phaseIdentityLabel(result.record)}`));
|
||||
console.log(chalk.dim(`Sequence: ${result.record.sequence}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('agent:decide-phase-approval <approvalId>')
|
||||
.description('Approve or reject an exact pending phase transition')
|
||||
.requiredOption('--decision <decision>', 'approve or reject')
|
||||
.option('--note <text>', 'Decision note')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(
|
||||
async (
|
||||
approvalId: string,
|
||||
options: { decision: string; note?: string; json?: boolean }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!['approve', 'reject'].includes(options.decision)) {
|
||||
throw new Error('--decision must be approve or reject');
|
||||
}
|
||||
const approval = await api<RunApprovalRequest>(
|
||||
`/api/run-approvals/${encodeURIComponent(approvalId)}`
|
||||
);
|
||||
const decided = await api<RunApprovalRequest>(
|
||||
`/api/run-approvals/${encodeURIComponent(approval.id)}/decision`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
decision: options.decision === 'approve' ? 'approved' : 'rejected',
|
||||
expectedRevision: approval.revision,
|
||||
expectedActionHash: approval.actionHash,
|
||||
note: options.note,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(decided, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Phase transition approval ${decided.status}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
program
|
||||
.command('agent:cancel-recovery <id>')
|
||||
.description('Cancel the exact pending retry or fallback for a task')
|
||||
.requiredOption('--attempt <attemptId>', 'Parent attempt that owns the pending recovery')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options: { attempt: string; json?: boolean }) => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<{ cancelled: boolean; recovery: RunRecoveryRecord }>(
|
||||
`/api/agents/${taskId}/recovery/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ attemptId: options.attempt }),
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.yellow('✓ Automatic recovery cancelled'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
registerConversationTurnCommand(
|
||||
program,
|
||||
'resume',
|
||||
'Resume a terminal provider conversation without replaying prior prompts'
|
||||
);
|
||||
registerConversationTurnCommand(
|
||||
program,
|
||||
'follow-up',
|
||||
'Start a provider-native follow-up turn from a terminal attempt'
|
||||
);
|
||||
registerConversationTurnCommand(
|
||||
program,
|
||||
'fork',
|
||||
'Fork provider-native history from a terminal attempt'
|
||||
);
|
||||
|
||||
program
|
||||
.command('agent:steer <id>')
|
||||
.description('Steer the exact active provider turn')
|
||||
.requiredOption('--attempt <attemptId>', 'Exact active attempt ID')
|
||||
.requiredOption('-m, --message <text>', 'Steering message')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: ConversationControlOptions & { message: string }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const taskId = await resolveTaskId(id);
|
||||
const result = await api<ConversationLifecycleResult>(
|
||||
`/api/agents/${taskId}/conversation/steer`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attemptId: options.attempt,
|
||||
message: options.message,
|
||||
}),
|
||||
}
|
||||
);
|
||||
printConversationResult('steered', result, options.json);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
registerConversationControlCommand(
|
||||
program,
|
||||
'interrupt',
|
||||
'Interrupt the exact active provider turn'
|
||||
);
|
||||
registerConversationControlCommand(
|
||||
program,
|
||||
'compact',
|
||||
'Compact the active provider conversation'
|
||||
);
|
||||
registerConversationControlCommand(
|
||||
program,
|
||||
'archive',
|
||||
'Archive the active provider conversation'
|
||||
);
|
||||
registerConversationControlCommand(program, 'close', 'Close the active provider conversation');
|
||||
|
||||
// Get pending agent requests (for Veritas to process)
|
||||
program
|
||||
.command('agents:pending')
|
||||
|
|
@ -143,10 +952,17 @@ export function registerAgentCommands(program: Command): void {
|
|||
.option('-f, --failed', 'Mark as failed')
|
||||
.option('-m, --summary <text>', 'Summary of what was done')
|
||||
.option('-e, --error <text>', 'Error message (if failed)')
|
||||
.requiredOption('--attempt-id <id>', 'Attempt ID that produced this completion')
|
||||
.requiredOption(
|
||||
'--manifest-digest <digest>',
|
||||
'Provider runtime manifest digest bound to the attempt'
|
||||
)
|
||||
.action(async (taskId, options) => {
|
||||
try {
|
||||
const success = !options.failed;
|
||||
const body = {
|
||||
attemptId: options.attemptId,
|
||||
providerRuntimeManifestDigest: options.manifestDigest,
|
||||
success,
|
||||
summary: options.summary,
|
||||
error: options.error,
|
||||
|
|
|
|||
1026
cli/src/commands/doctor.ts
Normal file
1026
cli/src/commands/doctor.ts
Normal file
File diff suppressed because it is too large
Load diff
312
cli/src/commands/goals.ts
Normal file
312
cli/src/commands/goals.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import type {
|
||||
DurableGoalBlocker,
|
||||
DurableGoalCompletionEvidence,
|
||||
DurableGoalCompletionRequirement,
|
||||
DurableGoalContinuationMode,
|
||||
DurableGoalRecord,
|
||||
DurableGoalState,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { DURABLE_GOAL_STATES } from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
interface GoalListResponse {
|
||||
generatedAt: string;
|
||||
goals: DurableGoalRecord[];
|
||||
}
|
||||
|
||||
interface GoalRolloverResponse {
|
||||
action: string;
|
||||
goal?: DurableGoalRecord;
|
||||
continuation?: {
|
||||
id: string;
|
||||
kind: string;
|
||||
state: string;
|
||||
resultAttemptId?: string;
|
||||
queueId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type GoalBlockerInput = Omit<DurableGoalBlocker, 'id' | 'recordedAt'> & { id?: string };
|
||||
|
||||
const VERIFICATION_KINDS = new Set(['test', 'build', 'artifact', 'operator', 'external', 'other']);
|
||||
|
||||
export function registerGoalCommands(program: Command): void {
|
||||
const goals = program
|
||||
.command('goals')
|
||||
.description('Create, inspect, and control durable objectives');
|
||||
|
||||
goals
|
||||
.command('list')
|
||||
.description('List durable goals in the current workspace')
|
||||
.option('--state <states...>', 'Filter by goal state')
|
||||
.option('--root-task <id>', 'Filter by root task')
|
||||
.option('--root-workflow <id>', 'Filter by root workflow')
|
||||
.option('--limit <count>', 'Maximum goals', '100')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
for (const state of (options.state ?? []) as DurableGoalState[]) {
|
||||
query.append('state', state);
|
||||
}
|
||||
if (options.rootTask) query.set('rootTaskId', options.rootTask);
|
||||
if (options.rootWorkflow) query.set('rootWorkflowId', options.rootWorkflow);
|
||||
query.set('limit', options.limit);
|
||||
const result = await api<GoalListResponse>(`/api/goals?${query.toString()}`);
|
||||
if (options.json) return printJson(result);
|
||||
if (result.goals.length === 0) {
|
||||
console.log(chalk.dim('No durable goals matched.'));
|
||||
return;
|
||||
}
|
||||
for (const goal of result.goals) printGoal(goal);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('get <id>')
|
||||
.description('Inspect one durable goal')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const goal = await api<DurableGoalRecord>(`/api/goals/${encodeURIComponent(id)}`);
|
||||
if (options.json) return printJson(goal);
|
||||
printGoal(goal, true);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('create')
|
||||
.description('Create an evidence-gated durable goal')
|
||||
.requiredOption('--objective <text>', 'Goal objective')
|
||||
.requiredOption('--acceptance <criteria...>', 'Acceptance criteria')
|
||||
.requiredOption(
|
||||
'--requirement <requirements...>',
|
||||
'Completion requirement as id|kind|description'
|
||||
)
|
||||
.option('--constraint <constraints...>', 'Goal constraints')
|
||||
.option('--root-task <id>', 'Root task identity')
|
||||
.option('--root-workflow <id>', 'Root workflow identity')
|
||||
.option('--task <id>', 'Optional task associated with a root workflow')
|
||||
.option('--mode <mode>', 'Continuation mode: manual or automatic', 'manual')
|
||||
.option('--max-turns <count>', 'Maximum continuation turns')
|
||||
.option('--max-rollovers <count>', 'Maximum conversation rollovers')
|
||||
.option('--compact-after-tokens <count>', 'Compaction threshold')
|
||||
.option('--require-rollover-approval', 'Require approval before rollover')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (Boolean(options.rootTask) === Boolean(options.rootWorkflow)) {
|
||||
throw new Error('Specify exactly one of --root-task or --root-workflow.');
|
||||
}
|
||||
if (!['manual', 'automatic'].includes(options.mode)) {
|
||||
throw new Error('--mode must be manual or automatic.');
|
||||
}
|
||||
const completionRequirements = (options.requirement as string[]).map(
|
||||
parseCompletionRequirement
|
||||
);
|
||||
const root = options.rootTask
|
||||
? { kind: 'task' as const, taskId: options.rootTask }
|
||||
: {
|
||||
kind: 'workflow' as const,
|
||||
workflowId: options.rootWorkflow,
|
||||
...(options.task ? { taskId: options.task } : {}),
|
||||
};
|
||||
const goal = await api<DurableGoalRecord>('/api/goals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
objective: options.objective,
|
||||
constraints: options.constraint ?? [],
|
||||
acceptanceCriteria: options.acceptance,
|
||||
root,
|
||||
continuation: {
|
||||
mode: options.mode as DurableGoalContinuationMode,
|
||||
...(options.maxTurns ? { maxTurns: parsePositiveInteger(options.maxTurns) } : {}),
|
||||
...(options.maxRollovers
|
||||
? { maxRollovers: parseNonnegativeInteger(options.maxRollovers) }
|
||||
: {}),
|
||||
...(options.compactAfterTokens
|
||||
? { compactAfterTokens: parsePositiveInteger(options.compactAfterTokens) }
|
||||
: {}),
|
||||
...(options.requireRolloverApproval ? { requireApprovalForRollover: true } : {}),
|
||||
},
|
||||
completionRequirements,
|
||||
}),
|
||||
});
|
||||
if (options.json) return printJson(goal);
|
||||
console.log(chalk.green(`✓ Created durable goal ${goal.id}`));
|
||||
printGoal(goal);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('transition <id>')
|
||||
.description('Apply one compare-and-set goal state transition')
|
||||
.requiredOption('--revision <number>', 'Expected goal revision')
|
||||
.requiredOption('--state <state>', `New state: ${DURABLE_GOAL_STATES.join(', ')}`)
|
||||
.requiredOption('--reason <text>', 'Operator reason')
|
||||
.option('--blocker-json <json>', 'Actionable blocker JSON for blocked state')
|
||||
.option('--evidence-json <json>', 'Completion evidence JSON array')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
if (!DURABLE_GOAL_STATES.includes(options.state as DurableGoalState)) {
|
||||
throw new Error(`Unknown goal state: ${options.state}`);
|
||||
}
|
||||
const blocker = options.blockerJson
|
||||
? parseJson<GoalBlockerInput>(options.blockerJson, '--blocker-json')
|
||||
: undefined;
|
||||
const completionEvidence = options.evidenceJson
|
||||
? parseJson<
|
||||
Array<Pick<DurableGoalCompletionEvidence, 'requirementId' | 'evidenceId' | 'summary'>>
|
||||
>(options.evidenceJson, '--evidence-json')
|
||||
: undefined;
|
||||
const goal = await api<DurableGoalRecord>(
|
||||
`/api/goals/${encodeURIComponent(id)}/transition`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: parsePositiveInteger(options.revision),
|
||||
state: options.state,
|
||||
reason: options.reason,
|
||||
blocker,
|
||||
completionEvidence,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) return printJson(goal);
|
||||
console.log(chalk.green(`✓ Goal ${goal.id} is ${goal.state} at revision ${goal.revision}`));
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('link-run <id>')
|
||||
.description('Link one run or continuation to a durable goal')
|
||||
.requiredOption('--revision <number>', 'Expected goal revision')
|
||||
.requiredOption('--task <id>', 'Run task identity')
|
||||
.option('--attempt <id>', 'Attempt identity')
|
||||
.option('--workflow-run <id>', 'Workflow run identity')
|
||||
.option('--conversation <id>', 'Conversation identity')
|
||||
.option('--parent-attempt <id>', 'Causal parent attempt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const goal = await api<DurableGoalRecord>(`/api/goals/${encodeURIComponent(id)}/runs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: parsePositiveInteger(options.revision),
|
||||
taskId: options.task,
|
||||
attemptId: options.attempt,
|
||||
workflowRunId: options.workflowRun,
|
||||
conversationId: options.conversation,
|
||||
parentAttemptId: options.parentAttempt,
|
||||
}),
|
||||
});
|
||||
if (options.json) return printJson(goal);
|
||||
console.log(chalk.green(`✓ Linked run to goal ${goal.id} at revision ${goal.revision}`));
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
goals
|
||||
.command('rollover <id>')
|
||||
.description('Approve and dispatch one bounded fresh-conversation rollover')
|
||||
.requiredOption('--revision <number>', 'Expected goal revision')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const result = await api<GoalRolloverResponse>(
|
||||
`/api/goals/${encodeURIComponent(id)}/rollover`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expectedRevision: parsePositiveInteger(options.revision),
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) return printJson(result);
|
||||
const attempt = result.continuation?.resultAttemptId;
|
||||
console.log(
|
||||
chalk.green(`✓ Goal ${id} rollover ${result.action}${attempt ? ` as ${attempt}` : ''}`)
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseCompletionRequirement(value: string): DurableGoalCompletionRequirement {
|
||||
const [id, verificationKind, ...descriptionParts] = value.split('|');
|
||||
const description = descriptionParts.join('|').trim();
|
||||
if (!id?.trim() || !verificationKind?.trim() || !description) {
|
||||
throw new Error(`Invalid requirement "${value}"; expected id|kind|description.`);
|
||||
}
|
||||
if (!VERIFICATION_KINDS.has(verificationKind)) {
|
||||
throw new Error(`Invalid verification kind "${verificationKind}".`);
|
||||
}
|
||||
return {
|
||||
id: id.trim(),
|
||||
verificationKind: verificationKind as DurableGoalCompletionRequirement['verificationKind'],
|
||||
description,
|
||||
required: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`Expected a positive integer.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseNonnegativeInteger(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`Expected a nonnegative integer.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseJson<T>(value: string, option: string): T {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
throw new Error(`${option} must contain valid JSON.`);
|
||||
}
|
||||
}
|
||||
|
||||
function printGoal(goal: DurableGoalRecord, verbose = false): void {
|
||||
const state =
|
||||
goal.state === 'active'
|
||||
? chalk.green(goal.state)
|
||||
: ['complete'].includes(goal.state)
|
||||
? chalk.blue(goal.state)
|
||||
: ['cancelled', 'failed'].includes(goal.state)
|
||||
? chalk.red(goal.state)
|
||||
: chalk.yellow(goal.state);
|
||||
console.log(`${state} ${chalk.bold(goal.id)} revision=${goal.revision}`);
|
||||
console.log(` ${goal.objective}`);
|
||||
if (verbose) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` root=${goal.root.kind === 'task' ? goal.root.taskId : goal.root.workflowId} runs=${goal.continuationChain.length} blockers=${goal.blockers.length} evidence=${goal.completionEvidence.length}/${goal.completionRequirements.length}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(value: unknown): void {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
function printError(error: unknown): void {
|
||||
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
619
cli/src/commands/prompts.ts
Normal file
619
cli/src/commands/prompts.ts
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { API_BASE, buildApiHeaders } from '../utils/api.js';
|
||||
import type {
|
||||
CreatePromptTemplateInput,
|
||||
PromptCategory,
|
||||
PromptTemplate,
|
||||
UpdatePromptTemplateInput,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
const VALID_CATEGORIES = new Set<PromptCategory>(['system', 'agent', 'tool', 'evaluation']);
|
||||
|
||||
interface PromptImportOptions {
|
||||
sourceDir: string;
|
||||
apiBase: string;
|
||||
timeoutMs: number;
|
||||
dryRun: boolean;
|
||||
force: boolean;
|
||||
includeReadme: boolean;
|
||||
}
|
||||
|
||||
interface PromptImportDependencies {
|
||||
fetch: typeof fetch;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface FilePromptTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: PromptCategory;
|
||||
content: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export type PromptImportStatus = 'created' | 'updated' | 'unchanged' | 'conflict' | 'malformed';
|
||||
|
||||
export interface PromptImportItem {
|
||||
status: PromptImportStatus;
|
||||
file: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
reason?: string;
|
||||
changedFields?: string[];
|
||||
}
|
||||
|
||||
export interface PromptImportReport {
|
||||
dryRun: boolean;
|
||||
force: boolean;
|
||||
sourceDirectory: string;
|
||||
counts: Record<PromptImportStatus, number> & { total: number };
|
||||
items: PromptImportItem[];
|
||||
}
|
||||
|
||||
interface ParsedPromptFile {
|
||||
template?: FilePromptTemplate;
|
||||
item?: PromptImportItem;
|
||||
}
|
||||
|
||||
interface FrontmatterParseResult {
|
||||
data: Record<string, unknown>;
|
||||
content: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function unwrapData<T>(body: unknown): T {
|
||||
if (isRecord(body) && body.success === true && 'data' in body) {
|
||||
return body.data as T;
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function normalizeApiBase(value: string): string {
|
||||
const trimmed = value.trim().replace(/\/+$/, '');
|
||||
try {
|
||||
return new URL(trimmed).origin;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContent(value: string): string {
|
||||
return value.replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
function normalizeOptional(value: string | undefined): string {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function slugifyId(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function isValidTemplateId(value: string): boolean {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value);
|
||||
}
|
||||
|
||||
function humanizeId(value: string): string {
|
||||
return value
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function frontmatterString(data: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = data[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function parseScalar(value: string): unknown {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
const rawItems = trimmed.slice(1, -1).trim();
|
||||
if (!rawItems) return [];
|
||||
return rawItems.split(',').map((item) => String(parseScalar(item)));
|
||||
}
|
||||
if (trimmed === 'true') return true;
|
||||
if (trimmed === 'false') return false;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): FrontmatterParseResult {
|
||||
const normalized = raw.replace(/\r\n/g, '\n');
|
||||
if (!normalized.startsWith('---\n')) {
|
||||
return { data: {}, content: normalized };
|
||||
}
|
||||
|
||||
const lines = normalized.split('\n');
|
||||
const closingIndex = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
|
||||
if (closingIndex === -1) {
|
||||
return { data: {}, content: '', error: 'Missing closing frontmatter delimiter' };
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
let currentListKey: string | null = null;
|
||||
|
||||
for (const line of lines.slice(1, closingIndex)) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
|
||||
const listMatch = line.match(/^\s*-\s+(.+)$/);
|
||||
if (listMatch && currentListKey) {
|
||||
(data[currentListKey] as unknown[]).push(parseScalar(listMatch[1] ?? ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryMatch = line.match(/^([A-Za-z][\w-]*):(?:\s*(.*))?$/);
|
||||
if (!entryMatch) {
|
||||
return { data: {}, content: '', error: `Malformed frontmatter line: ${line.trim()}` };
|
||||
}
|
||||
|
||||
const key = entryMatch[1] as string;
|
||||
const rawValue = entryMatch[2] ?? '';
|
||||
if (!rawValue.trim()) {
|
||||
data[key] = [];
|
||||
currentListKey = key;
|
||||
} else {
|
||||
data[key] = parseScalar(rawValue);
|
||||
currentListKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
content: lines.slice(closingIndex + 1).join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function firstMarkdownHeading(content: string): string | undefined {
|
||||
const heading = content
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^#\s+(.+)$/)?.[1]?.trim())
|
||||
.find((line): line is string => Boolean(line));
|
||||
return heading || undefined;
|
||||
}
|
||||
|
||||
export async function discoverPromptTemplateFiles(
|
||||
sourceDir: string,
|
||||
options: { includeReadme?: boolean } = {}
|
||||
): Promise<string[]> {
|
||||
const root = path.resolve(sourceDir);
|
||||
const results: string[] = [];
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue;
|
||||
if (!options.includeReadme && entry.name.toLowerCase() === 'readme.md') continue;
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root);
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function parsePromptTemplateFile(
|
||||
filePath: string,
|
||||
rootDir: string
|
||||
): Promise<ParsedPromptFile> {
|
||||
const relativePath = path.relative(path.resolve(rootDir), filePath).replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf-8');
|
||||
const parsed = parseFrontmatter(raw);
|
||||
if (parsed.error) {
|
||||
return { item: { status: 'malformed', file: relativePath, reason: parsed.error } };
|
||||
}
|
||||
|
||||
const content = normalizeContent(parsed.content);
|
||||
if (!content) {
|
||||
return {
|
||||
item: { status: 'malformed', file: relativePath, reason: 'Template content is empty' },
|
||||
};
|
||||
}
|
||||
|
||||
const basename = path.basename(filePath, path.extname(filePath));
|
||||
const frontmatterId = frontmatterString(parsed.data, 'id');
|
||||
const id = frontmatterId ?? slugifyId(basename);
|
||||
if (!id || !isValidTemplateId(id)) {
|
||||
return {
|
||||
item: {
|
||||
status: 'malformed',
|
||||
file: relativePath,
|
||||
id,
|
||||
reason: 'Template ID must contain only letters, numbers, dashes, and underscores',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const category = frontmatterString(parsed.data, 'category') ?? 'agent';
|
||||
if (!VALID_CATEGORIES.has(category as PromptCategory)) {
|
||||
return {
|
||||
item: {
|
||||
status: 'malformed',
|
||||
file: relativePath,
|
||||
id,
|
||||
reason: `Unsupported category: ${category}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const name =
|
||||
frontmatterString(parsed.data, 'name') ??
|
||||
frontmatterString(parsed.data, 'title') ??
|
||||
firstMarkdownHeading(content) ??
|
||||
humanizeId(id);
|
||||
const description = frontmatterString(parsed.data, 'description');
|
||||
|
||||
return {
|
||||
template: {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
category: category as PromptCategory,
|
||||
content,
|
||||
filePath,
|
||||
relativePath,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
item: {
|
||||
status: 'malformed',
|
||||
file: relativePath,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function changedFields(fileTemplate: FilePromptTemplate, existing: PromptTemplate): string[] {
|
||||
const fields: string[] = [];
|
||||
if (fileTemplate.name !== existing.name) fields.push('name');
|
||||
if (normalizeOptional(fileTemplate.description) !== normalizeOptional(existing.description)) {
|
||||
fields.push('description');
|
||||
}
|
||||
if (fileTemplate.category !== existing.category) fields.push('category');
|
||||
if (fileTemplate.content !== normalizeContent(existing.content)) fields.push('content');
|
||||
return fields;
|
||||
}
|
||||
|
||||
function emptyCounts(): PromptImportReport['counts'] {
|
||||
return {
|
||||
total: 0,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
conflict: 0,
|
||||
malformed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildReport(
|
||||
items: PromptImportItem[],
|
||||
options: Pick<PromptImportOptions, 'dryRun' | 'force' | 'sourceDir'>
|
||||
): PromptImportReport {
|
||||
const counts = emptyCounts();
|
||||
for (const item of items) {
|
||||
counts.total += 1;
|
||||
counts[item.status] += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun: options.dryRun,
|
||||
force: options.force,
|
||||
sourceDirectory: path.basename(path.resolve(options.sourceDir)),
|
||||
counts,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPromptImportBlockers(report: PromptImportReport): boolean {
|
||||
return report.counts.conflict > 0 || report.counts.malformed > 0;
|
||||
}
|
||||
|
||||
export function planPromptTemplateImport(
|
||||
parsedFiles: ParsedPromptFile[],
|
||||
existingTemplates: PromptTemplate[],
|
||||
options: Pick<PromptImportOptions, 'dryRun' | 'force' | 'sourceDir'>
|
||||
): PromptImportReport {
|
||||
const existingById = new Map(existingTemplates.map((template) => [template.id, template]));
|
||||
const existingByName = new Map(
|
||||
existingTemplates.map((template) => [template.name.trim().toLowerCase(), template])
|
||||
);
|
||||
const seenIds = new Set<string>();
|
||||
const items: PromptImportItem[] = [];
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
if (parsed.item) {
|
||||
items.push(parsed.item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileTemplate = parsed.template;
|
||||
if (!fileTemplate) continue;
|
||||
if (seenIds.has(fileTemplate.id)) {
|
||||
items.push({
|
||||
status: 'conflict',
|
||||
file: fileTemplate.relativePath,
|
||||
id: fileTemplate.id,
|
||||
name: fileTemplate.name,
|
||||
reason: 'Multiple files resolve to the same template ID',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
seenIds.add(fileTemplate.id);
|
||||
|
||||
const existing = existingById.get(fileTemplate.id);
|
||||
if (existing) {
|
||||
const fields = changedFields(fileTemplate, existing);
|
||||
if (fields.length === 0) {
|
||||
items.push({
|
||||
status: 'unchanged',
|
||||
file: fileTemplate.relativePath,
|
||||
id: fileTemplate.id,
|
||||
name: fileTemplate.name,
|
||||
});
|
||||
} else if (options.force) {
|
||||
items.push({
|
||||
status: 'updated',
|
||||
file: fileTemplate.relativePath,
|
||||
id: fileTemplate.id,
|
||||
name: fileTemplate.name,
|
||||
changedFields: fields,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
status: 'conflict',
|
||||
file: fileTemplate.relativePath,
|
||||
id: fileTemplate.id,
|
||||
name: fileTemplate.name,
|
||||
changedFields: fields,
|
||||
reason: 'Runtime template differs; rerun with --force to update',
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const sameName = existingByName.get(fileTemplate.name.trim().toLowerCase());
|
||||
if (sameName) {
|
||||
items.push({
|
||||
status: 'conflict',
|
||||
file: fileTemplate.relativePath,
|
||||
id: fileTemplate.id,
|
||||
name: fileTemplate.name,
|
||||
reason: `Template name already exists with runtime ID ${sameName.id}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
status: 'created',
|
||||
file: fileTemplate.relativePath,
|
||||
id: fileTemplate.id,
|
||||
name: fileTemplate.name,
|
||||
});
|
||||
}
|
||||
|
||||
return buildReport(items, options);
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
deps: PromptImportDependencies,
|
||||
options: PromptImportOptions,
|
||||
pathName: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('content-type', 'application/json');
|
||||
for (const [key, value] of Object.entries(buildApiHeaders(undefined, deps.env.VK_API_KEY))) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
|
||||
const response = await deps.fetch(`${options.apiBase}${pathName}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
const body = text ? JSON.parse(text) : null;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
isRecord(body) && typeof body.error === 'string'
|
||||
? body.error
|
||||
: isRecord(body) && typeof body.message === 'string'
|
||||
? body.message
|
||||
: response.statusText;
|
||||
throw new Error(`${response.status} ${message}`);
|
||||
}
|
||||
return unwrapData<T>(body);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExistingTemplates(
|
||||
deps: PromptImportDependencies,
|
||||
options: PromptImportOptions
|
||||
): Promise<PromptTemplate[]> {
|
||||
return requestJson<PromptTemplate[]>(deps, options, '/api/prompt-registry');
|
||||
}
|
||||
|
||||
async function applyPromptTemplateImport(
|
||||
report: PromptImportReport,
|
||||
parsedFiles: ParsedPromptFile[],
|
||||
deps: PromptImportDependencies,
|
||||
options: PromptImportOptions
|
||||
): Promise<void> {
|
||||
const byRelativePath = new Map(
|
||||
parsedFiles
|
||||
.filter((parsed): parsed is ParsedPromptFile & { template: FilePromptTemplate } =>
|
||||
Boolean(parsed.template)
|
||||
)
|
||||
.map((parsed) => [parsed.template.relativePath, parsed.template])
|
||||
);
|
||||
|
||||
for (const item of report.items) {
|
||||
const fileTemplate = byRelativePath.get(item.file);
|
||||
if (!fileTemplate) continue;
|
||||
|
||||
if (item.status === 'created') {
|
||||
const body: CreatePromptTemplateInput = {
|
||||
id: fileTemplate.id,
|
||||
name: fileTemplate.name,
|
||||
description: fileTemplate.description,
|
||||
category: fileTemplate.category,
|
||||
content: fileTemplate.content,
|
||||
};
|
||||
await requestJson<PromptTemplate>(deps, options, '/api/prompt-registry', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
if (item.status === 'updated') {
|
||||
const body: UpdatePromptTemplateInput = {
|
||||
name: fileTemplate.name,
|
||||
description: fileTemplate.description,
|
||||
category: fileTemplate.category,
|
||||
content: fileTemplate.content,
|
||||
changelog: `Sync from ${fileTemplate.relativePath}`,
|
||||
};
|
||||
await requestJson<PromptTemplate>(deps, options, `/api/prompt-registry/${fileTemplate.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPromptTemplateImport(
|
||||
input: Partial<PromptImportOptions> & { sourceDir: string },
|
||||
depsInput: Partial<PromptImportDependencies> = {}
|
||||
): Promise<PromptImportReport> {
|
||||
const options: PromptImportOptions = {
|
||||
sourceDir: input.sourceDir,
|
||||
apiBase: normalizeApiBase(input.apiBase ?? API_BASE),
|
||||
timeoutMs: input.timeoutMs ?? 5000,
|
||||
dryRun: input.dryRun ?? false,
|
||||
force: input.force ?? false,
|
||||
includeReadme: input.includeReadme ?? false,
|
||||
};
|
||||
const deps: PromptImportDependencies = {
|
||||
fetch: depsInput.fetch ?? globalThis.fetch.bind(globalThis),
|
||||
env: depsInput.env ?? process.env,
|
||||
};
|
||||
|
||||
const files = await discoverPromptTemplateFiles(options.sourceDir, {
|
||||
includeReadme: options.includeReadme,
|
||||
});
|
||||
const parsedFiles = await Promise.all(
|
||||
files.map((file) => parsePromptTemplateFile(file, options.sourceDir))
|
||||
);
|
||||
const existingTemplates = await loadExistingTemplates(deps, options);
|
||||
const report = planPromptTemplateImport(parsedFiles, existingTemplates, options);
|
||||
|
||||
if (!options.dryRun && !hasPromptImportBlockers(report)) {
|
||||
await applyPromptTemplateImport(report, parsedFiles, deps, options);
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
export function formatPromptImportReport(report: PromptImportReport): string {
|
||||
const lines = [
|
||||
`Prompt import ${report.dryRun ? 'dry run' : 'result'}`,
|
||||
`Source: ${report.sourceDirectory}`,
|
||||
`Counts: ${report.counts.created} created, ${report.counts.updated} updated, ${report.counts.unchanged} unchanged, ${report.counts.conflict} conflict, ${report.counts.malformed} malformed`,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const item of report.items) {
|
||||
const changed = item.changedFields?.length ? ` (${item.changedFields.join(', ')})` : '';
|
||||
const reason = item.reason ? ` - ${item.reason}` : '';
|
||||
lines.push(
|
||||
`- ${item.status}: ${item.id ?? 'unknown'} ${item.file}${changed}${reason}`.trimEnd()
|
||||
);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function parseTimeout(value: string): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5000;
|
||||
}
|
||||
|
||||
export function registerPromptCommands(program: Command): void {
|
||||
const prompts = program.command('prompts').description('Prompt registry commands');
|
||||
|
||||
prompts
|
||||
.command('import <dir>')
|
||||
.description('Import file-based prompt templates into the runtime registry')
|
||||
.option('--dry-run', 'Report changes without writing to the runtime registry')
|
||||
.option('--force', 'Update existing runtime templates when file content or metadata differs')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--include-readme', 'Include README.md files as templates')
|
||||
.option('--api <url>', 'API base URL', API_BASE)
|
||||
.option('--timeout <ms>', 'Per-request timeout in milliseconds', '5000')
|
||||
.action(async (dir, options) => {
|
||||
try {
|
||||
const report = await runPromptTemplateImport({
|
||||
sourceDir: dir,
|
||||
apiBase: options.api,
|
||||
timeoutMs: parseTimeout(options.timeout),
|
||||
dryRun: Boolean(options.dryRun),
|
||||
force: Boolean(options.force),
|
||||
includeReadme: Boolean(options.includeReadme),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
process.stdout.write(formatPromptImportReport(report));
|
||||
}
|
||||
|
||||
if (hasPromptImportBlockers(report)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!report.dryRun) {
|
||||
const changed = report.counts.created + report.counts.updated;
|
||||
console.log(chalk.green(`Prompt registry import applied: ${changed} changed`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
176
cli/src/commands/queue-monitors.ts
Normal file
176
cli/src/commands/queue-monitors.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api } from '../utils/api.js';
|
||||
import type {
|
||||
QueueMonitorExplainResult,
|
||||
QueueMonitorHealthResult,
|
||||
QueueMonitorListResponse,
|
||||
QueueMonitorRunResult,
|
||||
QueueMonitorSnapshot,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
export function registerQueueMonitorCommands(program: Command): void {
|
||||
const monitors = program
|
||||
.command('queue-monitors')
|
||||
.alias('queue-monitor')
|
||||
.alias('queues')
|
||||
.description('Inspect and run policy-gated GitHub queue intake monitors');
|
||||
|
||||
monitors
|
||||
.command('list')
|
||||
.alias('status')
|
||||
.description('List configured queue intake monitors')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<QueueMonitorListResponse>('/api/queue-monitors');
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printSummary(result);
|
||||
for (const monitor of result.monitors) printMonitor(monitor);
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
|
||||
monitors
|
||||
.command('run <monitorId>')
|
||||
.description('Run a queue intake monitor once')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => runAction(monitorId, 'run', options.json));
|
||||
|
||||
monitors
|
||||
.command('pause <monitorId>')
|
||||
.description('Pause a queue intake monitor')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => runAction(monitorId, 'pause', options.json));
|
||||
|
||||
monitors
|
||||
.command('resume <monitorId>')
|
||||
.description('Resume a queue intake monitor')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => runAction(monitorId, 'resume', options.json));
|
||||
|
||||
monitors
|
||||
.command('health <monitorId>')
|
||||
.description('Show queue monitor health')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => {
|
||||
try {
|
||||
const result = await api<QueueMonitorHealthResult>(
|
||||
`/api/queue-monitors/${encodeURIComponent(monitorId)}/health`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printMonitor(result.monitor);
|
||||
if (result.actionItem) {
|
||||
console.log(chalk.yellow(`Action item: ${result.actionItem.summary}`));
|
||||
console.log(chalk.dim(result.actionItem.remediation));
|
||||
}
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
|
||||
monitors
|
||||
.command('explain <monitorId>')
|
||||
.description('Build a fresh candidate packet and explain the selected action')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (monitorId, options) => {
|
||||
try {
|
||||
const result = await api<QueueMonitorExplainResult>(
|
||||
`/api/queue-monitors/${encodeURIComponent(monitorId)}/explain`
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printMonitor(result.monitor);
|
||||
console.log(chalk.bold('\nSelected'));
|
||||
if (result.packet.selected) {
|
||||
console.log(
|
||||
`${result.packet.selected.repo}#${result.packet.selected.number} ${result.packet.selected.title}`
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.dim('No candidate selected.'));
|
||||
}
|
||||
console.log(chalk.bold('\nAction'));
|
||||
console.log(`${result.action.action}: ${result.action.summary}`);
|
||||
for (const check of result.action.gateChecks) {
|
||||
const color =
|
||||
check.status === 'pass'
|
||||
? chalk.green
|
||||
: check.status === 'warn'
|
||||
? chalk.yellow
|
||||
: chalk.red;
|
||||
console.log(` ${color(check.status)} ${check.name}: ${check.summary}`);
|
||||
}
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
monitorId: string,
|
||||
action: 'run' | 'pause' | 'resume',
|
||||
json: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await api<QueueMonitorRunResult>(
|
||||
`/api/queue-monitors/${encodeURIComponent(monitorId)}/${action}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`${action}: ${result.event.summary}`));
|
||||
if (result.packet.selected) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Selected ${result.packet.selected.repo}#${result.packet.selected.number} ${result.packet.selected.title}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (result.action.skippedReasons.length > 0) {
|
||||
console.log(chalk.yellow(`Skipped: ${result.action.skippedReasons.length}`));
|
||||
}
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary(result: QueueMonitorListResponse): void {
|
||||
console.log(chalk.bold('\nQueue Intake Monitors'));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`total=${result.summary.total} enabled=${result.summary.enabled} due=${result.summary.due} failed=${result.summary.failed} blocked=${result.summary.blocked}`
|
||||
)
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
function printMonitor(monitor: QueueMonitorSnapshot): void {
|
||||
const health =
|
||||
monitor.health === 'healthy'
|
||||
? chalk.green(monitor.health)
|
||||
: monitor.health === 'blocked'
|
||||
? chalk.red(monitor.health)
|
||||
: chalk.yellow(monitor.health);
|
||||
console.log(`${chalk.bold(monitor.id)} ${health}`);
|
||||
console.log(` ${monitor.name}`);
|
||||
console.log(
|
||||
` repo=${monitor.source.repo} mode=${monitor.mode} next=${monitor.nextRunAt ?? 'not set'}`
|
||||
);
|
||||
if (monitor.lastSummary) console.log(chalk.dim(` last=${monitor.lastSummary}`));
|
||||
}
|
||||
|
||||
function printError(err: unknown): never {
|
||||
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
156
cli/src/commands/scheduler.ts
Normal file
156
cli/src/commands/scheduler.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api } from '../utils/api.js';
|
||||
import type {
|
||||
SchedulerDueRunResult,
|
||||
SchedulerItem,
|
||||
SchedulerListResponse,
|
||||
SchedulerRunResult,
|
||||
SchedulerValidationResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
export function registerSchedulerCommands(program: Command): void {
|
||||
const scheduler = program
|
||||
.command('scheduler')
|
||||
.alias('schedule')
|
||||
.description('Inspect and control recurring Veritas work');
|
||||
|
||||
scheduler
|
||||
.command('list')
|
||||
.alias('status')
|
||||
.description('List recurring work scheduler items')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<SchedulerListResponse>('/api/scheduler');
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
printSummary(result);
|
||||
for (const item of result.items) printItem(item);
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('run <itemId>')
|
||||
.description('Run a scheduler item now')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (itemId, options) => {
|
||||
await runItemAction(itemId, 'run', options.json);
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('pause <itemId>')
|
||||
.description('Pause a scheduler item')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (itemId, options) => {
|
||||
await runItemAction(itemId, 'pause', options.json);
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('resume <itemId>')
|
||||
.description('Resume a scheduler item')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (itemId, options) => {
|
||||
await runItemAction(itemId, 'resume', options.json);
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('validate <itemId>')
|
||||
.description('Validate a scheduler item')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (itemId, options) => {
|
||||
try {
|
||||
const result = await api<SchedulerValidationResult>(
|
||||
`/api/scheduler/items/${encodeURIComponent(itemId)}/validate`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`Valid: ${itemId}`));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.yellow(`Validation issues: ${itemId}`));
|
||||
for (const issue of result.issues) {
|
||||
console.log(` ${issue.severity}: ${issue.path} - ${issue.message}`);
|
||||
}
|
||||
process.exitCode = result.issues.some((issue) => issue.severity === 'error') ? 1 : 0;
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
|
||||
scheduler
|
||||
.command('run-due')
|
||||
.description('Run all scheduler items due now')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<SchedulerDueRunResult>('/api/scheduler/due/run', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
chalk.green(
|
||||
`Checked ${result.checked}, executed ${result.executed}, skipped ${result.skipped}, failed ${result.failed}`
|
||||
)
|
||||
);
|
||||
if (result.overlapping) console.log(chalk.yellow('Due runner already active.'));
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runItemAction(
|
||||
itemId: string,
|
||||
action: 'run' | 'pause' | 'resume',
|
||||
json: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await api<SchedulerRunResult>(
|
||||
`/api/scheduler/items/${encodeURIComponent(itemId)}/${action}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`${action}: ${result.event.summary}`));
|
||||
if (result.event.sourceRunId) console.log(chalk.dim(`Run: ${result.event.sourceRunId}`));
|
||||
} catch (err) {
|
||||
printError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary(result: SchedulerListResponse): void {
|
||||
console.log(chalk.bold('\nRecurring Work Scheduler'));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`total=${result.summary.total} enabled=${result.summary.enabled} due=${result.summary.due} failed=${result.summary.failed} blocked=${result.summary.blocked}`
|
||||
)
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
function printItem(item: SchedulerItem): void {
|
||||
const status = item.health === 'healthy' ? chalk.green(item.health) : chalk.yellow(item.health);
|
||||
console.log(`${chalk.bold(item.id)} ${status}`);
|
||||
console.log(` ${item.name}`);
|
||||
console.log(` schedule=${item.trigger.description} next=${item.nextRunAt ?? 'not set'}`);
|
||||
if (item.lastSummary) console.log(chalk.dim(` last=${item.lastSummary}`));
|
||||
}
|
||||
|
||||
function printError(err: unknown): never {
|
||||
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -55,11 +55,10 @@ export function registerSetupCommands(program: Command): void {
|
|||
|
||||
// Step 2: Check if server is running
|
||||
let serverRunning = false;
|
||||
let serverVersion = '';
|
||||
try {
|
||||
const health = await api<HealthResponse>('/api/health');
|
||||
serverRunning = health.ok;
|
||||
serverVersion = health.version;
|
||||
const serverVersion = health.version;
|
||||
results.push({
|
||||
step: 'server',
|
||||
status: 'pass',
|
||||
|
|
@ -135,7 +134,7 @@ This is a sample task created by \`vk setup\`.
|
|||
Use the API to create, update, and manage tasks.
|
||||
\`\`\`
|
||||
3. **Try the CLI** — Run \`vk list\` to see all tasks
|
||||
4. **Archive this task** — When done exploring, run \`vk done ${Date.now()}\`
|
||||
4. **Archive this task** — When done exploring, run \`vk done <task-id>\` with the task ID printed by setup
|
||||
|
||||
## Resources
|
||||
|
||||
|
|
|
|||
679
cli/src/commands/snapshot.ts
Normal file
679
cli/src/commands/snapshot.ts
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { API_BASE, buildApiHeaders } from '../utils/api.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/(token|secret|password|authorization|cookie|credential|apikey|api_key|private)/i;
|
||||
const SENSITIVE_URL_KEY_PATTERN = /(webhook.*url|openclawGatewayUrl|url|destination)/i;
|
||||
|
||||
interface SnapshotOptions {
|
||||
apiBase: string;
|
||||
timeoutMs: number;
|
||||
format: 'json' | 'markdown';
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface SnapshotDependencies {
|
||||
fetch: typeof fetch;
|
||||
env: NodeJS.ProcessEnv;
|
||||
now: () => Date;
|
||||
gitSha: () => Promise<string | null>;
|
||||
cliVersion: () => Promise<string>;
|
||||
}
|
||||
|
||||
interface RequestResult<T> {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: T | null;
|
||||
headers: Headers;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface HealthResponse {
|
||||
ok?: boolean;
|
||||
version?: string;
|
||||
uptimeMs?: number;
|
||||
}
|
||||
|
||||
interface RepoResponse {
|
||||
name?: string;
|
||||
path?: string;
|
||||
defaultBranch?: string;
|
||||
}
|
||||
|
||||
interface ManagedListResponse {
|
||||
id?: string;
|
||||
label?: string;
|
||||
isHidden?: boolean;
|
||||
}
|
||||
|
||||
interface AgentResponse {
|
||||
type?: string;
|
||||
name?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
enabled?: boolean;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface AgentStatusResponse {
|
||||
status?: string;
|
||||
subAgentCount?: number;
|
||||
activeTask?: string;
|
||||
activeTaskTitle?: string;
|
||||
activeAgents?: Array<{
|
||||
agent?: string;
|
||||
status?: string;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
startedAt?: string;
|
||||
}>;
|
||||
lastUpdated?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface RoutingResponse {
|
||||
enabled?: boolean;
|
||||
defaultAgent?: string;
|
||||
defaultModel?: string;
|
||||
fallbackOnFailure?: boolean;
|
||||
rules?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
fallback?: string;
|
||||
enabled?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FeatureSettingsResponse {
|
||||
notifications?: {
|
||||
enabled?: boolean;
|
||||
webhookUrl?: string;
|
||||
onTaskComplete?: boolean;
|
||||
onAgentFailure?: boolean;
|
||||
onReviewNeeded?: boolean;
|
||||
};
|
||||
hooks?: Record<string, unknown>;
|
||||
squadWebhook?: {
|
||||
enabled?: boolean;
|
||||
mode?: string;
|
||||
url?: string;
|
||||
openclawGatewayUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PromptTemplateResponse {
|
||||
id?: string;
|
||||
name?: string;
|
||||
category?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
interface TaskSummaryResponse {
|
||||
id?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
type?: string;
|
||||
project?: string;
|
||||
sprint?: string;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
interface MaintenanceSummaryResponse {
|
||||
mode?: string;
|
||||
storageMode?: string;
|
||||
health?: Array<{ id?: string; state?: string; detail?: string }>;
|
||||
storage?: { totalBytes?: number; categories?: Array<{ id?: string; itemCount?: number }> };
|
||||
logs?: Array<{ id?: string; exists?: boolean; redacted?: boolean }>;
|
||||
}
|
||||
|
||||
export interface RuntimeSnapshot {
|
||||
generatedAt: string;
|
||||
redacted: true;
|
||||
app: {
|
||||
cliVersion: string;
|
||||
serverVersion?: string;
|
||||
gitSha?: string;
|
||||
apiBase: string;
|
||||
apiReachable: boolean;
|
||||
};
|
||||
projects: {
|
||||
repos: Array<{ name: string; defaultBranch?: string; path: string }>;
|
||||
projects: Array<{ id: string; label: string; hidden: boolean }>;
|
||||
sprints: Array<{ id: string; label: string; hidden: boolean }>;
|
||||
};
|
||||
agents: {
|
||||
total: number;
|
||||
enabled: number;
|
||||
items: Array<{
|
||||
type: string;
|
||||
name?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
enabled: boolean;
|
||||
executable?: string;
|
||||
}>;
|
||||
status?: {
|
||||
state: string;
|
||||
subAgentCount: number;
|
||||
activeAgents: number;
|
||||
activeAgentsByStatus: Record<string, number>;
|
||||
activeTaskPresent: boolean;
|
||||
lastUpdated?: string;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
routing: {
|
||||
enabled: boolean;
|
||||
defaultAgent?: string;
|
||||
defaultModel?: string;
|
||||
fallbackOnFailure: boolean;
|
||||
ruleCount: number;
|
||||
rules: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
fallback?: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
};
|
||||
prompts: {
|
||||
count: number;
|
||||
templates: Array<{ id: string; name?: string; category?: string; version?: number }>;
|
||||
};
|
||||
tasks: {
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
byPriority: Record<string, number>;
|
||||
byType: Record<string, number>;
|
||||
duplicateIdentityConflicts: number;
|
||||
};
|
||||
notifications: {
|
||||
notificationsEnabled: boolean;
|
||||
notificationWebhookConfigured: boolean;
|
||||
squadWebhookEnabled: boolean;
|
||||
squadWebhookMode?: string;
|
||||
squadWebhookDestinationConfigured: boolean;
|
||||
lifecycleHooksEnabled: boolean;
|
||||
lifecycleHookActions: number;
|
||||
};
|
||||
health: {
|
||||
maintenanceAvailable: boolean;
|
||||
mode?: string;
|
||||
storageMode?: string;
|
||||
failingChecks: Array<{ id: string; state: string; detail?: string }>;
|
||||
warningChecks: Array<{ id: string; state: string; detail?: string }>;
|
||||
logs: Array<{ id: string; exists: boolean; redacted: boolean }>;
|
||||
};
|
||||
accessIssues: Array<{ section: string; status: number; error: string }>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function unwrapData<T>(body: unknown): T | null {
|
||||
if (isRecord(body) && body.success === true && 'data' in body) {
|
||||
return body.data as T;
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function redactString(value: string, options: { redactUrls?: boolean } = {}): string {
|
||||
let redacted = value
|
||||
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
|
||||
.replace(/\bsk-[A-Za-z0-9_-]{12,}/g, 'sk-[REDACTED]')
|
||||
.replace(/\bvk_[A-Za-z0-9_-]{12,}/g, 'vk_[REDACTED]')
|
||||
.replace(/\b[A-Fa-f0-9]{32,}\b/g, '[REDACTED_TOKEN]')
|
||||
.replace(/\/Users\/[^/\s]+\/[^\s)]+/g, '[redacted-local-path]')
|
||||
.replace(/[A-Z]:\\Users\\[^\\\s]+\\[^\s)]+/g, '[redacted-local-path]');
|
||||
|
||||
if (options.redactUrls !== false) {
|
||||
redacted = redacted.replace(/https?:\/\/[^\s)]+/gi, (match) => redactUrl(match));
|
||||
}
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function redactUrl(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return `${parsed.protocol}//${parsed.host}/[redacted]`;
|
||||
} catch {
|
||||
return '[redacted-url]';
|
||||
}
|
||||
}
|
||||
|
||||
function redactApiBase(value: string): string {
|
||||
const cleaned = redactString(value, { redactUrls: false });
|
||||
try {
|
||||
const parsed = new URL(cleaned);
|
||||
const hasPrivateParts = parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '';
|
||||
return hasPrivateParts ? `${parsed.origin}/[redacted]` : parsed.origin;
|
||||
} catch {
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
function redactValue(value: unknown, key = ''): unknown {
|
||||
if (typeof value === 'string') {
|
||||
if (key === 'apiBase') return redactApiBase(value);
|
||||
if (SENSITIVE_KEY_PATTERN.test(key)) return '[REDACTED]';
|
||||
if (SENSITIVE_URL_KEY_PATTERN.test(key) && key !== 'apiBase') return redactUrl(value);
|
||||
return redactString(value, { redactUrls: true });
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((item) => redactValue(item));
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([entryKey, entryValue]) => [
|
||||
entryKey,
|
||||
redactValue(entryValue, entryKey),
|
||||
])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function executableName(command: string | undefined): string | undefined {
|
||||
return command?.trim().split(/\s+/)[0] || undefined;
|
||||
}
|
||||
|
||||
function countBy<T>(items: T[], key: keyof T): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const item of items) {
|
||||
const raw = item[key];
|
||||
const value = typeof raw === 'string' && raw ? raw : 'unknown';
|
||||
counts[value] = (counts[value] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function normalizeApiBase(value: string): string {
|
||||
const trimmed = value.trim().replace(/\/+$/, '');
|
||||
try {
|
||||
return new URL(trimmed).origin;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function activeLifecycleHookActions(settings: FeatureSettingsResponse | null): number {
|
||||
if (!settings?.hooks || settings.hooks.enabled !== true) return 0;
|
||||
return Object.entries(settings.hooks).filter(([key, value]) => {
|
||||
if (key === 'enabled' || !isRecord(value)) return false;
|
||||
return value.enabled === true && (value.webhook || value.notify);
|
||||
}).length;
|
||||
}
|
||||
|
||||
async function defaultGitSha(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], {
|
||||
timeout: 3000,
|
||||
});
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultCliVersion(): Promise<string> {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL('../../package.json', import.meta.url), 'utf-8')
|
||||
) as { version?: string };
|
||||
return packageJson.version ?? '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
deps: SnapshotDependencies,
|
||||
options: SnapshotOptions,
|
||||
pathName: string
|
||||
): Promise<RequestResult<T>> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await deps.fetch(`${options.apiBase}${pathName}`, {
|
||||
headers: buildApiHeaders(undefined, deps.env.VK_API_KEY),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
const body = text ? JSON.parse(text) : null;
|
||||
const data = response.ok ? unwrapData<T>(body) : null;
|
||||
const error =
|
||||
!response.ok && isRecord(body)
|
||||
? String(
|
||||
isRecord(body.error)
|
||||
? (body.error.message ?? response.statusText)
|
||||
: (body.error ?? body.message ?? response.statusText)
|
||||
)
|
||||
: undefined;
|
||||
return { ok: response.ok, status: response.status, data, headers: response.headers, error };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: null,
|
||||
headers: new Headers(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function recordIssue(
|
||||
issues: RuntimeSnapshot['accessIssues'],
|
||||
section: string,
|
||||
response: RequestResult<unknown>
|
||||
): void {
|
||||
if (response.ok) return;
|
||||
issues.push({
|
||||
section,
|
||||
status: response.status,
|
||||
error: redactString(response.error ?? 'request failed', { redactUrls: true }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildRuntimeSnapshot(
|
||||
input: Partial<SnapshotOptions> = {},
|
||||
depsInput: Partial<SnapshotDependencies> = {}
|
||||
): Promise<RuntimeSnapshot> {
|
||||
const options: SnapshotOptions = {
|
||||
apiBase: normalizeApiBase(input.apiBase ?? API_BASE),
|
||||
timeoutMs: input.timeoutMs ?? 5000,
|
||||
format: input.format ?? 'json',
|
||||
output: input.output,
|
||||
};
|
||||
const deps: SnapshotDependencies = {
|
||||
fetch: depsInput.fetch ?? globalThis.fetch.bind(globalThis),
|
||||
env: depsInput.env ?? process.env,
|
||||
now: depsInput.now ?? (() => new Date()),
|
||||
gitSha: depsInput.gitSha ?? defaultGitSha,
|
||||
cliVersion: depsInput.cliVersion ?? defaultCliVersion,
|
||||
};
|
||||
const accessIssues: RuntimeSnapshot['accessIssues'] = [];
|
||||
|
||||
const [
|
||||
cliVersion,
|
||||
gitSha,
|
||||
health,
|
||||
repos,
|
||||
projects,
|
||||
sprints,
|
||||
agents,
|
||||
agentStatus,
|
||||
routing,
|
||||
settings,
|
||||
prompts,
|
||||
tasks,
|
||||
maintenance,
|
||||
] = await Promise.all([
|
||||
deps.cliVersion(),
|
||||
deps.gitSha(),
|
||||
requestJson<HealthResponse>(deps, options, '/api/health'),
|
||||
requestJson<RepoResponse[]>(deps, options, '/api/config/repos'),
|
||||
requestJson<ManagedListResponse[]>(deps, options, '/api/projects'),
|
||||
requestJson<ManagedListResponse[]>(deps, options, '/api/sprints'),
|
||||
requestJson<AgentResponse[]>(deps, options, '/api/config/agents'),
|
||||
requestJson<AgentStatusResponse>(deps, options, '/api/agent/status'),
|
||||
requestJson<RoutingResponse>(deps, options, '/api/agents/routing'),
|
||||
requestJson<FeatureSettingsResponse>(deps, options, '/api/settings/features'),
|
||||
requestJson<PromptTemplateResponse[]>(deps, options, '/api/prompt-registry'),
|
||||
requestJson<TaskSummaryResponse[]>(deps, options, '/api/tasks?view=summary'),
|
||||
requestJson<MaintenanceSummaryResponse>(deps, options, '/api/maintenance/summary'),
|
||||
]);
|
||||
|
||||
recordIssue(accessIssues, 'health', health);
|
||||
recordIssue(accessIssues, 'repos', repos);
|
||||
recordIssue(accessIssues, 'projects', projects);
|
||||
recordIssue(accessIssues, 'sprints', sprints);
|
||||
recordIssue(accessIssues, 'agents', agents);
|
||||
recordIssue(accessIssues, 'agent-status', agentStatus);
|
||||
recordIssue(accessIssues, 'routing', routing);
|
||||
recordIssue(accessIssues, 'settings', settings);
|
||||
recordIssue(accessIssues, 'prompts', prompts);
|
||||
recordIssue(accessIssues, 'tasks', tasks);
|
||||
recordIssue(accessIssues, 'maintenance', maintenance);
|
||||
|
||||
const agentItems = (agents.data ?? []).map((agent) => ({
|
||||
type: agent.type ?? 'unknown',
|
||||
name: agent.name,
|
||||
provider: agent.provider,
|
||||
model: agent.model,
|
||||
enabled: Boolean(agent.enabled),
|
||||
executable: executableName(agent.command),
|
||||
}));
|
||||
const taskItems = tasks.data ?? [];
|
||||
const settingsData = settings.data;
|
||||
const maintenanceData = maintenance.data;
|
||||
const maintenanceHealth = maintenanceData?.health ?? [];
|
||||
const activeAgents = agentStatus.data?.activeAgents ?? [];
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
generatedAt: deps.now().toISOString(),
|
||||
redacted: true,
|
||||
app: {
|
||||
cliVersion,
|
||||
...(health.data?.version ? { serverVersion: health.data.version } : {}),
|
||||
...(gitSha ? { gitSha } : {}),
|
||||
apiBase: options.apiBase,
|
||||
apiReachable: health.ok && (health.data?.ok ?? true),
|
||||
},
|
||||
projects: {
|
||||
repos: (repos.data ?? []).map((repo) => ({
|
||||
name: repo.name ?? 'unknown',
|
||||
defaultBranch: repo.defaultBranch,
|
||||
path: '[redacted path]',
|
||||
})),
|
||||
projects: (projects.data ?? []).map((project) => ({
|
||||
id: project.id ?? 'unknown',
|
||||
label: project.label ?? project.id ?? 'unknown',
|
||||
hidden: Boolean(project.isHidden),
|
||||
})),
|
||||
sprints: (sprints.data ?? []).map((sprint) => ({
|
||||
id: sprint.id ?? 'unknown',
|
||||
label: sprint.label ?? sprint.id ?? 'unknown',
|
||||
hidden: Boolean(sprint.isHidden),
|
||||
})),
|
||||
},
|
||||
agents: {
|
||||
total: agentItems.length,
|
||||
enabled: agentItems.filter((agent) => agent.enabled).length,
|
||||
items: agentItems,
|
||||
...(agentStatus.data
|
||||
? {
|
||||
status: {
|
||||
state: agentStatus.data.status ?? 'unknown',
|
||||
subAgentCount: agentStatus.data.subAgentCount ?? 0,
|
||||
activeAgents: activeAgents.length,
|
||||
activeAgentsByStatus: countBy(activeAgents, 'status'),
|
||||
activeTaskPresent: Boolean(agentStatus.data.activeTask),
|
||||
lastUpdated: agentStatus.data.lastUpdated,
|
||||
error: agentStatus.data.error,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
routing: {
|
||||
enabled: Boolean(routing.data?.enabled),
|
||||
defaultAgent: routing.data?.defaultAgent,
|
||||
defaultModel: routing.data?.defaultModel,
|
||||
fallbackOnFailure: Boolean(routing.data?.fallbackOnFailure),
|
||||
ruleCount: routing.data?.rules?.length ?? 0,
|
||||
rules: (routing.data?.rules ?? []).map((rule) => ({
|
||||
id: rule.id ?? 'unknown',
|
||||
name: rule.name,
|
||||
agent: rule.agent,
|
||||
model: rule.model,
|
||||
fallback: rule.fallback,
|
||||
enabled: rule.enabled !== false,
|
||||
})),
|
||||
},
|
||||
prompts: {
|
||||
count: prompts.data?.length ?? 0,
|
||||
templates: (prompts.data ?? []).map((template) => ({
|
||||
id: template.id ?? 'unknown',
|
||||
name: template.name,
|
||||
category: template.category,
|
||||
version: template.version,
|
||||
})),
|
||||
},
|
||||
tasks: {
|
||||
total: taskItems.length,
|
||||
byStatus: countBy(taskItems, 'status'),
|
||||
byPriority: countBy(taskItems, 'priority'),
|
||||
byType: countBy(taskItems, 'type'),
|
||||
duplicateIdentityConflicts: Number(
|
||||
tasks.headers.get('x-veritas-task-identity-conflicts') ?? '0'
|
||||
),
|
||||
},
|
||||
notifications: {
|
||||
notificationsEnabled: Boolean(settingsData?.notifications?.enabled),
|
||||
notificationWebhookConfigured: Boolean(settingsData?.notifications?.webhookUrl),
|
||||
squadWebhookEnabled: Boolean(settingsData?.squadWebhook?.enabled),
|
||||
squadWebhookMode: settingsData?.squadWebhook?.mode,
|
||||
squadWebhookDestinationConfigured: Boolean(
|
||||
settingsData?.squadWebhook?.url || settingsData?.squadWebhook?.openclawGatewayUrl
|
||||
),
|
||||
lifecycleHooksEnabled: Boolean(settingsData?.hooks?.enabled),
|
||||
lifecycleHookActions: activeLifecycleHookActions(settingsData),
|
||||
},
|
||||
health: {
|
||||
maintenanceAvailable: maintenance.ok,
|
||||
mode: maintenanceData?.mode,
|
||||
storageMode: maintenanceData?.storageMode,
|
||||
failingChecks: maintenanceHealth
|
||||
.filter((item) => item.state === 'fail')
|
||||
.map((item) => ({
|
||||
id: item.id ?? 'unknown',
|
||||
state: item.state ?? 'unknown',
|
||||
detail: item.detail,
|
||||
})),
|
||||
warningChecks: maintenanceHealth
|
||||
.filter((item) => item.state === 'warn' || item.state === 'unknown')
|
||||
.map((item) => ({
|
||||
id: item.id ?? 'unknown',
|
||||
state: item.state ?? 'unknown',
|
||||
detail: item.detail,
|
||||
})),
|
||||
logs: (maintenanceData?.logs ?? []).map((log) => ({
|
||||
id: log.id ?? 'unknown',
|
||||
exists: Boolean(log.exists),
|
||||
redacted: log.redacted !== false,
|
||||
})),
|
||||
},
|
||||
accessIssues,
|
||||
};
|
||||
|
||||
return redactValue(snapshot) as RuntimeSnapshot;
|
||||
}
|
||||
|
||||
export function formatRuntimeSnapshotMarkdown(snapshot: RuntimeSnapshot): string {
|
||||
const lines = [
|
||||
'# Veritas Runtime Snapshot',
|
||||
'',
|
||||
`Generated: ${snapshot.generatedAt}`,
|
||||
`Redacted: ${snapshot.redacted ? 'yes' : 'no'}`,
|
||||
'',
|
||||
'## App',
|
||||
'',
|
||||
`- CLI version: ${snapshot.app.cliVersion}`,
|
||||
`- Server version: ${snapshot.app.serverVersion ?? 'unknown'}`,
|
||||
`- Git SHA: ${snapshot.app.gitSha ?? 'unknown'}`,
|
||||
`- API reachable: ${snapshot.app.apiReachable ? 'yes' : 'no'}`,
|
||||
'',
|
||||
'## Tasks',
|
||||
'',
|
||||
`- Total: ${snapshot.tasks.total}`,
|
||||
`- Duplicate identity conflicts: ${snapshot.tasks.duplicateIdentityConflicts}`,
|
||||
`- By status: ${JSON.stringify(snapshot.tasks.byStatus)}`,
|
||||
'',
|
||||
'## Agents',
|
||||
'',
|
||||
`- Total: ${snapshot.agents.total}`,
|
||||
`- Enabled: ${snapshot.agents.enabled}`,
|
||||
`- Global status: ${snapshot.agents.status?.state ?? 'unknown'}`,
|
||||
`- Active agents: ${snapshot.agents.status?.activeAgents ?? 0}`,
|
||||
`- Routing enabled: ${snapshot.routing.enabled ? 'yes' : 'no'}`,
|
||||
`- Routing rules: ${snapshot.routing.ruleCount}`,
|
||||
'',
|
||||
'## Prompts',
|
||||
'',
|
||||
`- Runtime templates: ${snapshot.prompts.count}`,
|
||||
'',
|
||||
'## Notifications',
|
||||
'',
|
||||
`- Notifications enabled: ${snapshot.notifications.notificationsEnabled ? 'yes' : 'no'}`,
|
||||
`- Notification webhook configured: ${
|
||||
snapshot.notifications.notificationWebhookConfigured ? 'yes' : 'no'
|
||||
}`,
|
||||
`- Squad webhook enabled: ${snapshot.notifications.squadWebhookEnabled ? 'yes' : 'no'}`,
|
||||
`- Lifecycle hook actions: ${snapshot.notifications.lifecycleHookActions}`,
|
||||
'',
|
||||
'## Health',
|
||||
'',
|
||||
`- Maintenance summary available: ${snapshot.health.maintenanceAvailable ? 'yes' : 'no'}`,
|
||||
`- Failing checks: ${snapshot.health.failingChecks.length}`,
|
||||
`- Warning checks: ${snapshot.health.warningChecks.length}`,
|
||||
'',
|
||||
];
|
||||
|
||||
if (snapshot.accessIssues.length > 0) {
|
||||
lines.push('## Access Issues', '');
|
||||
for (const issue of snapshot.accessIssues) {
|
||||
lines.push(`- ${issue.section}: ${issue.status} ${issue.error}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function parseFormat(value: string): SnapshotOptions['format'] {
|
||||
if (value === 'json' || value === 'markdown') return value;
|
||||
throw new Error('Snapshot format must be json or markdown');
|
||||
}
|
||||
|
||||
export function registerSnapshotCommand(program: Command): void {
|
||||
program
|
||||
.command('snapshot')
|
||||
.description('Export a redacted runtime support snapshot')
|
||||
.option('--format <format>', 'Output format: json or markdown', 'json')
|
||||
.option('--output <path>', 'Write snapshot to a file instead of stdout')
|
||||
.option('--api <url>', 'API base URL', API_BASE)
|
||||
.option('--timeout <ms>', 'Per-request timeout in milliseconds', '5000')
|
||||
.action(async (options) => {
|
||||
const timeoutMs = Number(options.timeout);
|
||||
const format = parseFormat(options.format);
|
||||
const snapshot = await buildRuntimeSnapshot({
|
||||
apiBase: options.api,
|
||||
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : 5000,
|
||||
format,
|
||||
output: options.output,
|
||||
});
|
||||
const rendered =
|
||||
format === 'markdown'
|
||||
? formatRuntimeSnapshotMarkdown(snapshot)
|
||||
: `${JSON.stringify(snapshot, null, 2)}\n`;
|
||||
|
||||
if (options.output) {
|
||||
const outputPath = path.resolve(options.output);
|
||||
await writeFile(outputPath, rendered, 'utf-8');
|
||||
console.log(chalk.green(`Snapshot written to ${path.basename(outputPath)}`));
|
||||
} else {
|
||||
process.stdout.write(rendered);
|
||||
}
|
||||
});
|
||||
}
|
||||
178
cli/src/commands/sqlite.ts
Normal file
178
cli/src/commands/sqlite.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import type {
|
||||
SqliteJournalOperationStatus,
|
||||
SqliteJournalPolicySummary,
|
||||
SqliteJournalPreview,
|
||||
SqliteJournalTarget,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
interface StatusResponse {
|
||||
operation?: SqliteJournalOperationStatus;
|
||||
policy?: SqliteJournalPolicySummary;
|
||||
}
|
||||
|
||||
function printError(error: unknown): void {
|
||||
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function printOperation(operation: SqliteJournalOperationStatus): void {
|
||||
const state = operation.recoveryRequired
|
||||
? chalk.red(operation.state)
|
||||
: operation.state === 'completed'
|
||||
? chalk.green(operation.state)
|
||||
: chalk.yellow(operation.state);
|
||||
console.log(`${chalk.bold(operation.id)} ${state}`);
|
||||
console.log(` ${operation.originalMode} -> ${operation.targetMode}`);
|
||||
console.log(` backup: ${operation.backupAvailable ? 'verified' : 'not created'}`);
|
||||
console.log(` restart required: ${operation.restartRequired ? 'yes' : 'no'}`);
|
||||
if (operation.errorCode) console.log(chalk.red(` error: ${operation.errorCode}`));
|
||||
}
|
||||
|
||||
export function registerSqliteCommands(program: Command): void {
|
||||
const sqlite = program
|
||||
.command('sqlite')
|
||||
.description('SQLite storage diagnostics and maintenance');
|
||||
const journal = sqlite
|
||||
.command('journal')
|
||||
.description('Preview and schedule journal-mode changes');
|
||||
|
||||
journal
|
||||
.command('preview')
|
||||
.requiredOption('--target <mode>', 'Target journal mode: wal or delete')
|
||||
.option('--single-host', 'Acknowledge single-host compatibility mode')
|
||||
.option(
|
||||
'--override-reason <reason>',
|
||||
'Required justification for compatibility or override mode'
|
||||
)
|
||||
.option('--expires-at <timestamp>', 'ISO timestamp when the override expires')
|
||||
.option('--json', 'Output stable JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (!['wal', 'delete'].includes(options.target)) {
|
||||
throw new Error('--target must be wal or delete');
|
||||
}
|
||||
const preview = await api<SqliteJournalPreview>('/api/maintenance/sqlite/journal/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetMode: options.target as SqliteJournalTarget,
|
||||
singleHost: options.singleHost || undefined,
|
||||
overrideReason: options.overrideReason,
|
||||
expiresAt: options.expiresAt,
|
||||
}),
|
||||
});
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(preview, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold(`SQLite journal preview ${preview.id}`));
|
||||
console.log(` mode: ${preview.currentMode} -> ${preview.targetMode}`);
|
||||
console.log(` filesystem: ${preview.filesystemType} (${preview.filesystemPosture})`);
|
||||
console.log(
|
||||
` ownership: ${preview.ownershipState}; active connections: ${preview.activeConnectionCount}`
|
||||
);
|
||||
console.log(` backup: ${preview.backupLocation}`);
|
||||
for (const sidecar of preview.sidecars) {
|
||||
console.log(
|
||||
` ${sidecar.kind}: ${sidecar.present ? `${sidecar.bytes} bytes (${sidecar.fileType})` : 'absent'}`
|
||||
);
|
||||
}
|
||||
console.log(chalk.yellow('Risks:'));
|
||||
for (const risk of preview.risks) console.log(` - ${risk}`);
|
||||
console.log(chalk.bold('\nSchedule after review:'));
|
||||
console.log(
|
||||
` vk sqlite journal apply --preview-id ${preview.id} --preview-token ${preview.token} --confirm ${preview.id} --acknowledge-risks`
|
||||
);
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
journal
|
||||
.command('apply')
|
||||
.requiredOption('--preview-id <id>', 'Preview operation ID')
|
||||
.requiredOption('--preview-token <token>', 'One-time preview token')
|
||||
.requiredOption('--confirm <id>', 'Repeat the preview ID to confirm')
|
||||
.option('--acknowledge-risks', 'Acknowledge the previewed risks')
|
||||
.option('--json', 'Output stable JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (!options.acknowledgeRisks || options.confirm !== options.previewId) {
|
||||
throw new Error('Apply requires --acknowledge-risks and --confirm matching --preview-id');
|
||||
}
|
||||
const operation = await api<SqliteJournalOperationStatus>(
|
||||
'/api/maintenance/sqlite/journal/apply',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
previewId: options.previewId,
|
||||
previewToken: options.previewToken,
|
||||
confirm: options.confirm,
|
||||
acknowledgeRisks: true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (options.json) console.log(JSON.stringify(operation, null, 2));
|
||||
else {
|
||||
printOperation(operation);
|
||||
console.log(chalk.yellow('Restart the server to execute the scheduled conversion.'));
|
||||
}
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
journal
|
||||
.command('status [operationId]')
|
||||
.option('--json', 'Output stable JSON')
|
||||
.action(async (operationId, options) => {
|
||||
try {
|
||||
if (operationId) {
|
||||
const operation = await api<SqliteJournalOperationStatus>(
|
||||
`/api/maintenance/sqlite/journal/operations/${encodeURIComponent(operationId)}`
|
||||
);
|
||||
if (options.json) console.log(JSON.stringify(operation, null, 2));
|
||||
else printOperation(operation);
|
||||
return;
|
||||
}
|
||||
const status = await api<StatusResponse>('/api/maintenance/sqlite/journal/status');
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
if (status.operation) printOperation(status.operation);
|
||||
else console.log(chalk.dim('No SQLite journal operation is scheduled.'));
|
||||
if (status.policy) {
|
||||
console.log(
|
||||
`Policy: ${status.policy.source} ${status.policy.status}; expires ${status.policy.expiresAt}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
|
||||
journal
|
||||
.command('override')
|
||||
.description('Manage SQLite journal overrides')
|
||||
.command('revoke')
|
||||
.requiredOption('--reason <reason>', 'Revocation reason')
|
||||
.option('--json', 'Output stable JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (String(options.reason).trim().length < 8) {
|
||||
throw new Error('--reason must be at least 8 characters');
|
||||
}
|
||||
const policy = await api<SqliteJournalPolicySummary>(
|
||||
'/api/maintenance/sqlite/journal/override/revoke',
|
||||
{ method: 'POST', body: JSON.stringify({ reason: options.reason }) }
|
||||
);
|
||||
if (options.json) console.log(JSON.stringify(policy, null, 2));
|
||||
else console.log(chalk.yellow(`Revoked ${policy.id}; restart is required.`));
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api, API_BASE } from '../utils/api.js';
|
||||
import { api, API_BASE, assertApiPermissionForRequest, buildApiHeaders } from '../utils/api.js';
|
||||
|
||||
export function registerSummaryCommands(program: Command): void {
|
||||
// Create summary parent command with subcommands
|
||||
|
|
@ -22,10 +22,9 @@ export function registerSummaryCommands(program: Command): void {
|
|||
console.log(chalk.bold('\n📊 Veritas Kanban Summary\n'));
|
||||
|
||||
console.log(chalk.dim('Status:'));
|
||||
console.log(` To Do: ${summary.byStatus.todo}`);
|
||||
console.log(` In Progress: ${summary.byStatus['in-progress']}`);
|
||||
console.log(` Blocked: ${summary.byStatus.blocked}`);
|
||||
console.log(` Done: ${summary.byStatus.done}`);
|
||||
Object.entries(summary.byStatus).forEach(([status, count]) => {
|
||||
console.log(` ${status}: ${count}`);
|
||||
});
|
||||
|
||||
const projects = Object.entries(summary.byProject) as [
|
||||
string,
|
||||
|
|
@ -88,9 +87,15 @@ export function registerSummaryCommands(program: Command): void {
|
|||
const standup = await api<unknown>(`/api/summary/standup?date=${dateParam}&format=json`);
|
||||
console.log(JSON.stringify(standup, null, 2));
|
||||
} else {
|
||||
await assertApiPermissionForRequest(
|
||||
`/api/summary/standup?date=${dateParam}&format=${format}`
|
||||
);
|
||||
// Fetch markdown or text directly
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/summary/standup?date=${dateParam}&format=${format}`
|
||||
`${API_BASE}/api/summary/standup?date=${dateParam}&format=${format}`,
|
||||
{
|
||||
headers: buildApiHeaders({ accept: 'text/plain, text/markdown, application/json' }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => ({ error: res.statusText }));
|
||||
|
|
@ -118,7 +123,14 @@ export function registerSummaryCommands(program: Command): void {
|
|||
const recent = await api<unknown>(`/api/summary/recent?hours=${options.hours}`);
|
||||
console.log(JSON.stringify(recent, null, 2));
|
||||
} else {
|
||||
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`);
|
||||
await assertApiPermissionForRequest(`/api/summary/memory?hours=${options.hours}`);
|
||||
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`, {
|
||||
headers: buildApiHeaders({ accept: 'text/markdown, text/plain, application/json' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error((errorBody as { error?: string }).error || `API error: ${res.status}`);
|
||||
}
|
||||
const markdown = await res.text();
|
||||
|
||||
if (options.output) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function registerTaskCommands(program: Command): void {
|
|||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List tasks')
|
||||
.option('-s, --status <status>', 'Filter by status (todo, in-progress, blocked, done)')
|
||||
.option('-s, --status <status>', 'Filter by status')
|
||||
.option('-t, --type <type>', 'Filter by type (code, research, content, automation)')
|
||||
.option('-p, --project <project>', 'Filter by project')
|
||||
.option('-S, --sprint <sprint>', 'Filter by sprint')
|
||||
|
|
@ -93,6 +93,8 @@ export function registerTaskCommands(program: Command): void {
|
|||
.option('-S, --sprint <sprint>', 'Sprint name or ID')
|
||||
.option('-d, --description <desc>', 'Task description')
|
||||
.option('--priority <priority>', 'Priority (low, medium, high)', 'medium')
|
||||
.option('-s, --status <status>', 'Initial status')
|
||||
.option('--commit-policy <policy>', 'Task commit policy (forbidden, allowed, or required)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (title, options) => {
|
||||
try {
|
||||
|
|
@ -105,6 +107,10 @@ export function registerTaskCommands(program: Command): void {
|
|||
sprint: options.sprint,
|
||||
description: options.description || '',
|
||||
priority: options.priority,
|
||||
status: options.status,
|
||||
executionPolicy: options.commitPolicy
|
||||
? { commitPolicy: options.commitPolicy }
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -130,6 +136,7 @@ export function registerTaskCommands(program: Command): void {
|
|||
.option('-S, --sprint <sprint>', 'Sprint name or ID')
|
||||
.option('--priority <priority>', 'New priority')
|
||||
.option('--title <title>', 'New title')
|
||||
.option('--commit-policy <policy>', 'Task commit policy (forbidden, allowed, or required)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
|
|
@ -147,6 +154,9 @@ export function registerTaskCommands(program: Command): void {
|
|||
if (options.sprint) updates.sprint = options.sprint;
|
||||
if (options.priority) updates.priority = options.priority;
|
||||
if (options.title) updates.title = options.title;
|
||||
if (options.commitPolicy) {
|
||||
updates.executionPolicy = { commitPolicy: options.commitPolicy };
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.error(chalk.yellow('No updates specified'));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Command } from 'commander';
|
|||
import chalk from 'chalk';
|
||||
import { api } from '../utils/api.js';
|
||||
import { findTask } from '../utils/find.js';
|
||||
import type { Task } from '../utils/types.js';
|
||||
|
||||
function formatDuration(totalSeconds: number): string {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
|
|
|
|||
230
cli/src/commands/tool-servers.ts
Normal file
230
cli/src/commands/tool-servers.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import type {
|
||||
RunToolCatalog,
|
||||
ToolInvocationResult,
|
||||
ToolServerDefinition,
|
||||
ToolServerDefinitionInput,
|
||||
ToolServerDiscovery,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export function registerToolServerCommands(program: Command): void {
|
||||
const servers = program
|
||||
.command('tool-servers')
|
||||
.alias('tools')
|
||||
.description('Manage run-scoped MCP and tool servers');
|
||||
|
||||
servers
|
||||
.command('list')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
await execute(async () => {
|
||||
const definitions = await api<ToolServerDefinition[]>('/api/tool-servers');
|
||||
if (options.json) return printJson(definitions);
|
||||
for (const definition of definitions) {
|
||||
console.log(
|
||||
`${chalk.bold(definition.id)} ${definition.enabled ? chalk.green('enabled') : chalk.yellow('disabled')} ${definition.version} ${definition.transport.kind}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('get <id>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, _options) => {
|
||||
await execute(async () => {
|
||||
const definition = await api<ToolServerDefinition>(
|
||||
`/api/tool-servers/${encodeURIComponent(id)}`
|
||||
);
|
||||
printJson(definition);
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('create <file>')
|
||||
.description('Create a definition from a JSON file')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
await execute(async () => {
|
||||
const input = JSON.parse(readFileSync(file, 'utf8')) as ToolServerDefinitionInput;
|
||||
const definition = await api<ToolServerDefinition>('/api/tool-servers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (options.json) return printJson(definition);
|
||||
console.log(chalk.green(`Created ${definition.id}@${definition.version}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('update <id> <file>')
|
||||
.description('Replace a definition from a JSON file')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, file, options) => {
|
||||
await execute(async () => {
|
||||
const input = JSON.parse(readFileSync(file, 'utf8')) as ToolServerDefinitionInput;
|
||||
const definition = await api<ToolServerDefinition>(
|
||||
`/api/tool-servers/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
);
|
||||
if (options.json) return printJson(definition);
|
||||
console.log(chalk.green(`Updated ${definition.id}@${definition.version}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('delete <id>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
await execute(async () => {
|
||||
const result = await api<{ deleted: string }>(
|
||||
`/api/tool-servers/${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (options.json) return printJson(result);
|
||||
console.log(chalk.green(`Deleted ${result.deleted}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('enable <id>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
await execute(async () => {
|
||||
const updated = await setDefinitionEnabled(id, true);
|
||||
if (options.json) return printJson(updated);
|
||||
console.log(chalk.green(`Enabled ${updated.id}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('disable <id>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
await execute(async () => {
|
||||
const updated = await setDefinitionEnabled(id, false);
|
||||
if (options.json) return printJson(updated);
|
||||
console.log(chalk.green(`Disabled ${updated.id}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('version <id> <version>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, version, options) => {
|
||||
await execute(async () => {
|
||||
const current = await getDefinition(id);
|
||||
const updated = await replaceDefinition(id, { ...current, version });
|
||||
if (options.json) return printJson(updated);
|
||||
console.log(chalk.green(`Versioned ${updated.id}@${updated.version}`));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('discover <id>')
|
||||
.option('--force', 'Ignore a matching discovery cache entry')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
await execute(async () => {
|
||||
const discovery = await api<ToolServerDiscovery>(
|
||||
`/api/tool-servers/${encodeURIComponent(id)}/discover`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ force: options.force === true }),
|
||||
}
|
||||
);
|
||||
if (options.json) return printJson(discovery);
|
||||
console.log(
|
||||
`${discovery.status === 'ready' ? chalk.green('ready') : chalk.red('failed')} ${discovery.serverId}@${discovery.serverVersion} tools=${discovery.tools.length}`
|
||||
);
|
||||
if (discovery.error) console.log(chalk.red(discovery.error));
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('catalog <taskId> <attemptId>')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (taskId, attemptId) => {
|
||||
await execute(async () => {
|
||||
printJson(
|
||||
await api<RunToolCatalog>(
|
||||
`/api/tool-servers/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(attemptId)}/catalog`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
servers
|
||||
.command('call <taskId> <attemptId> <serverId> <tool>')
|
||||
.requiredOption('--arguments <json>', 'JSON object of tool arguments')
|
||||
.option('--operation-id <id>', 'Stable caller operation ID', randomUUID())
|
||||
.option('--approval-id <id>', 'Approved run approval ID')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (taskId, attemptId, serverId, tool, options) => {
|
||||
await execute(async () => {
|
||||
const result = await api<ToolInvocationResult>('/api/tool-servers/call', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
taskId,
|
||||
attemptId,
|
||||
serverId,
|
||||
tool,
|
||||
arguments: JSON.parse(options.arguments),
|
||||
operationId: options.operationId,
|
||||
approvalId: options.approvalId,
|
||||
}),
|
||||
});
|
||||
if (options.json) return printJson(result);
|
||||
console.log(
|
||||
`${result.isError ? chalk.red('error') : chalk.green('complete')} ${result.serverId}/${result.tool} event=${result.eventId}`
|
||||
);
|
||||
printJson(result.content);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function execute(action: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(value: unknown): void {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
async function getDefinition(id: string): Promise<ToolServerDefinition> {
|
||||
return api<ToolServerDefinition>(`/api/tool-servers/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async function replaceDefinition(
|
||||
id: string,
|
||||
definition: ToolServerDefinition
|
||||
): Promise<ToolServerDefinition> {
|
||||
const {
|
||||
schemaVersion: _schemaVersion,
|
||||
digest: _digest,
|
||||
createdAt: _createdAt,
|
||||
updatedAt: _updatedAt,
|
||||
...input
|
||||
} = definition;
|
||||
return api<ToolServerDefinition>(`/api/tool-servers/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input satisfies ToolServerDefinitionInput),
|
||||
});
|
||||
}
|
||||
|
||||
async function setDefinitionEnabled(id: string, enabled: boolean): Promise<ToolServerDefinition> {
|
||||
const current = await getDefinition(id);
|
||||
return replaceDefinition(id, { ...current, enabled });
|
||||
}
|
||||
|
|
@ -250,7 +250,7 @@ async function displayTaskUsage(taskId: string, period: string, json: boolean):
|
|||
}
|
||||
|
||||
export function registerUsageCommands(program: Command): void {
|
||||
const usage = program
|
||||
program
|
||||
.command('usage')
|
||||
.description('Display usage statistics (tokens, costs, time)')
|
||||
.option(
|
||||
|
|
|
|||
201
cli/src/commands/workspaces.ts
Normal file
201
cli/src/commands/workspaces.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { api } from '../utils/api.js';
|
||||
import type {
|
||||
WorkspaceCapabilityDiscoveryResult,
|
||||
WorkspaceCapabilityFormat,
|
||||
WorkspaceCapabilityRegistrationResult,
|
||||
WorkspaceCapabilityValidationResult,
|
||||
WorkspaceDelegatedWorkIntakeResult,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
function inferFormat(filePath: string): WorkspaceCapabilityFormat {
|
||||
return path.extname(filePath).toLowerCase() === '.json' ? 'json' : 'yaml';
|
||||
}
|
||||
|
||||
function contextField(
|
||||
value: string,
|
||||
previous: Record<string, string> = {}
|
||||
): Record<string, string> {
|
||||
const index = value.indexOf('=');
|
||||
if (index === -1) {
|
||||
throw new Error('Context fields must use key=value format');
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
[value.slice(0, index).trim()]: value.slice(index + 1).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerWorkspaceCommands(program: Command): void {
|
||||
const workspaces = program
|
||||
.command('workspaces')
|
||||
.alias('workspace')
|
||||
.description('Workspace capability discovery and delegated intake');
|
||||
|
||||
workspaces
|
||||
.command('discover')
|
||||
.description('List local and trusted workspace capability manifests')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<WorkspaceCapabilityDiscoveryResult>(
|
||||
'/api/workspace-capabilities/discover'
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.local) {
|
||||
console.log(chalk.bold(`\nLocal: ${result.local.name}`));
|
||||
console.log(chalk.dim(` ${result.local.workspaceId}`));
|
||||
for (const capability of result.local.capabilities) {
|
||||
console.log(` - ${capability.id}: ${capability.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`\nTrusted Workspaces (${result.trusted.length})`));
|
||||
if (result.trusted.length === 0) {
|
||||
console.log(chalk.dim(' No trusted workspace manifests registered.'));
|
||||
}
|
||||
for (const workspace of result.trusted) {
|
||||
console.log(` ${chalk.cyan(workspace.workspaceId)} ${workspace.name}`);
|
||||
for (const capability of workspace.capabilities) {
|
||||
console.log(
|
||||
` - ${capability.id}: ${capability.acceptedTaskTypes.join(', ') || 'any'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaces
|
||||
.command('validate <file>')
|
||||
.description('Validate a workspace capability manifest YAML or JSON file')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const result = await api<WorkspaceCapabilityValidationResult>(
|
||||
'/api/workspace-capabilities/manifest/validate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, format: inferFormat(file), source: file }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.valid) {
|
||||
console.log(chalk.green(`Valid workspace manifest: ${result.manifest?.workspaceId}`));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.red('Invalid workspace manifest'));
|
||||
for (const issue of result.issues) {
|
||||
console.log(chalk.dim(` ${issue.path}: ${issue.message}`));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaces
|
||||
.command('trust <file>')
|
||||
.description('Register a trusted peer workspace manifest')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (file, options) => {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const result = await api<WorkspaceCapabilityRegistrationResult>(
|
||||
'/api/workspace-capabilities/trusted',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, format: inferFormat(file), source: file }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
chalk.green(
|
||||
`${result.created ? 'Registered' : 'Updated'} trusted workspace: ${result.manifest.name}`
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workspaces
|
||||
.command('intake')
|
||||
.description('Create delegated work intake in this workspace')
|
||||
.requiredOption('--source-workspace <id>', 'Source workspace ID')
|
||||
.requiredOption('--capability <id>', 'Target capability ID')
|
||||
.requiredOption('--title <title>', 'Delegated work title')
|
||||
.requiredOption('--context <text>', 'Delegated work context')
|
||||
.option('--source-name <name>', 'Source workspace display name')
|
||||
.option('--source-task <id>', 'Originating task ID')
|
||||
.option('--source-task-url <url>', 'Originating task URL')
|
||||
.option('--repository <repo>', 'Source repository')
|
||||
.option('--issue-url <url>', 'Source issue URL')
|
||||
.option('--type <type>', 'Task type')
|
||||
.option('--project <project>', 'Target project')
|
||||
.option('--priority <priority>', 'Target priority')
|
||||
.option('--label <label...>', 'Delegation labels')
|
||||
.option('--context-field <key=value>', 'Required context field', contextField, {})
|
||||
.option('--requested-by <actor>', 'Requester actor')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<WorkspaceDelegatedWorkIntakeResult>(
|
||||
'/api/workspace-capabilities/intake',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
source: {
|
||||
workspaceId: options.sourceWorkspace,
|
||||
workspaceName: options.sourceName,
|
||||
taskId: options.sourceTask,
|
||||
taskUrl: options.sourceTaskUrl,
|
||||
repository: options.repository,
|
||||
issueUrl: options.issueUrl,
|
||||
},
|
||||
capabilityId: options.capability,
|
||||
title: options.title,
|
||||
context: options.context,
|
||||
contextFields: options.contextField,
|
||||
labels: options.label,
|
||||
priority: options.priority,
|
||||
project: options.project,
|
||||
type: options.type,
|
||||
requestedBy: options.requestedBy,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.green(`Created delegated task: ${result.taskId}`));
|
||||
console.log(chalk.dim(`Delegation: ${result.record.id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
import { Command } from 'commander';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { registerTaskCommands } from './commands/tasks.js';
|
||||
import { registerBacklogCommands } from './commands/backlog.js';
|
||||
import { registerAgentCommands } from './commands/agents.js';
|
||||
|
|
@ -15,13 +16,27 @@ import { registerWorkflowCommands } from './commands/workflow.js';
|
|||
import { registerSetupCommands } from './commands/setup.js';
|
||||
import { registerUsageCommands } from './commands/usage.js';
|
||||
import { registerSprintCommands } from './commands/sprints.js';
|
||||
import { registerDoctorCommand } from './commands/doctor.js';
|
||||
import { registerSnapshotCommand } from './commands/snapshot.js';
|
||||
import { registerPromptCommands } from './commands/prompts.js';
|
||||
import { registerWorkspaceCommands } from './commands/workspaces.js';
|
||||
import { registerSchedulerCommands } from './commands/scheduler.js';
|
||||
import { registerQueueMonitorCommands } from './commands/queue-monitors.js';
|
||||
import { registerSqliteCommands } from './commands/sqlite.js';
|
||||
import { registerToolServerCommands } from './commands/tool-servers.js';
|
||||
import { registerAcpCommands } from './commands/acp.js';
|
||||
import { registerAdmissionCommands } from './commands/admission.js';
|
||||
import { registerGoalCommands } from './commands/goals.js';
|
||||
|
||||
const program = new Command();
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
|
||||
) as { version?: string };
|
||||
|
||||
program
|
||||
.name('vk')
|
||||
.description('Veritas Kanban CLI - Task management for AI agents')
|
||||
.version('0.1.0');
|
||||
.version(packageJson.version ?? '0.0.0');
|
||||
|
||||
// Register all command groups
|
||||
registerTaskCommands(program);
|
||||
|
|
@ -39,5 +54,16 @@ registerWorkflowCommands(program);
|
|||
registerSetupCommands(program);
|
||||
registerUsageCommands(program);
|
||||
registerSprintCommands(program);
|
||||
registerDoctorCommand(program);
|
||||
registerSnapshotCommand(program);
|
||||
registerPromptCommands(program);
|
||||
registerWorkspaceCommands(program);
|
||||
registerSchedulerCommands(program);
|
||||
registerQueueMonitorCommands(program);
|
||||
registerSqliteCommands(program);
|
||||
registerToolServerCommands(program);
|
||||
registerAcpCommands(program);
|
||||
registerAdmissionCommands(program);
|
||||
registerGoalCommands(program);
|
||||
|
||||
program.parse();
|
||||
|
|
|
|||
|
|
@ -1,2 +1,26 @@
|
|||
// Re-export shared API client
|
||||
export { api, createApiClient, API_BASE } from '@veritas-kanban/shared';
|
||||
// Re-export shared API helpers with CLI permission preflight enabled.
|
||||
import {
|
||||
API_BASE,
|
||||
createApiClient,
|
||||
createApiPermissionGuard,
|
||||
createGuardedApiClient,
|
||||
type ClientAuthContext,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
export {
|
||||
API_BASE,
|
||||
ClientPermissionError,
|
||||
buildApiHeaders,
|
||||
createApiClient,
|
||||
createGuardedApiClient,
|
||||
getApiPermissionRequirement,
|
||||
type ClientAuthContext,
|
||||
type ClientAuthPermission,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
export const api = createGuardedApiClient(API_BASE);
|
||||
|
||||
const contextApi = createApiClient(API_BASE);
|
||||
export const assertApiPermissionForRequest = createApiPermissionGuard(() =>
|
||||
contextApi<ClientAuthContext>('/api/auth/context')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,7 @@
|
|||
// Re-export shared findTask
|
||||
export { findTask } from '@veritas-kanban/shared';
|
||||
import { findTask as findTaskWithClient } from '@veritas-kanban/shared';
|
||||
import type { Task } from './types.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
export function findTask(id: string): Promise<Task | null> {
|
||||
return findTaskWithClient(id, api);
|
||||
}
|
||||
|
|
|
|||
25
cli/vitest.config.ts
Normal file
25
cli/vitest.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.d.ts',
|
||||
'src/__tests__/**',
|
||||
'src/**/__fixtures__/**',
|
||||
'src/**/fixtures/**',
|
||||
'src/**/generated/**',
|
||||
'src/**/*.generated.*',
|
||||
'src/**/types.ts',
|
||||
'src/types/**/*.ts',
|
||||
],
|
||||
all: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -4,8 +4,13 @@
|
|||
# Port the demo UI will be accessible on (host side)
|
||||
DEMO_PORT=3099
|
||||
|
||||
# Admin API key for seeding data
|
||||
# Host interface for the published demo port.
|
||||
# Keep 127.0.0.1 for local demos. Do not use 0.0.0.0 unless auth is enabled
|
||||
# and credentials are replaced.
|
||||
DEMO_BIND=127.0.0.1
|
||||
|
||||
# Throwaway admin API key for local demo seeding only.
|
||||
VERITAS_ADMIN_KEY=demo-admin-key-2026
|
||||
|
||||
# Set to true to disable auth entirely (easier for demos)
|
||||
# Set false only for local loopback demos. Set true before any non-loopback access.
|
||||
VERITAS_AUTH_ENABLED=false
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ docker compose -f demo/docker-compose.demo.yml up --build
|
|||
|
||||
Then open **http://localhost:3099**
|
||||
|
||||
The demo binds to `127.0.0.1` and disables auth by default. Keep it local. For LAN, tunnel, VPS, or reverse-proxy access, set `VERITAS_AUTH_ENABLED=true`, replace `VERITAS_ADMIN_KEY`, and intentionally set `DEMO_BIND` to the required interface.
|
||||
|
||||
## What's Included
|
||||
|
||||
The demo seeds realistic data showcasing VK's features:
|
||||
|
|
@ -34,11 +36,12 @@ Copy `.env.example` to `.env` to customize:
|
|||
cp demo/.env.example demo/.env
|
||||
```
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------------- | --------------------- | -------------------------- |
|
||||
| `DEMO_PORT` | `3099` | Host port for the UI |
|
||||
| `VERITAS_ADMIN_KEY` | `demo-admin-key-2026` | API admin key |
|
||||
| `VERITAS_AUTH_ENABLED` | `false` | Set `true` to require auth |
|
||||
| Variable | Default | Description |
|
||||
| ---------------------- | --------------------- | ------------------------------------------------ |
|
||||
| `DEMO_PORT` | `3099` | Host port for the UI |
|
||||
| `DEMO_BIND` | `127.0.0.1` | Host interface for the published port |
|
||||
| `VERITAS_ADMIN_KEY` | `demo-admin-key-2026` | Throwaway local demo key |
|
||||
| `VERITAS_AUTH_ENABLED` | `false` | Set `true` before any non-loopback demo exposure |
|
||||
|
||||
## Reset Demo Data
|
||||
|
||||
|
|
@ -58,3 +61,13 @@ docker compose -f demo/docker-compose.demo.yml up --build
|
|||
4. The sidecar exits; VK keeps running with seeded data
|
||||
|
||||
Data persists in a Docker volume (`demo-data`) across restarts. The seed script is idempotent — it skips if tasks already exist.
|
||||
|
||||
## Validate Compose Output
|
||||
|
||||
Before changing bind/auth settings, inspect the generated config:
|
||||
|
||||
```bash
|
||||
docker compose -f demo/docker-compose.demo.yml config
|
||||
```
|
||||
|
||||
The default `ports` output should include `127.0.0.1:3099:3001`.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
# Then open: http://localhost:3099
|
||||
#
|
||||
# Automatically seeds demo data on first run via the seed sidecar.
|
||||
#
|
||||
# LOCAL DEMO ONLY:
|
||||
# - Defaults to DEMO_BIND=127.0.0.1 and VERITAS_AUTH_ENABLED=false.
|
||||
# - Do not set DEMO_BIND=0.0.0.0 unless auth is enabled and credentials are replaced.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
|
|
@ -15,12 +19,14 @@ services:
|
|||
container_name: vk-demo
|
||||
working_dir: /app/server
|
||||
ports:
|
||||
- '${DEMO_PORT:-3099}:3001'
|
||||
- '${DEMO_BIND:-127.0.0.1}:${DEMO_PORT:-3099}:3001'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- DATA_DIR=/app/data
|
||||
# Fixed local demo credential. Replace before enabling non-loopback access.
|
||||
- VERITAS_ADMIN_KEY=${VERITAS_ADMIN_KEY:-demo-admin-key-2026}
|
||||
# Auth-disabled mode is safe only with DEMO_BIND left at 127.0.0.1.
|
||||
- VERITAS_AUTH_ENABLED=${VERITAS_AUTH_ENABLED:-false}
|
||||
- VERITAS_AUTH_LOCALHOST_BYPASS=true
|
||||
- VERITAS_AUTH_LOCALHOST_ROLE=admin
|
||||
|
|
|
|||
113
desktop/README.md
Normal file
113
desktop/README.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Veritas Kanban Desktop
|
||||
|
||||
This package is the v6 native desktop application. It uses Electron with
|
||||
electron-vite, starts the existing Veritas server as the local backend, and
|
||||
loads the existing web UI.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm desktop:dev
|
||||
pnpm desktop:dev:fresh
|
||||
```
|
||||
|
||||
`desktop:dev` launches a loopback-only local server and a Vite web renderer
|
||||
without requiring a separate terminal. The desktop runtime chooses available
|
||||
ports and isolates data by profile and workspace:
|
||||
`.veritas-desktop-dev/profiles/<profile>/workspaces/<workspace>/`.
|
||||
|
||||
`desktop:dev:fresh` uses the `fresh` profile so onboarding and startup behavior
|
||||
can be tested without reusing the default development home.
|
||||
|
||||
## Runtime Boundaries
|
||||
|
||||
- Electron main owns window lifecycle, process supervision, app paths, native
|
||||
URL opening, status pages, and future native capabilities.
|
||||
- Closing the last desktop window quits the app and stops supervised local
|
||||
processes. Native menu/background behavior belongs in the dedicated menus
|
||||
work.
|
||||
- The renderer uses the existing Veritas web app and has no Node, filesystem,
|
||||
process, or secret access.
|
||||
- The preload bridge exposes only typed desktop operations. The current v6
|
||||
contract covers app/setup diagnostics, local server lifecycle, connection
|
||||
validation, update status, native command dispatch, upload/import picking,
|
||||
diagnostics bundles, notification actions, work product export, external URL
|
||||
opening, and desktop event subscriptions.
|
||||
- Bridge methods, event channels, validation, and redaction live in the shared
|
||||
desktop bridge contract module so main and preload cannot drift silently.
|
||||
- Dangerous bridge methods require typed request objects and contract validators
|
||||
before native execution. Unsupported native features return explicit
|
||||
placeholder results until their dedicated roadmap issues implement the backing
|
||||
behavior.
|
||||
- Fresh packaged installs store desktop data below the OS app data directory
|
||||
returned by Electron `app.getPath('userData')`, then under
|
||||
`profiles/<profile>/workspaces/<workspace>/`.
|
||||
- Packaged desktop installs detect a populated desktop SQLite database and offer
|
||||
**Use Existing Data** so setup can secure it without replacing board records
|
||||
or imported owner metadata. Do not choose recovery import when the expected
|
||||
records are already present. Packaged installs do not automatically import an
|
||||
external web/source checkout. Use
|
||||
[`docs/WEB-TO-MAC-DESKTOP-MIGRATION.md`](../docs/WEB-TO-MAC-DESKTOP-MIGRATION.md)
|
||||
for the already-populated case or when moving file-backed `tasks/` and
|
||||
`.veritas-kanban/` data from a repo server into the Mac app's SQLite
|
||||
workspace.
|
||||
- Desktop runtime secrets are created through Electron `safeStorage`, which uses
|
||||
the OS credential backend on macOS. The encrypted metadata file lives at
|
||||
`<appHome>/config/desktop-secrets.json`; plaintext admin/JWT secrets are only
|
||||
passed to the supervised local server process environment.
|
||||
- Legacy desktop data is copied forward into the profile/workspace app home when
|
||||
a new isolated app home is first initialized. The legacy source is left in
|
||||
place for manual rollback.
|
||||
- Local development mode disables app auth only for the supervised loopback
|
||||
runtime. Packaged mode keeps auth enabled and uses the keychain-backed
|
||||
bootstrap secrets for admin and JWT signing. Local automation that talks to
|
||||
the packaged app must send `X-API-Key` or `Authorization: Bearer` rather than
|
||||
assuming unauthenticated localhost writes.
|
||||
|
||||
## Recovery Notes
|
||||
|
||||
If Keychain or encrypted desktop secret state breaks, quit the app, move
|
||||
`desktop-secrets.json` out of the affected workspace `config` directory, and
|
||||
restart. The app will regenerate the desktop bootstrap secrets for that
|
||||
profile/workspace. Existing database files, exports, backups, and debug bundles
|
||||
remain on disk in the workspace app home.
|
||||
|
||||
## Native Commands
|
||||
|
||||
The desktop shell owns a single command registry for menu items, keyboard
|
||||
shortcuts, deep links, notification actions, and renderer bridge dispatch. Menu
|
||||
commands are forwarded to the renderer through typed bridge events when the web
|
||||
app owns the business logic, and handled in main only for native operations such
|
||||
as restarting the local server, opening logs, checking update status, showing a
|
||||
local notification test, copying redacted diagnostics, and quitting.
|
||||
|
||||
Supported `veritas://` deep-link resources include task, workflow, run,
|
||||
invite/pairing, settings, command center, search, and work product destinations.
|
||||
Notification previews support a private mode that replaces task/run details
|
||||
with generic copy while preserving the durable target for click-through.
|
||||
|
||||
Window size, position, and maximized state are persisted per profile/workspace
|
||||
in `config/window-state.json`.
|
||||
|
||||
## Release Packaging
|
||||
|
||||
Unsigned PR artifacts, macOS signed/notarized release artifacts, update
|
||||
metadata, and platform smoke steps are documented in
|
||||
[`docs/DESKTOP-RELEASE.md`](../docs/DESKTOP-RELEASE.md). Use the root
|
||||
`desktop:package:mac:unsigned` script for Mac release validation. The
|
||||
`desktop:package:linux:unsigned` and `desktop:package:windows:unsigned` scripts
|
||||
produce preview-only, non-GA artifacts for post-GA readiness checks. Use
|
||||
`desktop:release:mac` for stable macOS releases; use `desktop:release:linux` or
|
||||
`desktop:release:windows` only after the platform release prerequisites in the
|
||||
release guide are promoted.
|
||||
|
||||
## Production Scaffold
|
||||
|
||||
`pnpm desktop:build` compiles the Electron main, preload, and fallback renderer.
|
||||
The build then verifies that main and preload import Electron from the native
|
||||
runtime and do not contain Electron's npm installer shim. Run the artifact
|
||||
check directly with `pnpm desktop:check:electron-artifacts` when inspecting an
|
||||
existing build. Packaging, signing, notarization, updater metadata, and bundled
|
||||
server/web asset layout follow the release workflow documented above. Packaged
|
||||
mode expects a built server entry at `server/dist/index.js` unless
|
||||
`VERITAS_DESKTOP_SERVER_ENTRY` is provided.
|
||||
67
desktop/electron.vite.config.ts
Normal file
67
desktop/electron.vite.config.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { resolve } from 'node:path';
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||
|
||||
const electronRuntimeExternal = ['electron', /^electron\/.+/];
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
define: {
|
||||
__VERITAS_BUILD_SHA__: JSON.stringify(
|
||||
process.env.VERITAS_BUILD_SHA ?? process.env.GITHUB_SHA ?? ''
|
||||
),
|
||||
__VERITAS_RELEASE_CHANNEL__: JSON.stringify(process.env.VERITAS_UPDATE_CHANNEL ?? ''),
|
||||
},
|
||||
build: {
|
||||
// Vite 8 builds with Rolldown. Electron Vite 5 still places its built-in
|
||||
// runtime externals under rollupOptions, which Rolldown does not consume.
|
||||
// Keep Electron explicitly external so the emitted main process receives
|
||||
// Electron's runtime API instead of bundling the npm executable-path shim.
|
||||
rolldownOptions: {
|
||||
external: electronRuntimeExternal,
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
external: electronRuntimeExternal,
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/preload/index.ts'),
|
||||
},
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].cjs',
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/preload/index.ts'),
|
||||
},
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].cjs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: resolve(__dirname, 'src/renderer'),
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
177
desktop/package.json
Normal file
177
desktop/package.json
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
{
|
||||
"name": "@veritas-kanban/desktop",
|
||||
"version": "6.1.2",
|
||||
"private": true,
|
||||
"homepage": "https://github.com/BradGroux/veritas-kanban",
|
||||
"description": "Veritas Kanban native desktop shell",
|
||||
"author": "Brad Groux <brad@digitalmeld.io>",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.1",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:fresh": "VERITAS_DESKTOP_PROFILE=fresh electron-vite dev",
|
||||
"build": "electron-vite build && pnpm --dir .. desktop:check:electron-artifacts",
|
||||
"package:prepare": "node ../scripts/prepare-desktop-release.mjs",
|
||||
"package:mac:dir": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --mac dir --publish never --config.mac.identity=null --config.mac.notarize=false",
|
||||
"package:mac:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --mac dmg zip --publish never --config.mac.identity=null --config.mac.notarize=false",
|
||||
"package:linux:dir": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --linux dir --x64 --publish never",
|
||||
"package:linux:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --linux AppImage deb rpm --x64 --publish never",
|
||||
"package:win:dir": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --win dir --x64 --publish never",
|
||||
"package:win:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --win nsis zip --x64 --publish never",
|
||||
"release:mac": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --mac dmg zip --publish always",
|
||||
"release:linux": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --linux AppImage deb rpm --x64 --publish always",
|
||||
"release:win": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --win nsis zip --x64 --publish always",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src electron.vite.config.ts --ext .ts",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"clean": "rm -rf dist out .veritas-desktop-dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@veritas-kanban/shared": "workspace:*",
|
||||
"electron-updater": "^6.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
"electron": "^43.4.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"build": {
|
||||
"appId": "io.digitalmeld.veritas-kanban",
|
||||
"productName": "Veritas Kanban",
|
||||
"executableName": "veritas-kanban",
|
||||
"artifactName": "Veritas-Kanban-${version}-${os}-${arch}.${ext}",
|
||||
"asar": true,
|
||||
"afterPack": "scripts/desktop-after-pack.mjs",
|
||||
"directories": {
|
||||
"buildResources": "resources",
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"out/**/*",
|
||||
"resources/**/*",
|
||||
"package.json",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": ".desktop-release/server",
|
||||
"to": "server"
|
||||
},
|
||||
{
|
||||
"from": ".desktop-release/web",
|
||||
"to": "web"
|
||||
}
|
||||
],
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Veritas Kanban",
|
||||
"schemes": [
|
||||
"veritas"
|
||||
],
|
||||
"role": "Viewer"
|
||||
}
|
||||
],
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "resources/icon.icns",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "resources/entitlements.mac.plist",
|
||||
"entitlementsInherit": "resources/entitlements.mac.inherit.plist",
|
||||
"notarize": true
|
||||
},
|
||||
"dmg": {
|
||||
"sign": true,
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"deb": {
|
||||
"packageName": "veritas-kanban"
|
||||
},
|
||||
"rpm": {
|
||||
"packageName": "veritas-kanban"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "resources/icon.png",
|
||||
"category": "Office",
|
||||
"maintainer": "Digital Meld <support@digitalmeld.io>",
|
||||
"synopsis": "Local-first task management and AI agent orchestration",
|
||||
"description": "Veritas Kanban packages the local board, API server, and desktop runtime for local-first task management and AI agent orchestration.",
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "rpm",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "resources/icon.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"verifyUpdateCodeSignature": true
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Veritas Kanban"
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "BradGroux",
|
||||
"repo": "veritas-kanban",
|
||||
"releaseType": "release"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
14
desktop/resources/entitlements.mac.inherit.plist
Normal file
14
desktop/resources/entitlements.mac.inherit.plist
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
12
desktop/resources/entitlements.mac.plist
Normal file
12
desktop/resources/entitlements.mac.plist
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
desktop/resources/icon.icns
Normal file
BIN
desktop/resources/icon.icns
Normal file
Binary file not shown.
BIN
desktop/resources/icon.ico
Normal file
BIN
desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
BIN
desktop/resources/icon.png
Normal file
BIN
desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 951 KiB |
1
desktop/scripts/desktop-after-pack.mjs
Normal file
1
desktop/scripts/desktop-after-pack.mjs
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from '../../scripts/desktop-after-pack.mjs';
|
||||
490
desktop/src/main/__tests__/bridge-contracts.test.ts
Normal file
490
desktop/src/main/__tests__/bridge-contracts.test.ts
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IpcMain, Shell } from 'electron';
|
||||
|
||||
const mockLookup = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: mockLookup,
|
||||
}));
|
||||
|
||||
import {
|
||||
createDesktopBridgeHandlers,
|
||||
registerDesktopBridge,
|
||||
type DesktopBridgeHandlerMap,
|
||||
} from '../bridge.js';
|
||||
import type { DesktopRuntime } from '../runtime.js';
|
||||
import type { DesktopStatusSnapshot } from '../types.js';
|
||||
import {
|
||||
assertDesktopBridgeMethodAvailable,
|
||||
createDesktopBridgeEventCleanup,
|
||||
createDesktopSetupDiagnostics,
|
||||
createDesktopSupportSnapshot,
|
||||
DESKTOP_BRIDGE_CAPABILITIES,
|
||||
DESKTOP_BRIDGE_EVENT_NAMES,
|
||||
DESKTOP_BRIDGE_EVENTS,
|
||||
DESKTOP_BRIDGE_METHOD_NAMES,
|
||||
DESKTOP_BRIDGE_METHOD_VALIDATORS,
|
||||
DESKTOP_BRIDGE_METHODS,
|
||||
DESKTOP_COMMAND_NAMES,
|
||||
DESKTOP_FILE_PICKER_PURPOSES,
|
||||
DESKTOP_PRELOAD_API_METHODS,
|
||||
DESKTOP_PRELOAD_EVENT_METHODS,
|
||||
DESKTOP_REDACTED_VALUE,
|
||||
DESKTOP_RESTART_CONFIRMATION,
|
||||
redactDesktopBridgeError,
|
||||
redactDesktopBridgeValue,
|
||||
validateConnectionConfigRequest,
|
||||
validateDesktopCommandDispatchRequest,
|
||||
validateDiagnosticsBundleRequest,
|
||||
validateFilePickerRequest,
|
||||
validateNotificationActionRequest,
|
||||
validateOpenExternalRequest,
|
||||
validateRestartLocalServerRequest,
|
||||
validateWorkProductExportRequest,
|
||||
} from '../../shared/desktop-bridge-contracts.js';
|
||||
|
||||
function snapshot(): DesktopStatusSnapshot {
|
||||
return {
|
||||
mode: 'local-dev',
|
||||
profile: 'fresh',
|
||||
workspace: 'local',
|
||||
server: {
|
||||
name: 'server',
|
||||
state: 'ready',
|
||||
pid: 123,
|
||||
port: 3001,
|
||||
lastError: null,
|
||||
startedAt: '2026-05-31T00:00:00.000Z',
|
||||
exitedAt: null,
|
||||
},
|
||||
web: {
|
||||
name: 'web',
|
||||
state: 'ready',
|
||||
pid: 124,
|
||||
port: 3000,
|
||||
lastError: null,
|
||||
startedAt: '2026-05-31T00:00:00.000Z',
|
||||
exitedAt: null,
|
||||
},
|
||||
serverOrigin: 'http://127.0.0.1:3001',
|
||||
rendererOrigin: 'http://127.0.0.1:3000',
|
||||
appHome: '/Users/bradgroux/Projects/veritas-kanban/.veritas-desktop-dev/fresh',
|
||||
dataDir: '/Users/bradgroux/Projects/veritas-kanban/.veritas-desktop-dev/fresh/data',
|
||||
configDir: '/Users/bradgroux/Projects/veritas-kanban/.veritas-desktop-dev/fresh/config',
|
||||
logsDir: '/Users/bradgroux/Projects/veritas-kanban/.veritas-desktop-dev/fresh/logs',
|
||||
secretsBackedByKeychain: true,
|
||||
warnings: [],
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(): DesktopRuntime {
|
||||
const currentSnapshot = snapshot();
|
||||
return {
|
||||
snapshot: vi.fn(() => currentSnapshot),
|
||||
restartLocalServer: vi.fn(async () => currentSnapshot),
|
||||
} as unknown as DesktopRuntime;
|
||||
}
|
||||
|
||||
function shell(): Shell {
|
||||
return {
|
||||
openExternal: vi.fn(async () => undefined),
|
||||
} as unknown as Shell;
|
||||
}
|
||||
|
||||
function handlers(): DesktopBridgeHandlerMap {
|
||||
return createDesktopBridgeHandlers(runtime(), shell(), false, '6.0.1');
|
||||
}
|
||||
|
||||
describe('desktop bridge contracts', () => {
|
||||
beforeEach(() => {
|
||||
mockLookup.mockReset();
|
||||
mockLookup.mockResolvedValue([{ address: '203.0.113.10', family: 4 }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps contract registries and name lists in sync', () => {
|
||||
expect(Object.keys(DESKTOP_BRIDGE_METHODS).sort()).toEqual(
|
||||
[...DESKTOP_BRIDGE_METHOD_NAMES].sort()
|
||||
);
|
||||
expect(Object.keys(DESKTOP_BRIDGE_EVENTS).sort()).toEqual(
|
||||
[...DESKTOP_BRIDGE_EVENT_NAMES].sort()
|
||||
);
|
||||
expect(Object.keys(handlers()).sort()).toEqual([...DESKTOP_BRIDGE_METHOD_NAMES].sort());
|
||||
});
|
||||
|
||||
it('registers exactly one native handler for each declared bridge method', () => {
|
||||
const registered = new Map<string, unknown>();
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: unknown) => {
|
||||
registered.set(channel, handler);
|
||||
}),
|
||||
} as unknown as IpcMain;
|
||||
|
||||
registerDesktopBridge(ipcMain, runtime(), shell(), false, '6.0.1');
|
||||
|
||||
expect([...registered.keys()].sort()).toEqual(
|
||||
DESKTOP_BRIDGE_METHOD_NAMES.map((method) => DESKTOP_BRIDGE_METHODS[method].channel).sort()
|
||||
);
|
||||
expect(registered.size).toBe(DESKTOP_BRIDGE_METHOD_NAMES.length);
|
||||
});
|
||||
|
||||
it('reports the Electron application version through the desktop bridge', () => {
|
||||
const bridgeHandlers = createDesktopBridgeHandlers(runtime(), shell(), true, '6.0.1');
|
||||
|
||||
expect(bridgeHandlers.getAppInfo(undefined)).toMatchObject({
|
||||
name: 'Veritas Kanban',
|
||||
version: '6.0.1',
|
||||
channel: 'stable',
|
||||
arch: process.arch,
|
||||
osVersion: expect.any(String),
|
||||
packaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the preload API method list aligned to invoke and event contracts', () => {
|
||||
expect(DESKTOP_PRELOAD_API_METHODS).toEqual([
|
||||
...DESKTOP_BRIDGE_METHOD_NAMES,
|
||||
...Object.values(DESKTOP_PRELOAD_EVENT_METHODS),
|
||||
]);
|
||||
|
||||
for (const method of DESKTOP_BRIDGE_METHOD_NAMES) {
|
||||
expect(DESKTOP_BRIDGE_CAPABILITIES).toContain(DESKTOP_BRIDGE_METHODS[method].capability);
|
||||
}
|
||||
|
||||
for (const event of DESKTOP_BRIDGE_EVENT_NAMES) {
|
||||
expect(DESKTOP_BRIDGE_CAPABILITIES).toContain(DESKTOP_BRIDGE_EVENTS[event].capability);
|
||||
expect(DESKTOP_PRELOAD_API_METHODS).toContain(DESKTOP_PRELOAD_EVENT_METHODS[event]);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires validators for every dangerous bridge method', () => {
|
||||
for (const method of DESKTOP_BRIDGE_METHOD_NAMES) {
|
||||
if (DESKTOP_BRIDGE_METHODS[method].dangerous) {
|
||||
expect(DESKTOP_BRIDGE_METHODS[method].validator).toBeDefined();
|
||||
expect(DESKTOP_BRIDGE_METHOD_VALIDATORS[method]).toEqual(expect.any(Function));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks desktop-only bridge methods from unsupported client modes', () => {
|
||||
expect(() => assertDesktopBridgeMethodAvailable('openExternal', 'desktop')).not.toThrow();
|
||||
expect(() => assertDesktopBridgeMethodAvailable('openExternal', 'browser')).toThrow(
|
||||
'desktop client'
|
||||
);
|
||||
expect(() => assertDesktopBridgeMethodAvailable('restartLocalServer', 'mobile')).toThrow(
|
||||
'desktop client'
|
||||
);
|
||||
});
|
||||
|
||||
it('validates dangerous openExternal requests before shell execution', async () => {
|
||||
const fakeShell = shell();
|
||||
const bridgeHandlers: DesktopBridgeHandlerMap = createDesktopBridgeHandlers(
|
||||
runtime(),
|
||||
fakeShell,
|
||||
false,
|
||||
'6.0.1'
|
||||
);
|
||||
|
||||
await expect(
|
||||
bridgeHandlers.openExternal({ url: 'file:///Users/bradgroux/.ssh/id_ed25519' })
|
||||
).rejects.toThrow('protocol is not allowed');
|
||||
await expect(bridgeHandlers.openExternal('https://example.com' as never)).rejects.toThrow(
|
||||
'typed request object'
|
||||
);
|
||||
await expect(
|
||||
bridgeHandlers.openExternal({ url: 'https://user:pass@example.com' })
|
||||
).rejects.toThrow('credentials are not allowed');
|
||||
|
||||
await bridgeHandlers.openExternal({ url: 'https://example.com/docs' });
|
||||
|
||||
expect(fakeShell.openExternal).toHaveBeenCalledTimes(1);
|
||||
expect(fakeShell.openExternal).toHaveBeenCalledWith('https://example.com/docs');
|
||||
});
|
||||
|
||||
it('validates restart confirmation before restarting the local server', async () => {
|
||||
const fakeRuntime = runtime();
|
||||
const bridgeHandlers = createDesktopBridgeHandlers(fakeRuntime, shell(), false, '6.0.1');
|
||||
|
||||
expect(() => bridgeHandlers.restartLocalServer({ confirmation: 'restart' } as never)).toThrow(
|
||||
'explicit restart confirmation'
|
||||
);
|
||||
|
||||
await bridgeHandlers.restartLocalServer({ confirmation: DESKTOP_RESTART_CONFIRMATION });
|
||||
|
||||
expect(fakeRuntime.restartLocalServer).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
validateRestartLocalServerRequest({ confirmation: DESKTOP_RESTART_CONFIRMATION })
|
||||
).toEqual({
|
||||
confirmation: DESKTOP_RESTART_CONFIRMATION,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates connection config without accepting credentials or unsupported protocols', () => {
|
||||
expect(validateConnectionConfigRequest({ mode: 'local' })).toEqual({ mode: 'local' });
|
||||
expect(
|
||||
validateConnectionConfigRequest({
|
||||
mode: 'remote',
|
||||
serverUrl: ' https://example.com/veritas ',
|
||||
serverToken: 'vk_pat_secret',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
).toEqual({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://example.com/veritas',
|
||||
serverToken: 'vk_pat_secret',
|
||||
workspaceId: 'workspace-1',
|
||||
});
|
||||
expect(() =>
|
||||
validateConnectionConfigRequest({ mode: 'remote', serverUrl: 'http://example.com' })
|
||||
).toThrow('must use HTTPS');
|
||||
expect(() =>
|
||||
validateConnectionConfigRequest({ mode: 'remote', serverUrl: 'https://user@example.com' })
|
||||
).toThrow('credentials are not allowed');
|
||||
expect(() =>
|
||||
validateConnectionConfigRequest({ mode: 'remote', serverUrl: 'https://10.0.0.1' })
|
||||
).toThrow('private IPv4 address');
|
||||
expect(() =>
|
||||
validateConnectionConfigRequest({ mode: 'remote', serverUrl: 'https://169.254.169.254' })
|
||||
).toThrow('link-local address');
|
||||
expect(() =>
|
||||
validateConnectionConfigRequest({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://metadata.google.internal',
|
||||
})
|
||||
).toThrow('cloud metadata destination');
|
||||
expect(() =>
|
||||
validateConnectionConfigRequest({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://example.com',
|
||||
serverToken: 'bad\ntoken',
|
||||
})
|
||||
).toThrow('cannot contain newlines');
|
||||
});
|
||||
|
||||
it('validates remote connection reachability in the desktop process', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify({ authenticated: false }))
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await handlers().validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://remote.example/veritas',
|
||||
serverToken: 'vk_pat_secret',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mode: 'remote',
|
||||
valid: true,
|
||||
normalizedServerUrl: 'https://remote.example/veritas',
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://remote.example/api/auth/context');
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
headers: { Authorization: 'Bearer vk_pat_secret' },
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks remote connection destinations that resolve to private addresses before fetch', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
mockLookup.mockResolvedValue([{ address: '192.168.1.10', family: 4 }]);
|
||||
|
||||
const result = await handlers().validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://remote.example/veritas',
|
||||
serverToken: 'vk_pat_secret',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mode: 'remote',
|
||||
valid: false,
|
||||
normalizedServerUrl: 'https://remote.example/veritas',
|
||||
errors: [expect.stringContaining('private IPv4 address')],
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks direct private and metadata remote connection URLs before fetch', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const bridgeHandlers = handlers();
|
||||
|
||||
await expect(
|
||||
bridgeHandlers.validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://10.0.0.1',
|
||||
})
|
||||
).rejects.toThrow('private IPv4 address');
|
||||
await expect(
|
||||
bridgeHandlers.validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://169.254.169.254',
|
||||
})
|
||||
).rejects.toThrow('link-local address');
|
||||
await expect(
|
||||
bridgeHandlers.validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://metadata.google.internal',
|
||||
})
|
||||
).rejects.toThrow('cloud metadata destination');
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates command names, file paths, notification actions, and work product exports', () => {
|
||||
expect(
|
||||
validateDesktopCommandDispatchRequest({
|
||||
command: DESKTOP_COMMAND_NAMES[0],
|
||||
source: 'menu',
|
||||
payload: { route: 'settings' },
|
||||
})
|
||||
).toEqual({
|
||||
command: DESKTOP_COMMAND_NAMES[0],
|
||||
source: 'menu',
|
||||
payload: { route: 'settings' },
|
||||
});
|
||||
expect(() => validateDesktopCommandDispatchRequest({ command: 'rm -rf' })).toThrow(
|
||||
'command is not allowed'
|
||||
);
|
||||
|
||||
expect(
|
||||
validateFilePickerRequest({
|
||||
purpose: DESKTOP_FILE_PICKER_PURPOSES[0],
|
||||
allowMultiple: true,
|
||||
allowedExtensions: ['.MD', '.json'],
|
||||
initialPath: '/Users/bradgroux/Desktop',
|
||||
})
|
||||
).toEqual({
|
||||
purpose: DESKTOP_FILE_PICKER_PURPOSES[0],
|
||||
allowMultiple: true,
|
||||
allowedExtensions: ['.md', '.json'],
|
||||
initialPath: '/Users/bradgroux/Desktop',
|
||||
});
|
||||
expect(() =>
|
||||
validateFilePickerRequest({
|
||||
purpose: DESKTOP_FILE_PICKER_PURPOSES[0],
|
||||
initialPath: 'https://example.com/file.md',
|
||||
})
|
||||
).toThrow('local filesystem path');
|
||||
|
||||
expect(validateDiagnosticsBundleRequest({ includeLogs: true, reason: 'support' })).toEqual({
|
||||
includeLogs: true,
|
||||
includeRuntimeState: undefined,
|
||||
reason: 'support',
|
||||
});
|
||||
|
||||
expect(
|
||||
validateNotificationActionRequest({
|
||||
notificationId: 'notice-1',
|
||||
action: 'complete-task',
|
||||
taskId: 'task-1',
|
||||
})
|
||||
).toEqual({
|
||||
notificationId: 'notice-1',
|
||||
action: 'complete-task',
|
||||
taskId: 'task-1',
|
||||
});
|
||||
expect(() =>
|
||||
validateNotificationActionRequest({ notificationId: 'notice-1', action: 'exec' })
|
||||
).toThrow('action is not allowed');
|
||||
|
||||
expect(
|
||||
validateWorkProductExportRequest({
|
||||
taskId: 'task-1',
|
||||
workProductId: 'artifact-1',
|
||||
targetPath: '/Users/bradgroux/Desktop/export.md',
|
||||
openWhenDone: true,
|
||||
})
|
||||
).toEqual({
|
||||
taskId: 'task-1',
|
||||
workProductId: 'artifact-1',
|
||||
targetPath: '/Users/bradgroux/Desktop/export.md',
|
||||
openWhenDone: true,
|
||||
});
|
||||
expect(() =>
|
||||
validateWorkProductExportRequest({
|
||||
taskId: 'task-1',
|
||||
workProductId: 'artifact-1',
|
||||
targetPath: 'relative/export.md',
|
||||
})
|
||||
).toThrow('absolute local path');
|
||||
});
|
||||
|
||||
it('normalizes allowed external URLs', () => {
|
||||
expect(validateOpenExternalRequest({ url: ' https://example.com/a ' })).toEqual({
|
||||
url: 'https://example.com/a',
|
||||
});
|
||||
expect(validateOpenExternalRequest({ url: 'mailto:help@example.com' })).toEqual({
|
||||
url: 'mailto:help@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('event cleanup unsubscribes once even when cleanup is called repeatedly', () => {
|
||||
const detach = vi.fn();
|
||||
const handler = vi.fn();
|
||||
const cleanup = createDesktopBridgeEventCleanup('desktop:server-status', handler, detach);
|
||||
|
||||
cleanup();
|
||||
cleanup();
|
||||
|
||||
expect(detach).toHaveBeenCalledTimes(1);
|
||||
expect(detach).toHaveBeenCalledWith('desktop:server-status', handler);
|
||||
});
|
||||
|
||||
it('redacts bridge diagnostics and error payloads', () => {
|
||||
const redacted = redactDesktopBridgeValue({
|
||||
token: 'secret-token',
|
||||
message:
|
||||
'Authorization: Bearer abc123 VERITAS_ADMIN_KEY=admin-key path=/Users/bradgroux/.ssh',
|
||||
nested: {
|
||||
webhookSecret: 'hook-secret',
|
||||
safe: 'plain value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(redacted).toEqual({
|
||||
token: DESKTOP_REDACTED_VALUE,
|
||||
message: `Authorization: Bearer ${DESKTOP_REDACTED_VALUE} VERITAS_ADMIN_KEY=${DESKTOP_REDACTED_VALUE} path=/Users/${DESKTOP_REDACTED_VALUE}/.ssh`,
|
||||
nested: {
|
||||
webhookSecret: DESKTOP_REDACTED_VALUE,
|
||||
safe: 'plain value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
redactDesktopBridgeError(
|
||||
new Error('Failed with token=abc123 from /Users/bradgroux/Projects/veritas-kanban')
|
||||
)
|
||||
).toBe(
|
||||
`Failed with token=${DESKTOP_REDACTED_VALUE} from /Users/${DESKTOP_REDACTED_VALUE}/Projects/veritas-kanban`
|
||||
);
|
||||
});
|
||||
|
||||
it('returns redacted setup diagnostics and support snapshots', () => {
|
||||
const support = createDesktopSupportSnapshot(snapshot(), new Date('2026-05-31T12:00:00.000Z'));
|
||||
const diagnostics = createDesktopSetupDiagnostics(
|
||||
snapshot(),
|
||||
new Date('2026-05-31T12:00:00.000Z')
|
||||
);
|
||||
|
||||
expect(support.generatedAt).toBe('2026-05-31T12:00:00.000Z');
|
||||
expect(support.status.appHome).toBe(
|
||||
`/Users/${DESKTOP_REDACTED_VALUE}/Projects/veritas-kanban/.veritas-desktop-dev/fresh`
|
||||
);
|
||||
expect(diagnostics.checks.map((check) => check.name)).toEqual([
|
||||
'local-server-health',
|
||||
'renderer-health',
|
||||
'communication-health',
|
||||
'desktop-secrets',
|
||||
'local-database',
|
||||
'cli-auth',
|
||||
'mcp-auth',
|
||||
]);
|
||||
expect(diagnostics.supportSnapshot.status.appHome).toContain(DESKTOP_REDACTED_VALUE);
|
||||
});
|
||||
});
|
||||
148
desktop/src/main/__tests__/commands.test.ts
Normal file
148
desktop/src/main/__tests__/commands.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Shell } from 'electron';
|
||||
|
||||
import {
|
||||
DESKTOP_COMMAND_REGISTRY,
|
||||
DesktopCommandDispatcher,
|
||||
createDesktopCommandRequest,
|
||||
} from '../commands.js';
|
||||
import type { DesktopRuntime } from '../runtime.js';
|
||||
import type { DesktopStatusSnapshot } from '../types.js';
|
||||
import {
|
||||
DESKTOP_COMMAND_NAMES,
|
||||
type DesktopUpdateStatus,
|
||||
} from '../../shared/desktop-bridge-contracts.js';
|
||||
|
||||
function status(): DesktopStatusSnapshot {
|
||||
return {
|
||||
mode: 'local-dev',
|
||||
profile: 'fresh',
|
||||
workspace: 'local',
|
||||
server: {
|
||||
name: 'server',
|
||||
state: 'ready',
|
||||
pid: 1,
|
||||
port: 3001,
|
||||
lastError: null,
|
||||
startedAt: '2026-05-31T00:00:00.000Z',
|
||||
exitedAt: null,
|
||||
},
|
||||
web: undefined,
|
||||
serverOrigin: 'http://127.0.0.1:3001',
|
||||
rendererOrigin: 'http://127.0.0.1:3000',
|
||||
appHome: '/tmp/veritas',
|
||||
dataDir: '/tmp/veritas/data',
|
||||
configDir: '/tmp/veritas/config',
|
||||
logsDir: '/tmp/veritas/logs',
|
||||
secretsBackedByKeychain: true,
|
||||
warnings: [],
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatus(state: DesktopUpdateStatus['state'] = 'idle'): DesktopUpdateStatus {
|
||||
return {
|
||||
state,
|
||||
currentVersion: '4.3.2',
|
||||
channel: 'stable',
|
||||
checkedAt: '2026-05-31T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function dispatcher() {
|
||||
const runtime = {
|
||||
snapshot: vi.fn(status),
|
||||
restartLocalServer: vi.fn(async () => status()),
|
||||
} as unknown as DesktopRuntime;
|
||||
const shell = {
|
||||
openPath: vi.fn(async () => ''),
|
||||
} as unknown as Shell;
|
||||
const sendRendererCommand = vi.fn();
|
||||
const checkForUpdates = vi.fn(async () => updateStatus('idle'));
|
||||
const downloadUpdate = vi.fn(async () => updateStatus('ready'));
|
||||
const installUpdate = vi.fn(() => updateStatus('ready'));
|
||||
const showTestNotification = vi.fn();
|
||||
const copyRedactedDiagnostics = vi.fn();
|
||||
|
||||
return {
|
||||
runtime,
|
||||
shell,
|
||||
sendRendererCommand,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
showTestNotification,
|
||||
copyRedactedDiagnostics,
|
||||
dispatcher: new DesktopCommandDispatcher({
|
||||
runtime,
|
||||
shell,
|
||||
quit: vi.fn(),
|
||||
sendRendererCommand,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
showTestNotification,
|
||||
copyRedactedDiagnostics,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('desktop command registry', () => {
|
||||
it('defines every typed desktop command exactly once', () => {
|
||||
expect(Object.keys(DESKTOP_COMMAND_REGISTRY).sort()).toEqual([...DESKTOP_COMMAND_NAMES].sort());
|
||||
expect(DESKTOP_COMMAND_REGISTRY['new-task'].accelerator).toBe('CommandOrControl+N');
|
||||
expect(DESKTOP_COMMAND_REGISTRY['open-command-center'].accelerator).toBe('CommandOrControl+K');
|
||||
expect(DESKTOP_COMMAND_REGISTRY['open-onboarding'].label).toBe('Setup & Diagnostics');
|
||||
expect(DESKTOP_COMMAND_REGISTRY['reset-layout']).toMatchObject({
|
||||
label: 'Reset Window Layout',
|
||||
nativeAction: 'renderer',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes renderer commands through the menu command event path', async () => {
|
||||
const harness = dispatcher();
|
||||
const result = await harness.dispatcher.dispatch(
|
||||
createDesktopCommandRequest('new-task', 'menu')
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'new-task',
|
||||
accepted: true,
|
||||
handledBy: 'renderer',
|
||||
message: undefined,
|
||||
});
|
||||
expect(harness.sendRendererCommand).toHaveBeenCalledWith({
|
||||
command: 'new-task',
|
||||
source: 'menu',
|
||||
payload: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles native desktop commands without exposing shell primitives to the renderer', async () => {
|
||||
const harness = dispatcher();
|
||||
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(createDesktopCommandRequest('restart-local-server', 'menu'))
|
||||
).resolves.toMatchObject({
|
||||
command: 'restart-local-server',
|
||||
accepted: true,
|
||||
handledBy: 'desktop',
|
||||
});
|
||||
await harness.dispatcher.dispatch(createDesktopCommandRequest('open-logs', 'menu'));
|
||||
await harness.dispatcher.dispatch(createDesktopCommandRequest('check-for-updates', 'menu'));
|
||||
await harness.dispatcher.dispatch(createDesktopCommandRequest('download-update', 'menu'));
|
||||
await harness.dispatcher.dispatch(createDesktopCommandRequest('install-update', 'menu'));
|
||||
await harness.dispatcher.dispatch(createDesktopCommandRequest('test-notification', 'menu'));
|
||||
await harness.dispatcher.dispatch(
|
||||
createDesktopCommandRequest('copy-redacted-diagnostics', 'menu')
|
||||
);
|
||||
|
||||
expect(harness.runtime.restartLocalServer).toHaveBeenCalledTimes(1);
|
||||
expect(harness.shell.openPath).toHaveBeenCalledWith('/tmp/veritas/logs');
|
||||
expect(harness.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
expect(harness.downloadUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(harness.installUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(harness.showTestNotification).toHaveBeenCalledTimes(1);
|
||||
expect(harness.copyRedactedDiagnostics).toHaveBeenCalledWith(status());
|
||||
});
|
||||
});
|
||||
53
desktop/src/main/__tests__/deep-links.test.ts
Normal file
53
desktop/src/main/__tests__/deep-links.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractDeepLinkFromArgv, parseDesktopDeepLink } from '../deep-links.js';
|
||||
|
||||
describe('desktop deep links', () => {
|
||||
it('maps task links into command-center navigation payloads', () => {
|
||||
expect(parseDesktopDeepLink('veritas://task/task-123?tab=work')).toEqual({
|
||||
url: 'veritas://task/task-123?tab=work',
|
||||
resource: 'task',
|
||||
resourceId: 'task-123',
|
||||
command: {
|
||||
command: 'open-command-center',
|
||||
source: 'deep-link',
|
||||
payload: {
|
||||
deepLink: {
|
||||
url: 'veritas://task/task-123?tab=work',
|
||||
resource: 'task',
|
||||
resourceId: 'task-123',
|
||||
params: {
|
||||
tab: 'work',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('supports settings, pairing, run, workflow, and command-center destinations', () => {
|
||||
expect(parseDesktopDeepLink('veritas://settings').command.command).toBe('open-settings');
|
||||
expect(parseDesktopDeepLink('veritas://pairing/device-1').command.command).toBe(
|
||||
'open-settings'
|
||||
);
|
||||
expect(parseDesktopDeepLink('veritas://run/run-1').command.command).toBe('open-command-center');
|
||||
expect(parseDesktopDeepLink('veritas://workflow/workflow-1').command.command).toBe(
|
||||
'open-command-center'
|
||||
);
|
||||
expect(parseDesktopDeepLink('veritas://command-center').command.command).toBe(
|
||||
'open-command-center'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsupported protocols and resources', () => {
|
||||
expect(() => parseDesktopDeepLink('https://example.com/task/1')).toThrow('protocol');
|
||||
expect(() => parseDesktopDeepLink('veritas://shell/rm')).toThrow('not supported');
|
||||
});
|
||||
|
||||
it('extracts veritas links from process argv', () => {
|
||||
expect(extractDeepLinkFromArgv(['Electron', '.', 'veritas://task/task-1'])).toBe(
|
||||
'veritas://task/task-1'
|
||||
);
|
||||
expect(extractDeepLinkFromArgv(['Electron', '.'])).toBeNull();
|
||||
});
|
||||
});
|
||||
88
desktop/src/main/__tests__/lifecycle.test.ts
Normal file
88
desktop/src/main/__tests__/lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildServerEnvironment,
|
||||
buildWebEnvironment,
|
||||
createManagedProcessConfigs,
|
||||
} from '../lifecycle.js';
|
||||
import type { DesktopLifecycleOptions } from '../lifecycle.js';
|
||||
|
||||
function options(): DesktopLifecycleOptions {
|
||||
return {
|
||||
repoRoot: '/repo/veritas-kanban',
|
||||
paths: {
|
||||
profile: 'fresh-profile',
|
||||
workspace: 'local',
|
||||
appHome: '/tmp/veritas-desktop',
|
||||
profileDir: '/tmp/veritas-desktop-profile',
|
||||
workspaceDir: '/tmp/veritas-desktop',
|
||||
legacyAppHome: null,
|
||||
configDir: '/tmp/veritas-desktop/config',
|
||||
dataDir: '/tmp/veritas-desktop/data',
|
||||
logsDir: '/tmp/veritas-desktop/logs',
|
||||
runtimeDir: '/tmp/veritas-desktop/runtime',
|
||||
exportsDir: '/tmp/veritas-desktop/exports',
|
||||
backupsDir: '/tmp/veritas-desktop/backups',
|
||||
debugBundlesDir: '/tmp/veritas-desktop/debug-bundles',
|
||||
secretsFile: '/tmp/veritas-desktop/config/desktop-secrets.json',
|
||||
migrationManifest: '/tmp/veritas-desktop/config/desktop-path-migration.json',
|
||||
},
|
||||
serverPort: 39123,
|
||||
webPort: 39124,
|
||||
isPackaged: false,
|
||||
secrets: {
|
||||
adminKey: 'desktop-keychain-admin-key',
|
||||
jwtSecret: 'desktop-keychain-jwt-secret',
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('desktop lifecycle config', () => {
|
||||
it('builds loopback server environment for local desktop dev mode', () => {
|
||||
const env = buildServerEnvironment(options());
|
||||
|
||||
expect(env.HOST).toBe('127.0.0.1');
|
||||
expect(env.PORT).toBe('39123');
|
||||
expect(env.VERITAS_ADMIN_KEY).toBe('desktop-keychain-admin-key');
|
||||
expect(env.VERITAS_JWT_SECRET).toBe('desktop-keychain-jwt-secret');
|
||||
expect(env.VERITAS_STORAGE).toBe('sqlite');
|
||||
expect(env.VERITAS_DESKTOP_RUNTIME).toBe('0');
|
||||
expect(env.DATA_DIR).toBe('/tmp/veritas-desktop/data');
|
||||
expect(env.VERITAS_DATA_DIR).toBe('/tmp/veritas-desktop/data');
|
||||
expect(env.VERITAS_AUTH_ENABLED).toBe('false');
|
||||
expect(env.CORS_ORIGINS).toContain('http://127.0.0.1:39124');
|
||||
});
|
||||
|
||||
it('runs packaged server with the Electron binary in Node mode from resources', () => {
|
||||
const configs = createManagedProcessConfigs({
|
||||
...options(),
|
||||
isPackaged: true,
|
||||
resourcesPath: '/Applications/Veritas Kanban.app/Contents/Resources',
|
||||
});
|
||||
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0]?.args).toEqual([
|
||||
'/Applications/Veritas Kanban.app/Contents/Resources/server/dist/index.js',
|
||||
]);
|
||||
expect(configs[0]?.cwd).toBe('/Applications/Veritas Kanban.app/Contents/Resources/server');
|
||||
expect(configs[0]?.env.ELECTRON_RUN_AS_NODE).toBe('1');
|
||||
expect(configs[0]?.env.VERITAS_DESKTOP_RUNTIME).toBe('1');
|
||||
expect(configs[0]?.env.VERITAS_AUTH_ENABLED).toBe('true');
|
||||
});
|
||||
|
||||
it('points web dev proxies at the selected server port', () => {
|
||||
const env = buildWebEnvironment(options());
|
||||
|
||||
expect(env.VITE_API_PROXY_TARGET).toBe('http://127.0.0.1:39123');
|
||||
expect(env.VITE_WS_PROXY_TARGET).toBe('ws://127.0.0.1:39123');
|
||||
});
|
||||
|
||||
it('creates server and web process configs in dev mode', () => {
|
||||
const configs = createManagedProcessConfigs(options());
|
||||
|
||||
expect(configs.map((config) => config.name)).toEqual(['server', 'web']);
|
||||
expect(configs[0]?.readyUrl).toBe('http://127.0.0.1:39123/api/health');
|
||||
expect(configs[1]?.readyUrl).toBe('http://127.0.0.1:39124');
|
||||
});
|
||||
});
|
||||
118
desktop/src/main/__tests__/menu.test.ts
Normal file
118
desktop/src/main/__tests__/menu.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDesktopMenuTemplate } from '../menu.js';
|
||||
import type { DesktopStatusSnapshot } from '../types.js';
|
||||
import type { DesktopUpdateStatus } from '../../shared/desktop-bridge-contracts.js';
|
||||
|
||||
function status(state: DesktopStatusSnapshot['server']['state'] = 'ready'): DesktopStatusSnapshot {
|
||||
return {
|
||||
mode: 'local-dev',
|
||||
profile: 'fresh',
|
||||
workspace: 'local',
|
||||
server: {
|
||||
name: 'server',
|
||||
state,
|
||||
pid: 1,
|
||||
port: 3001,
|
||||
lastError: null,
|
||||
startedAt: '2026-05-31T00:00:00.000Z',
|
||||
exitedAt: null,
|
||||
},
|
||||
web: undefined,
|
||||
serverOrigin: 'http://127.0.0.1:3001',
|
||||
rendererOrigin: 'http://127.0.0.1:3000',
|
||||
appHome: '/tmp/veritas',
|
||||
dataDir: '/tmp/veritas/data',
|
||||
configDir: '/tmp/veritas/config',
|
||||
logsDir: '/tmp/veritas/logs',
|
||||
secretsBackedByKeychain: true,
|
||||
warnings: [],
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatus(state: DesktopUpdateStatus['state']): DesktopUpdateStatus {
|
||||
return {
|
||||
state,
|
||||
currentVersion: '4.3.2',
|
||||
channel: 'stable',
|
||||
checkedAt: '2026-05-31T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('desktop native menu', () => {
|
||||
it('exposes common actions with keyboard shortcuts', () => {
|
||||
const dispatch = vi.fn();
|
||||
const copyVersionInfo = vi.fn();
|
||||
const template = createDesktopMenuTemplate({ status: status(), dispatch, copyVersionInfo });
|
||||
const labels = template.flatMap((item) =>
|
||||
Array.isArray(item.submenu) ? item.submenu.map((child) => child.label) : []
|
||||
);
|
||||
|
||||
expect(labels).toContain('New Task');
|
||||
expect(labels).toContain('Setup & Diagnostics');
|
||||
expect(labels).toContain('Command Center');
|
||||
expect(labels).toContain('Search');
|
||||
expect(labels).toContain('Settings');
|
||||
expect(labels).toContain('Restart Local Server');
|
||||
expect(labels).toContain('Reset Window Layout');
|
||||
|
||||
const appMenu = template.find((item) => item.label === 'Veritas Kanban');
|
||||
const appItems = Array.isArray(appMenu?.submenu) ? appMenu.submenu : [];
|
||||
expect(appItems[0]).toMatchObject({ role: 'about', label: 'About Veritas Kanban' });
|
||||
expect(appItems[1]).toMatchObject({ type: 'separator' });
|
||||
const copyVersion = appItems.find((item) => item.label === 'Copy Version Information');
|
||||
copyVersion?.click?.(undefined as never, undefined as never, undefined as never);
|
||||
expect(copyVersionInfo).toHaveBeenCalledOnce();
|
||||
|
||||
const fileMenu = template.find((item) => item.label === 'File');
|
||||
const newTask = Array.isArray(fileMenu?.submenu)
|
||||
? fileMenu.submenu.find((item) => item.label === 'New Task')
|
||||
: null;
|
||||
newTask?.click?.(undefined as never, undefined as never, undefined as never);
|
||||
|
||||
expect(newTask?.accelerator).toBe('CommandOrControl+N');
|
||||
expect(dispatch).toHaveBeenCalledWith('new-task');
|
||||
});
|
||||
|
||||
it('exposes the native edit menu so macOS text fields receive standard shortcuts', () => {
|
||||
const template = createDesktopMenuTemplate({
|
||||
status: status(),
|
||||
dispatch: vi.fn(),
|
||||
copyVersionInfo: vi.fn(),
|
||||
});
|
||||
|
||||
expect(template.some((item) => item.role === 'editMenu')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps external delivery test status-aware', () => {
|
||||
const desktopMenu = createDesktopMenuTemplate({
|
||||
status: status('failed'),
|
||||
dispatch: vi.fn(),
|
||||
copyVersionInfo: vi.fn(),
|
||||
}).find((item) => item.label === 'Desktop');
|
||||
const externalTest = Array.isArray(desktopMenu?.submenu)
|
||||
? desktopMenu.submenu.find((item) => item.label === 'Test External Delivery')
|
||||
: null;
|
||||
|
||||
expect(externalTest?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps update install actions tied to updater state', () => {
|
||||
const appMenu = createDesktopMenuTemplate({
|
||||
status: status(),
|
||||
updateStatus: updateStatus('available'),
|
||||
dispatch: vi.fn(),
|
||||
copyVersionInfo: vi.fn(),
|
||||
}).find((item) => item.label === 'Veritas Kanban');
|
||||
const downloadUpdate = Array.isArray(appMenu?.submenu)
|
||||
? appMenu.submenu.find((item) => item.label === 'Download Update')
|
||||
: null;
|
||||
const installUpdate = Array.isArray(appMenu?.submenu)
|
||||
? appMenu.submenu.find((item) => item.label === 'Install Update')
|
||||
: null;
|
||||
|
||||
expect(downloadUpdate?.enabled).toBe(true);
|
||||
expect(installUpdate?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
42
desktop/src/main/__tests__/navigation.test.ts
Normal file
42
desktop/src/main/__tests__/navigation.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Shell } from 'electron';
|
||||
|
||||
import { hasSameOriginNavigation, openValidatedExternalUrl } from '../navigation.js';
|
||||
|
||||
function shell(): Shell {
|
||||
return {
|
||||
openExternal: vi.fn(async () => undefined),
|
||||
} as unknown as Shell;
|
||||
}
|
||||
|
||||
describe('desktop navigation guards', () => {
|
||||
it('compares parsed origins instead of string prefixes', () => {
|
||||
expect(hasSameOriginNavigation('http://127.0.0.1:3000/tasks', 'http://127.0.0.1:3000')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
hasSameOriginNavigation(
|
||||
'http://127.0.0.1:3000@attacker.example/tasks',
|
||||
'http://127.0.0.1:3000'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(hasSameOriginNavigation('not a url', 'http://127.0.0.1:3000')).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses the safe external URL validator before opening OS handlers', async () => {
|
||||
const fakeShell = shell();
|
||||
|
||||
await expect(openValidatedExternalUrl(fakeShell, 'https://example.com/docs')).resolves.toBe(
|
||||
true
|
||||
);
|
||||
await expect(
|
||||
openValidatedExternalUrl(fakeShell, 'file:///Users/bradgroux/.ssh/id_ed25519')
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
openValidatedExternalUrl(fakeShell, 'https://user:pass@example.com')
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(fakeShell.openExternal).toHaveBeenCalledTimes(1);
|
||||
expect(fakeShell.openExternal).toHaveBeenCalledWith('https://example.com/docs');
|
||||
});
|
||||
});
|
||||
65
desktop/src/main/__tests__/notifications.test.ts
Normal file
65
desktop/src/main/__tests__/notifications.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createNotificationPreview,
|
||||
DesktopNotificationCenter,
|
||||
type DesktopNotificationAdapter,
|
||||
} from '../notifications.js';
|
||||
|
||||
describe('desktop notifications', () => {
|
||||
it('creates privacy-safe previews when private mode is enabled', () => {
|
||||
const preview = createNotificationPreview({
|
||||
id: 'notice-1',
|
||||
kind: 'mention',
|
||||
title: 'Brad mentioned you on Secret Task',
|
||||
body: 'Sensitive task body',
|
||||
target: { type: 'task', id: 'task-1' },
|
||||
privacyMode: 'private',
|
||||
});
|
||||
|
||||
expect(preview.title).toBe('New mention');
|
||||
expect(preview.body).toBe('Open Veritas Kanban to view details.');
|
||||
expect(preview.target).toEqual({ type: 'task', id: 'task-1' });
|
||||
});
|
||||
|
||||
it('dedupes notifications and emits open actions for durable targets', () => {
|
||||
const show = vi.fn((_, onClick: () => void) => onClick());
|
||||
const dispatchAction = vi.fn();
|
||||
const center = new DesktopNotificationCenter(
|
||||
{ show } as DesktopNotificationAdapter,
|
||||
dispatchAction
|
||||
);
|
||||
const request = {
|
||||
id: 'notice-1',
|
||||
kind: 'agent-complete' as const,
|
||||
title: 'Agent finished',
|
||||
body: 'Done',
|
||||
target: { type: 'task' as const, id: 'task-1' },
|
||||
dedupeKey: 'task-1:done',
|
||||
};
|
||||
|
||||
expect(center.show(request)).not.toBeNull();
|
||||
expect(center.show(request)).toBeNull();
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchAction).toHaveBeenCalledWith({
|
||||
notificationId: 'notice-1',
|
||||
action: 'open',
|
||||
taskId: 'task-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('supports mark-read style notification actions', () => {
|
||||
const dispatchAction = vi.fn();
|
||||
const center = new DesktopNotificationCenter(
|
||||
{ show: vi.fn() } as DesktopNotificationAdapter,
|
||||
dispatchAction
|
||||
);
|
||||
|
||||
center.markRead('notice-1');
|
||||
|
||||
expect(dispatchAction).toHaveBeenCalledWith({
|
||||
notificationId: 'notice-1',
|
||||
action: 'dismiss',
|
||||
});
|
||||
});
|
||||
});
|
||||
91
desktop/src/main/__tests__/paths.test.ts
Normal file
91
desktop/src/main/__tests__/paths.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { createDesktopPaths, ensureDesktopPathLayout, resolveRepoRoot } from '../paths.js';
|
||||
|
||||
describe('desktop paths', () => {
|
||||
it('uses profile and workspace isolated dev homes in the repo', () => {
|
||||
const paths = createDesktopPaths({
|
||||
userDataPath: '/Users/example/Library/Application Support/Veritas Kanban',
|
||||
repoRoot: '/repo/veritas-kanban',
|
||||
isPackaged: false,
|
||||
profile: 'fresh profile',
|
||||
workspace: 'demo workspace',
|
||||
});
|
||||
|
||||
expect(paths.appHome).toBe(
|
||||
path.join(
|
||||
'/repo/veritas-kanban',
|
||||
'.veritas-desktop-dev',
|
||||
'profiles',
|
||||
'fresh-profile',
|
||||
'workspaces',
|
||||
'demo-workspace'
|
||||
)
|
||||
);
|
||||
expect(paths.profile).toBe('fresh-profile');
|
||||
expect(paths.workspace).toBe('demo-workspace');
|
||||
expect(paths.legacyAppHome).toBe(
|
||||
path.join('/repo/veritas-kanban', '.veritas-desktop-dev', 'fresh-profile')
|
||||
);
|
||||
expect(paths.dataDir).toBe(path.join(paths.appHome, 'data'));
|
||||
expect(paths.logsDir).toBe(path.join(paths.appHome, 'logs'));
|
||||
expect(paths.runtimeDir).toBe(path.join(paths.appHome, 'runtime'));
|
||||
expect(paths.secretsFile).toBe(path.join(paths.configDir, 'desktop-secrets.json'));
|
||||
});
|
||||
|
||||
it('uses app userData with profile and workspace isolation in packaged mode', () => {
|
||||
const paths = createDesktopPaths({
|
||||
userDataPath: '/Users/example/Library/Application Support/Veritas Kanban',
|
||||
repoRoot: '/repo/veritas-kanban',
|
||||
isPackaged: true,
|
||||
profile: 'default',
|
||||
workspace: 'local',
|
||||
});
|
||||
|
||||
expect(paths.appHome).toBe(
|
||||
path.join(
|
||||
'/Users/example/Library/Application Support/Veritas Kanban',
|
||||
'profiles',
|
||||
'default',
|
||||
'workspaces',
|
||||
'local'
|
||||
)
|
||||
);
|
||||
expect(paths.legacyAppHome).toBe('/Users/example/Library/Application Support/Veritas Kanban');
|
||||
});
|
||||
|
||||
it('copies legacy desktop data into the workspace app home without deleting the source', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'veritas-desktop-paths-'));
|
||||
const legacyData = path.join(root, '.veritas-desktop-dev', 'fresh', 'data');
|
||||
const paths = createDesktopPaths({
|
||||
userDataPath: path.join(root, 'userData'),
|
||||
repoRoot: root,
|
||||
isPackaged: false,
|
||||
profile: 'fresh',
|
||||
});
|
||||
await mkdir(legacyData, { recursive: true });
|
||||
await writeFile(path.join(legacyData, 'veritas.db'), 'db');
|
||||
|
||||
const result = await ensureDesktopPathLayout(paths);
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.copiedEntries).toEqual(['data']);
|
||||
await expect(readFile(path.join(paths.dataDir, 'veritas.db'), 'utf-8')).resolves.toBe('db');
|
||||
await expect(readFile(path.join(legacyData, 'veritas.db'), 'utf-8')).resolves.toBe('db');
|
||||
});
|
||||
|
||||
it('resolves repo root from package cwd', () => {
|
||||
expect(
|
||||
resolveRepoRoot('/repo/veritas-kanban/desktop/out/main', '/repo/veritas-kanban/desktop')
|
||||
).toBe('/repo/veritas-kanban');
|
||||
});
|
||||
|
||||
it('resolves repo root from electron-vite app output path', () => {
|
||||
expect(resolveRepoRoot('/repo/veritas-kanban/desktop/out/main', '/repo/veritas-kanban')).toBe(
|
||||
'/repo/veritas-kanban'
|
||||
);
|
||||
});
|
||||
});
|
||||
122
desktop/src/main/__tests__/ports.test.ts
Normal file
122
desktop/src/main/__tests__/ports.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import net from 'node:net';
|
||||
|
||||
import { findAvailablePort, isPortAvailable } from '../ports.js';
|
||||
|
||||
describe('port selection', () => {
|
||||
it('returns the preferred port when it is available', async () => {
|
||||
const port = await findAvailablePort(47631, '127.0.0.1', 1);
|
||||
expect(port).toBe(47631);
|
||||
});
|
||||
|
||||
it('falls forward when the preferred port is busy', async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(47632, '127.0.0.1', resolve));
|
||||
|
||||
try {
|
||||
expect(await isPortAvailable(47632)).toBe(false);
|
||||
const port = await findAvailablePort(47632, '127.0.0.1', 3);
|
||||
expect(port).toBeGreaterThan(47632);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps separately selected desktop fallback ports distinct', async () => {
|
||||
const busyServer = net.createServer();
|
||||
await new Promise<void>((resolve) => busyServer.listen(47633, '127.0.0.1', resolve));
|
||||
|
||||
try {
|
||||
const serverPort = await findAvailablePort(47633, '127.0.0.1', 3);
|
||||
const webPort = await findAvailablePort(47633, '127.0.0.1', 3, new Set([serverPort]));
|
||||
|
||||
expect(serverPort).toBeGreaterThan(47633);
|
||||
expect(webPort).toBeGreaterThan(47633);
|
||||
expect(webPort).not.toBe(serverPort);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => busyServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('falls forward when the preferred port is busy on an IPv6 wildcard', async () => {
|
||||
const server = net.createServer();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(47634, '::', resolve);
|
||||
});
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'EAFNOSUPPORT' || code === 'EADDRNOTAVAIL') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
expect(await isPortAvailable(47634)).toBe(false);
|
||||
const port = await findAvailablePort(47634, '127.0.0.1', 3);
|
||||
expect(port).toBeGreaterThan(47634);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects IPv6-busy and excluded ephemeral fallback candidates', async () => {
|
||||
const ipv6Server = net.createServer();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ipv6Server.once('error', reject);
|
||||
ipv6Server.listen(0, '::1', resolve);
|
||||
});
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'EAFNOSUPPORT' || code === 'EADDRNOTAVAIL') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const ipv6Address = ipv6Server.address();
|
||||
if (!ipv6Address || typeof ipv6Address === 'string') {
|
||||
throw new Error('Unable to resolve IPv6 test listener port');
|
||||
}
|
||||
|
||||
const excludedServer = net.createServer();
|
||||
await new Promise<void>((resolve) => excludedServer.listen(0, '127.0.0.1', resolve));
|
||||
const excludedAddress = excludedServer.address();
|
||||
if (!excludedAddress || typeof excludedAddress === 'string') {
|
||||
throw new Error('Unable to resolve excluded test listener port');
|
||||
}
|
||||
await new Promise<void>((resolve) => excludedServer.close(() => resolve()));
|
||||
|
||||
const originalListen = net.Server.prototype.listen;
|
||||
const forcedPorts = [ipv6Address.port, excludedAddress.port];
|
||||
const listenSpy = vi.spyOn(net.Server.prototype, 'listen').mockImplementation(function (
|
||||
this: net.Server,
|
||||
...args: unknown[]
|
||||
) {
|
||||
if (args[0] === 0 && args[1] === '127.0.0.1' && forcedPorts.length > 0) {
|
||||
return Reflect.apply(originalListen, this, [
|
||||
forcedPorts.shift(),
|
||||
'127.0.0.1',
|
||||
]) as net.Server;
|
||||
}
|
||||
return Reflect.apply(originalListen, this, args) as net.Server;
|
||||
});
|
||||
|
||||
try {
|
||||
const port = await findAvailablePort(47635, '127.0.0.1', 0, new Set([excludedAddress.port]));
|
||||
|
||||
expect(forcedPorts).toHaveLength(0);
|
||||
expect(port).not.toBe(ipv6Address.port);
|
||||
expect(port).not.toBe(excludedAddress.port);
|
||||
expect(await isPortAvailable(port)).toBe(true);
|
||||
} finally {
|
||||
listenSpy.mockRestore();
|
||||
await new Promise<void>((resolve) => ipv6Server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
73
desktop/src/main/__tests__/process-supervisor.test.ts
Normal file
73
desktop/src/main/__tests__/process-supervisor.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { mkdtemp } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ProcessSupervisor } from '../process-supervisor.js';
|
||||
import type { DesktopProcessState, ManagedProcessConfig } from '../types.js';
|
||||
|
||||
async function createConfig(args: string[]): Promise<ManagedProcessConfig> {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'veritas-process-supervisor-'));
|
||||
|
||||
return {
|
||||
name: 'server',
|
||||
command: process.execPath,
|
||||
args,
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
logFile: path.join(tempDir, 'server.log'),
|
||||
};
|
||||
}
|
||||
|
||||
function waitForState(
|
||||
supervisor: ProcessSupervisor,
|
||||
state: DesktopProcessState
|
||||
): Promise<ReturnType<ProcessSupervisor['snapshot']>> {
|
||||
const snapshot = supervisor.snapshot();
|
||||
if (snapshot.state === state) {
|
||||
return Promise.resolve(snapshot);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
supervisor.off('state', onState);
|
||||
reject(new Error(`Timed out waiting for ${state}`));
|
||||
}, 2_000);
|
||||
|
||||
const onState = (next: ReturnType<ProcessSupervisor['snapshot']>) => {
|
||||
if (next.state === state) {
|
||||
clearTimeout(timeout);
|
||||
supervisor.off('state', onState);
|
||||
resolve(next);
|
||||
}
|
||||
};
|
||||
|
||||
supervisor.on('state', onState);
|
||||
});
|
||||
}
|
||||
|
||||
describe('ProcessSupervisor', () => {
|
||||
it('fails when a process exits before readiness, even with code 0', async () => {
|
||||
const supervisor = new ProcessSupervisor(await createConfig(['-e', 'process.exit(0)']));
|
||||
|
||||
const failed = waitForState(supervisor, 'failed');
|
||||
await supervisor.start();
|
||||
|
||||
const snapshot = await failed;
|
||||
expect(snapshot.lastError).toContain('server exited before becoming ready with code 0');
|
||||
});
|
||||
|
||||
it('treats a clean exit after readiness as stopped', async () => {
|
||||
const supervisor = new ProcessSupervisor(
|
||||
await createConfig(['-e', 'setTimeout(() => process.exit(0), 50)'])
|
||||
);
|
||||
|
||||
const stopped = waitForState(supervisor, 'stopped');
|
||||
await supervisor.start();
|
||||
supervisor.markReady();
|
||||
|
||||
const snapshot = await stopped;
|
||||
expect(snapshot.lastError).toBeNull();
|
||||
});
|
||||
});
|
||||
76
desktop/src/main/__tests__/secrets.test.ts
Normal file
76
desktop/src/main/__tests__/secrets.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { createDesktopPaths } from '../paths.js';
|
||||
import { DesktopSecretStore, type DesktopSafeStorage } from '../secrets.js';
|
||||
|
||||
function mockSafeStorage(available = true): DesktopSafeStorage {
|
||||
return {
|
||||
isEncryptionAvailable: () => available,
|
||||
encryptString: (value) => Buffer.from(`encrypted:${value}`, 'utf-8'),
|
||||
decryptString: (encrypted) => encrypted.toString('utf-8').replace(/^encrypted:/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function testPaths() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'veritas-desktop-secrets-'));
|
||||
return createDesktopPaths({
|
||||
userDataPath: path.join(root, 'user-data'),
|
||||
repoRoot: root,
|
||||
isPackaged: true,
|
||||
profile: 'fresh',
|
||||
workspace: 'local',
|
||||
});
|
||||
}
|
||||
|
||||
describe('desktop secret store', () => {
|
||||
it('stores runtime secrets as encrypted keychain-backed blobs scoped by profile and workspace', async () => {
|
||||
const paths = await testPaths();
|
||||
const store = new DesktopSecretStore({
|
||||
safeStorage: mockSafeStorage(),
|
||||
paths,
|
||||
});
|
||||
|
||||
const first = await store.loadRuntimeSecrets();
|
||||
const second = await store.loadRuntimeSecrets();
|
||||
const raw = await readFile(paths.secretsFile, 'utf-8');
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(first.adminKey).toMatch(/^vk_admin_/);
|
||||
expect(first.jwtSecret).toMatch(/^vk_jwt_/);
|
||||
expect(raw).toContain('"profile": "fresh"');
|
||||
expect(raw).toContain('"workspace": "local"');
|
||||
expect(raw).not.toContain(first.adminKey);
|
||||
expect(raw).not.toContain(first.jwtSecret);
|
||||
expect(raw).toContain(Buffer.from(`encrypted:${first.adminKey}`).toString('base64'));
|
||||
});
|
||||
|
||||
it('surfaces keychain availability recovery actions without writing plaintext fallbacks', async () => {
|
||||
const paths = await testPaths();
|
||||
const store = new DesktopSecretStore({
|
||||
safeStorage: mockSafeStorage(false),
|
||||
paths,
|
||||
});
|
||||
|
||||
const state = store.inspect();
|
||||
|
||||
expect(state.available).toBe(false);
|
||||
expect(state.error).toContain('safeStorage encryption is unavailable');
|
||||
expect(state.recoveryActions.join(' ')).toContain('Keychain');
|
||||
await expect(store.loadRuntimeSecrets()).rejects.toThrow('Keychain');
|
||||
});
|
||||
|
||||
it('rejects corrupt secret state with reset instructions', async () => {
|
||||
const paths = await testPaths();
|
||||
const store = new DesktopSecretStore({
|
||||
safeStorage: mockSafeStorage(),
|
||||
paths,
|
||||
});
|
||||
await store.loadRuntimeSecrets();
|
||||
await writeFile(paths.secretsFile, '{bad json', 'utf-8');
|
||||
|
||||
await expect(store.loadRuntimeSecrets()).rejects.toThrow('Desktop secret state is unreadable');
|
||||
});
|
||||
});
|
||||
39
desktop/src/main/__tests__/status-page.test.ts
Normal file
39
desktop/src/main/__tests__/status-page.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { statusPage } from '../status-page.js';
|
||||
|
||||
function styleHash(style: string): string {
|
||||
return `'sha256-${createHash('sha256').update(style).digest('base64')}'`;
|
||||
}
|
||||
|
||||
function inlineStyle(html: string): string {
|
||||
const match = html.match(/<style>([\s\S]*?)<\/style>/);
|
||||
if (!match) throw new Error('Expected inline style block');
|
||||
return match[1];
|
||||
}
|
||||
|
||||
describe('desktop status pages CSP', () => {
|
||||
it('allows the generated status page style block by hash instead of unsafe-inline', () => {
|
||||
const html = statusPage('Starting', 'Preparing runtime');
|
||||
const csp = html.match(/content="([^"]+)"/)?.[1] || '';
|
||||
const style = inlineStyle(html);
|
||||
|
||||
expect(csp).toContain(`style-src ${styleHash(style)}`);
|
||||
expect(csp).not.toContain("'unsafe-inline'");
|
||||
expect(style).toContain('-webkit-app-region: drag');
|
||||
});
|
||||
|
||||
it('allows the static renderer startup style block by hash instead of unsafe-inline', () => {
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const html = readFileSync(resolve(testDir, '../../renderer/index.html'), 'utf-8');
|
||||
const csp = html.match(/content="([^"]+)"/)?.[1] || '';
|
||||
const style = inlineStyle(html);
|
||||
|
||||
expect(csp).toContain(`style-src ${styleHash(style)}`);
|
||||
expect(csp).not.toContain("'unsafe-inline'");
|
||||
expect(style).toContain('-webkit-app-region: drag');
|
||||
});
|
||||
});
|
||||
155
desktop/src/main/__tests__/updates.test.ts
Normal file
155
desktop/src/main/__tests__/updates.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
DesktopUpdateService,
|
||||
ElectronAutoUpdaterAdapter,
|
||||
resolveDesktopUpdateChannel,
|
||||
type DesktopUpdateAdapter,
|
||||
type DesktopUpdateAdapterConfigureOptions,
|
||||
} from '../updates.js';
|
||||
|
||||
type UpdateListener = (...args: unknown[]) => void;
|
||||
|
||||
class FakeUpdateAdapter implements DesktopUpdateAdapter {
|
||||
configure = vi.fn((options: DesktopUpdateAdapterConfigureOptions) => {
|
||||
this.config = options;
|
||||
});
|
||||
checkForUpdates = vi.fn(async () => null);
|
||||
downloadUpdate = vi.fn(async () => []);
|
||||
quitAndInstall = vi.fn();
|
||||
active = true;
|
||||
config: DesktopUpdateAdapterConfigureOptions | null = null;
|
||||
private readonly listeners = new Map<string, UpdateListener[]>();
|
||||
|
||||
on(event: string, listener: UpdateListener): void {
|
||||
const existing = this.listeners.get(event) ?? [];
|
||||
this.listeners.set(event, [...existing, listener]);
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const listener of this.listeners.get(event) ?? []) {
|
||||
listener(...args);
|
||||
}
|
||||
}
|
||||
|
||||
isUpdaterActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
}
|
||||
|
||||
function service(adapter = new FakeUpdateAdapter(), currentVersion = '4.3.2') {
|
||||
const emitStatus = vi.fn();
|
||||
return {
|
||||
adapter,
|
||||
emitStatus,
|
||||
service: new DesktopUpdateService({
|
||||
adapter,
|
||||
packaged: true,
|
||||
currentVersion,
|
||||
channel: 'stable',
|
||||
now: () => new Date('2026-05-31T00:00:00.000Z'),
|
||||
emitStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('desktop update service', () => {
|
||||
it('configures updater for manual download and stable channel release checks', () => {
|
||||
const harness = service();
|
||||
|
||||
expect(harness.adapter.configure).toHaveBeenCalledWith({
|
||||
allowDowngrade: false,
|
||||
allowPrerelease: false,
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
channel: 'stable',
|
||||
forceDevUpdateConfig: false,
|
||||
});
|
||||
expect(harness.service.snapshot()).toMatchObject({
|
||||
state: 'idle',
|
||||
currentVersion: '4.3.2',
|
||||
channel: 'stable',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits available, downloading, and ready states from updater events', async () => {
|
||||
const harness = service();
|
||||
|
||||
await harness.service.checkForUpdates();
|
||||
harness.adapter.emit('update-available', { version: '4.3.3' });
|
||||
harness.adapter.emit('download-progress', { percent: 55.2 });
|
||||
harness.adapter.emit('update-downloaded', { version: '4.3.3' });
|
||||
|
||||
expect(harness.emitStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: 'available', availableVersion: '4.3.3' })
|
||||
);
|
||||
expect(harness.emitStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: 'downloading', detail: '55% downloaded.' })
|
||||
);
|
||||
expect(harness.service.snapshot()).toMatchObject({
|
||||
state: 'ready',
|
||||
availableVersion: '4.3.3',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables downgrade after assigning the updater channel', () => {
|
||||
const updater = {
|
||||
allowDowngrade: false,
|
||||
allowPrerelease: false,
|
||||
autoDownload: true,
|
||||
autoInstallOnAppQuit: true,
|
||||
forceDevUpdateConfig: false,
|
||||
set channel(_value: string | null) {
|
||||
this.allowDowngrade = true;
|
||||
},
|
||||
};
|
||||
const adapter = new ElectronAutoUpdaterAdapter(updater as never);
|
||||
|
||||
adapter.configure({
|
||||
allowDowngrade: false,
|
||||
allowPrerelease: false,
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
channel: 'stable',
|
||||
forceDevUpdateConfig: false,
|
||||
});
|
||||
|
||||
expect(updater.allowDowngrade).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps dev builds unsupported unless force dev update config is enabled', async () => {
|
||||
const adapter = new FakeUpdateAdapter();
|
||||
const updateService = new DesktopUpdateService({
|
||||
adapter,
|
||||
packaged: false,
|
||||
currentVersion: '4.3.2',
|
||||
channel: 'dev',
|
||||
});
|
||||
|
||||
await expect(updateService.checkForUpdates()).resolves.toMatchObject({
|
||||
state: 'unsupported',
|
||||
});
|
||||
expect(adapter.checkForUpdates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts sensitive update errors before publishing status', async () => {
|
||||
const harness = service();
|
||||
harness.adapter.checkForUpdates.mockRejectedValueOnce(
|
||||
new Error('download failed token=abc123 path=/Users/bradgroux/private')
|
||||
);
|
||||
|
||||
await harness.service.checkForUpdates();
|
||||
|
||||
expect(harness.service.snapshot()).toMatchObject({
|
||||
state: 'failed',
|
||||
detail: 'download failed token=[redacted] path=/Users/[redacted]/private',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves stable, beta, and dev channels conservatively', () => {
|
||||
expect(resolveDesktopUpdateChannel(undefined, '4.3.2', true)).toBe('stable');
|
||||
expect(resolveDesktopUpdateChannel(undefined, '5.0.0-beta.1', true)).toBe('beta');
|
||||
expect(resolveDesktopUpdateChannel('dev', '4.3.2', true)).toBe('dev');
|
||||
expect(resolveDesktopUpdateChannel(undefined, '4.3.2', false)).toBe('dev');
|
||||
});
|
||||
});
|
||||
80
desktop/src/main/__tests__/version-info.test.ts
Normal file
80
desktop/src/main/__tests__/version-info.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createDesktopAboutPanelOptions,
|
||||
createDesktopAppInfo,
|
||||
formatDesktopVersionInfo,
|
||||
normalizeBuildIdentity,
|
||||
} from '../version-info.js';
|
||||
|
||||
describe('desktop version information', () => {
|
||||
it('formats authoritative packaged version, build, channel, and platform details', () => {
|
||||
const info = createDesktopAppInfo('6.0.2', true, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
buildIdentity: 'abc1234',
|
||||
});
|
||||
|
||||
expect(info).toMatchObject({
|
||||
version: '6.0.2',
|
||||
buildIdentity: 'abc1234',
|
||||
channel: 'stable',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
packaged: true,
|
||||
});
|
||||
expect(formatDesktopVersionInfo(info)).toBe(
|
||||
['Veritas Kanban 6.0.2', 'Build: abc1234', 'Channel: stable', 'macOS 15.5 · arm64'].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('labels prerelease and development builds without network access', () => {
|
||||
expect(
|
||||
createDesktopAppInfo('6.0.2-beta.1', true, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
buildIdentity: null,
|
||||
}).channel
|
||||
).toBe('beta');
|
||||
|
||||
const development = createDesktopAppInfo('6.0.2', false, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
buildIdentity: null,
|
||||
});
|
||||
expect(development.channel).toBe('dev');
|
||||
expect(formatDesktopVersionInfo(development)).toContain('Build: development');
|
||||
});
|
||||
|
||||
it('rejects path-like or unbounded build metadata from support output', () => {
|
||||
expect(normalizeBuildIdentity('/Users/example/private/build')).toBeNull();
|
||||
expect(normalizeBuildIdentity('a'.repeat(65))).toBeNull();
|
||||
|
||||
const info = createDesktopAppInfo('6.0.2', true, {
|
||||
buildIdentity: '/Users/example/private/build',
|
||||
osVersion: '15.5',
|
||||
});
|
||||
expect(info.buildIdentity).toBeNull();
|
||||
expect(formatDesktopVersionInfo(info)).not.toContain('/Users/');
|
||||
});
|
||||
|
||||
it('builds an offline native About panel from the same app information', () => {
|
||||
const info = createDesktopAppInfo('6.0.2', true, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osVersion: '15.5',
|
||||
buildIdentity: 'abc1234',
|
||||
});
|
||||
|
||||
expect(createDesktopAboutPanelOptions(info)).toMatchObject({
|
||||
applicationName: 'Veritas Kanban',
|
||||
applicationVersion: '6.0.2',
|
||||
version: 'Build abc1234',
|
||||
credits: expect.stringContaining('Channel: stable'),
|
||||
});
|
||||
});
|
||||
});
|
||||
70
desktop/src/main/__tests__/window-state.test.ts
Normal file
70
desktop/src/main/__tests__/window-state.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { createDesktopPaths } from '../paths.js';
|
||||
import {
|
||||
applyDesktopWindowState,
|
||||
readDesktopWindowState,
|
||||
writeDesktopWindowState,
|
||||
writeDesktopWindowStateSync,
|
||||
} from '../window-state.js';
|
||||
|
||||
async function paths() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'veritas-window-state-'));
|
||||
return createDesktopPaths({
|
||||
userDataPath: path.join(root, 'userData'),
|
||||
repoRoot: root,
|
||||
isPackaged: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('desktop window state', () => {
|
||||
it('persists and restores sanitized window state', async () => {
|
||||
const desktopPaths = await paths();
|
||||
|
||||
await writeDesktopWindowState(desktopPaths, {
|
||||
width: 1400.2,
|
||||
height: 920.7,
|
||||
x: 40.4,
|
||||
y: 50.5,
|
||||
maximized: true,
|
||||
});
|
||||
|
||||
await expect(readDesktopWindowState(desktopPaths)).resolves.toEqual({
|
||||
width: 1400,
|
||||
height: 921,
|
||||
x: 40,
|
||||
y: 51,
|
||||
maximized: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to stable dimensions for invalid saved state', () => {
|
||||
expect(applyDesktopWindowState({ width: 10, height: Number.NaN })).toEqual({
|
||||
width: 720,
|
||||
height: 900,
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('supports synchronous close-path persistence', async () => {
|
||||
const desktopPaths = await paths();
|
||||
|
||||
writeDesktopWindowStateSync(desktopPaths, {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
|
||||
await expect(readDesktopWindowState(desktopPaths)).resolves.toMatchObject({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
7
desktop/src/main/app-metadata.ts
Normal file
7
desktop/src/main/app-metadata.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const DESKTOP_APP_NAME = 'Veritas Kanban';
|
||||
export const DESKTOP_APP_ID = 'io.digitalmeld.veritas-kanban';
|
||||
export const DESKTOP_PROTOCOL = 'veritas-kanban';
|
||||
export const DESKTOP_MIN_WINDOW = {
|
||||
width: 1180,
|
||||
height: 760,
|
||||
};
|
||||
332
desktop/src/main/bridge.ts
Normal file
332
desktop/src/main/bridge.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import type { IpcMain, Shell } from 'electron';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { blockedRemoteConnectionDestinationReason } from '@veritas-kanban/shared';
|
||||
|
||||
import type { DesktopCommandDispatcher } from './commands.js';
|
||||
import type { DesktopRuntime } from './runtime.js';
|
||||
import type { DesktopUpdateService } from './updates.js';
|
||||
import { createDesktopAppInfo } from './version-info.js';
|
||||
import {
|
||||
createDesktopSetupDiagnostics,
|
||||
createDesktopSupportSnapshot,
|
||||
DESKTOP_BRIDGE_METHOD_NAMES,
|
||||
DESKTOP_BRIDGE_METHODS,
|
||||
DESKTOP_BRIDGE_METHOD_VALIDATORS,
|
||||
redactDesktopBridgeError,
|
||||
validateConnectionConfigRequest,
|
||||
validateDesktopCommandDispatchRequest,
|
||||
validateDiagnosticsBundleRequest,
|
||||
validateFilePickerRequest,
|
||||
validateNotificationActionRequest,
|
||||
validateOpenExternalRequest,
|
||||
validateRestartLocalServerRequest,
|
||||
validateWorkProductExportRequest,
|
||||
type DesktopBridgeMethod,
|
||||
type DesktopBridgeRequest,
|
||||
type DesktopBridgeResponse,
|
||||
type DesktopConnectionConfigRequest,
|
||||
type DesktopConnectionValidationResult,
|
||||
type DesktopWindowToggleMaximizeResult,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type DesktopBridgeHandlerMap = {
|
||||
[Method in DesktopBridgeMethod]: (
|
||||
request: DesktopBridgeRequest<Method>
|
||||
) => MaybePromise<DesktopBridgeResponse<Method>>;
|
||||
};
|
||||
|
||||
export interface DesktopWindowControls {
|
||||
toggleMaximize(): DesktopWindowToggleMaximizeResult;
|
||||
}
|
||||
|
||||
async function remoteConnectionDestinationError(serverUrl: string): Promise<string | null> {
|
||||
const parsed = new URL(serverUrl);
|
||||
const directReason = blockedRemoteConnectionDestinationReason(parsed.hostname);
|
||||
if (directReason) {
|
||||
return `Remote server URL cannot target ${directReason}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await lookup(parsed.hostname, { all: true, verbatim: true });
|
||||
for (const record of records) {
|
||||
const reason = blockedRemoteConnectionDestinationReason(record.address);
|
||||
if (reason) {
|
||||
return `Remote server URL resolves to ${reason}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function validateRemoteConnection(
|
||||
config: DesktopConnectionConfigRequest
|
||||
): Promise<DesktopConnectionValidationResult> {
|
||||
if (!config.serverUrl) {
|
||||
return {
|
||||
mode: 'remote',
|
||||
valid: false,
|
||||
normalizedServerUrl: null,
|
||||
warnings: [],
|
||||
errors: ['Remote server URL is required.'],
|
||||
};
|
||||
}
|
||||
|
||||
let serverToken = config.serverToken;
|
||||
const warnings: string[] = [];
|
||||
const destinationError = await remoteConnectionDestinationError(config.serverUrl);
|
||||
if (destinationError) {
|
||||
return {
|
||||
mode: 'remote',
|
||||
valid: false,
|
||||
normalizedServerUrl: config.serverUrl,
|
||||
warnings,
|
||||
errors: [destinationError],
|
||||
};
|
||||
}
|
||||
|
||||
if (!serverToken && config.pairingPayload) {
|
||||
const paired = await exchangeRemotePairingPayload(config.serverUrl, config.pairingPayload);
|
||||
if (!paired.secret) {
|
||||
return {
|
||||
mode: 'remote',
|
||||
valid: false,
|
||||
normalizedServerUrl: config.serverUrl,
|
||||
warnings,
|
||||
errors: paired.errors,
|
||||
};
|
||||
}
|
||||
serverToken = paired.secret;
|
||||
warnings.push('Pairing payload was exchanged for a device session.');
|
||||
}
|
||||
|
||||
const statusUrl = new URL(
|
||||
serverToken ? '/api/auth/context' : '/api/auth/status',
|
||||
config.serverUrl
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(statusUrl, {
|
||||
method: 'GET',
|
||||
headers: serverToken ? { Authorization: `Bearer ${serverToken}` } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
warnings.push('Remote server is reachable but rejected the supplied credentials.');
|
||||
}
|
||||
if (response.ok) {
|
||||
return {
|
||||
mode: 'remote',
|
||||
valid: true,
|
||||
normalizedServerUrl: config.serverUrl,
|
||||
warnings,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'remote',
|
||||
valid: false,
|
||||
normalizedServerUrl: config.serverUrl,
|
||||
warnings,
|
||||
errors: [`Remote server returned HTTP ${response.status}.`],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
mode: 'remote',
|
||||
valid: false,
|
||||
normalizedServerUrl: config.serverUrl,
|
||||
warnings: [],
|
||||
errors: [redactDesktopBridgeError(error)],
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeRemotePairingPayload(
|
||||
serverUrl: string,
|
||||
pairingPayload: string
|
||||
): Promise<{ secret: string | null; errors: string[] }> {
|
||||
const exchangeUrl = new URL('/api/auth/device-pairing/exchange', serverUrl);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(exchangeUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parseRemotePairingPayload(pairingPayload)),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
secret: null,
|
||||
errors: [`Pairing exchange returned HTTP ${response.status}.`],
|
||||
};
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { secret?: unknown };
|
||||
return typeof body.secret === 'string'
|
||||
? { secret: body.secret, errors: [] }
|
||||
: { secret: null, errors: ['Pairing exchange did not return a device session secret.'] };
|
||||
} catch (error) {
|
||||
return { secret: null, errors: [redactDesktopBridgeError(error)] };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRemotePairingPayload(pairingPayload: string): unknown {
|
||||
const trimmed = pairingPayload.trim();
|
||||
if (trimmed.startsWith('veritas://pair')) {
|
||||
const url = new URL(trimmed);
|
||||
const encoded = url.searchParams.get('payload');
|
||||
if (!encoded) {
|
||||
throw new Error('Pairing link is missing payload.');
|
||||
}
|
||||
return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
||||
}
|
||||
if (trimmed.startsWith('{')) {
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
return { code: trimmed };
|
||||
}
|
||||
|
||||
export function createDesktopBridgeHandlers(
|
||||
runtime: DesktopRuntime,
|
||||
shell: Shell,
|
||||
packaged: boolean,
|
||||
appVersion: string,
|
||||
commandDispatcher?: DesktopCommandDispatcher,
|
||||
updateService?: DesktopUpdateService,
|
||||
windowControls?: DesktopWindowControls
|
||||
): DesktopBridgeHandlerMap {
|
||||
const appInfo = () => createDesktopAppInfo(appVersion, packaged);
|
||||
|
||||
return {
|
||||
getAppInfo: appInfo,
|
||||
getConnectionStatus: () => runtime.snapshot(),
|
||||
getSetupDiagnostics: () => createDesktopSetupDiagnostics(runtime.snapshot()),
|
||||
validateConnectionConfig: async (request) => {
|
||||
const config = validateConnectionConfigRequest(request);
|
||||
if (config.mode === 'remote') {
|
||||
return validateRemoteConnection(config);
|
||||
}
|
||||
const status = runtime.snapshot();
|
||||
return {
|
||||
mode: config.mode,
|
||||
valid: status.server.state === 'ready',
|
||||
normalizedServerUrl: config.serverUrl ?? null,
|
||||
warnings: status.server.state === 'ready' ? [] : ['Local server is not ready.'],
|
||||
errors: status.server.lastError ? [status.server.lastError] : [],
|
||||
};
|
||||
},
|
||||
restartLocalServer: (request) => {
|
||||
validateRestartLocalServerRequest(request);
|
||||
return runtime.restartLocalServer();
|
||||
},
|
||||
getSupportSnapshot: () => createDesktopSupportSnapshot(runtime.snapshot()),
|
||||
getUpdateStatus: () =>
|
||||
updateService?.snapshot() ?? {
|
||||
state: 'unsupported',
|
||||
currentVersion: appInfo().version,
|
||||
channel: appInfo().channel,
|
||||
checkedAt: new Date().toISOString(),
|
||||
detail: 'Updater service is not initialized.',
|
||||
},
|
||||
dispatchCommand: (request) => {
|
||||
const command = validateDesktopCommandDispatchRequest(request);
|
||||
return commandDispatcher
|
||||
? commandDispatcher.dispatch(command)
|
||||
: {
|
||||
command: command.command,
|
||||
accepted: false,
|
||||
handledBy: 'unsupported',
|
||||
message: 'Native command handling is reserved for the desktop menus issue.',
|
||||
};
|
||||
},
|
||||
pickUploadFiles: (request) => {
|
||||
validateFilePickerRequest(request);
|
||||
return {
|
||||
cancelled: true,
|
||||
files: [],
|
||||
};
|
||||
},
|
||||
createDiagnosticsBundle: (request) => {
|
||||
validateDiagnosticsBundleRequest(request);
|
||||
return {
|
||||
bundlePath: null,
|
||||
redacted: true,
|
||||
warnings: ['Diagnostics bundle creation is reserved for the diagnostics workflow.'],
|
||||
};
|
||||
},
|
||||
performNotificationAction: (request) => {
|
||||
const action = validateNotificationActionRequest(request);
|
||||
return {
|
||||
notificationId: action.notificationId,
|
||||
action: action.action,
|
||||
accepted: false,
|
||||
message: 'Native notification handling is reserved for the notifications issue.',
|
||||
};
|
||||
},
|
||||
exportWorkProduct: (request) => {
|
||||
validateWorkProductExportRequest(request);
|
||||
return {
|
||||
exportedPath: null,
|
||||
opened: false,
|
||||
warnings: ['Native work product export is reserved for the work products issue.'],
|
||||
};
|
||||
},
|
||||
openExternal: async (request) => {
|
||||
const { url } = validateOpenExternalRequest(request);
|
||||
await shell.openExternal(url);
|
||||
return undefined;
|
||||
},
|
||||
toggleWindowMaximize: () => windowControls?.toggleMaximize() ?? { maximized: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function registerDesktopBridge(
|
||||
ipcMain: IpcMain,
|
||||
runtime: DesktopRuntime,
|
||||
shell: Shell,
|
||||
packaged: boolean,
|
||||
appVersion: string,
|
||||
commandDispatcher?: DesktopCommandDispatcher,
|
||||
updateService?: DesktopUpdateService,
|
||||
windowControls?: DesktopWindowControls
|
||||
): void {
|
||||
const handlers = createDesktopBridgeHandlers(
|
||||
runtime,
|
||||
shell,
|
||||
packaged,
|
||||
appVersion,
|
||||
commandDispatcher,
|
||||
updateService,
|
||||
windowControls
|
||||
);
|
||||
|
||||
for (const method of DESKTOP_BRIDGE_METHOD_NAMES) {
|
||||
const definition = DESKTOP_BRIDGE_METHODS[method];
|
||||
const handler = handlers[method] as (request: unknown) => MaybePromise<unknown>;
|
||||
const validator = DESKTOP_BRIDGE_METHOD_VALIDATORS[method] as
|
||||
((payload: unknown) => unknown) | undefined;
|
||||
|
||||
ipcMain.handle(definition.channel, async (_event, request: unknown) => {
|
||||
try {
|
||||
return await handler(validator ? validator(request) : request);
|
||||
} catch (error) {
|
||||
// Do not attach the original cause to errors crossing the desktop bridge.
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error(redactDesktopBridgeError(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
236
desktop/src/main/commands.ts
Normal file
236
desktop/src/main/commands.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import type { Shell } from 'electron';
|
||||
|
||||
import type { DesktopRuntime } from './runtime.js';
|
||||
import type { DesktopStatusSnapshot } from './types.js';
|
||||
import {
|
||||
DESKTOP_COMMAND_NAMES,
|
||||
type DesktopCommandDispatchRequest,
|
||||
type DesktopCommandDispatchResult,
|
||||
type DesktopCommandName,
|
||||
type DesktopCommandSource,
|
||||
type DesktopUpdateStatus,
|
||||
validateDesktopCommandDispatchRequest,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
export type DesktopCommandNativeAction =
|
||||
| 'renderer'
|
||||
| 'restart-server'
|
||||
| 'open-logs'
|
||||
| 'show-diagnostics'
|
||||
| 'create-debug-bundle'
|
||||
| 'check-updates'
|
||||
| 'download-update'
|
||||
| 'install-update'
|
||||
| 'test-notification'
|
||||
| 'test-external-delivery'
|
||||
| 'copy-diagnostics'
|
||||
| 'quit';
|
||||
|
||||
export interface DesktopCommandDefinition {
|
||||
name: DesktopCommandName;
|
||||
label: string;
|
||||
accelerator?: string;
|
||||
nativeAction: DesktopCommandNativeAction;
|
||||
privacySensitive: boolean;
|
||||
}
|
||||
|
||||
export const DESKTOP_COMMAND_REGISTRY: Record<DesktopCommandName, DesktopCommandDefinition> =
|
||||
Object.fromEntries(
|
||||
DESKTOP_COMMAND_NAMES.map((name) => [
|
||||
name,
|
||||
{
|
||||
name,
|
||||
label: commandLabel(name),
|
||||
accelerator: commandAccelerator(name),
|
||||
nativeAction: commandNativeAction(name),
|
||||
privacySensitive: commandPrivacySensitive(name),
|
||||
},
|
||||
])
|
||||
) as Record<DesktopCommandName, DesktopCommandDefinition>;
|
||||
|
||||
function commandLabel(name: DesktopCommandName): string {
|
||||
switch (name) {
|
||||
case 'new-task':
|
||||
return 'New Task';
|
||||
case 'open-onboarding':
|
||||
return 'Setup & Diagnostics';
|
||||
case 'open-search':
|
||||
return 'Search';
|
||||
case 'open-settings':
|
||||
return 'Settings';
|
||||
case 'open-command-center':
|
||||
return 'Command Center';
|
||||
case 'reset-layout':
|
||||
return 'Reset Window Layout';
|
||||
case 'import-data':
|
||||
return 'Import';
|
||||
case 'export-data':
|
||||
return 'Export';
|
||||
case 'create-backup':
|
||||
return 'Create Backup';
|
||||
case 'open-logs':
|
||||
return 'Open Logs';
|
||||
case 'restart-local-server':
|
||||
return 'Restart Local Server';
|
||||
case 'communication-health':
|
||||
return 'Communication Health';
|
||||
case 'show-diagnostics':
|
||||
return 'Diagnostics';
|
||||
case 'create-debug-bundle':
|
||||
return 'Create Debug Bundle';
|
||||
case 'check-for-updates':
|
||||
return 'Check for Updates';
|
||||
case 'download-update':
|
||||
return 'Download Update';
|
||||
case 'install-update':
|
||||
return 'Install Update';
|
||||
case 'test-notification':
|
||||
return 'Test Local Notification';
|
||||
case 'test-squad-webhook':
|
||||
return 'Test External Delivery';
|
||||
case 'copy-redacted-diagnostics':
|
||||
return 'Copy Redacted Diagnostics';
|
||||
case 'export-work-product':
|
||||
return 'Export Work Product';
|
||||
case 'quit':
|
||||
return 'Quit Veritas Kanban';
|
||||
}
|
||||
}
|
||||
|
||||
function commandAccelerator(name: DesktopCommandName): string | undefined {
|
||||
switch (name) {
|
||||
case 'new-task':
|
||||
return 'CommandOrControl+N';
|
||||
case 'open-search':
|
||||
return 'CommandOrControl+F';
|
||||
case 'open-settings':
|
||||
return 'CommandOrControl+,';
|
||||
case 'open-command-center':
|
||||
return 'CommandOrControl+K';
|
||||
case 'restart-local-server':
|
||||
return 'CommandOrControl+R';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function commandNativeAction(name: DesktopCommandName): DesktopCommandNativeAction {
|
||||
switch (name) {
|
||||
case 'restart-local-server':
|
||||
return 'restart-server';
|
||||
case 'open-logs':
|
||||
return 'open-logs';
|
||||
case 'show-diagnostics':
|
||||
return 'show-diagnostics';
|
||||
case 'create-debug-bundle':
|
||||
return 'create-debug-bundle';
|
||||
case 'check-for-updates':
|
||||
return 'check-updates';
|
||||
case 'download-update':
|
||||
return 'download-update';
|
||||
case 'install-update':
|
||||
return 'install-update';
|
||||
case 'test-notification':
|
||||
return 'test-notification';
|
||||
case 'test-squad-webhook':
|
||||
return 'test-external-delivery';
|
||||
case 'copy-redacted-diagnostics':
|
||||
return 'copy-diagnostics';
|
||||
case 'quit':
|
||||
return 'quit';
|
||||
default:
|
||||
return 'renderer';
|
||||
}
|
||||
}
|
||||
|
||||
function commandPrivacySensitive(name: DesktopCommandName): boolean {
|
||||
return name === 'export-work-product' || name === 'copy-redacted-diagnostics';
|
||||
}
|
||||
|
||||
export interface DesktopCommandDispatcherOptions {
|
||||
runtime: DesktopRuntime;
|
||||
shell: Shell;
|
||||
quit(): void;
|
||||
sendRendererCommand(command: DesktopCommandDispatchRequest): void;
|
||||
checkForUpdates(): Promise<DesktopUpdateStatus>;
|
||||
downloadUpdate(): Promise<DesktopUpdateStatus>;
|
||||
installUpdate(): DesktopUpdateStatus;
|
||||
showTestNotification(): void;
|
||||
copyRedactedDiagnostics(status: DesktopStatusSnapshot): void;
|
||||
}
|
||||
|
||||
export class DesktopCommandDispatcher {
|
||||
constructor(private readonly options: DesktopCommandDispatcherOptions) {}
|
||||
|
||||
async dispatch(payload: unknown): Promise<DesktopCommandDispatchResult> {
|
||||
const request = validateDesktopCommandDispatchRequest(payload);
|
||||
const definition = DESKTOP_COMMAND_REGISTRY[request.command];
|
||||
|
||||
switch (definition.nativeAction) {
|
||||
case 'renderer':
|
||||
this.options.sendRendererCommand(request);
|
||||
return accepted(request, 'renderer');
|
||||
case 'restart-server':
|
||||
await this.options.runtime.restartLocalServer();
|
||||
this.options.sendRendererCommand(request);
|
||||
return accepted(request, 'desktop');
|
||||
case 'open-logs':
|
||||
await this.options.shell.openPath(this.options.runtime.snapshot().logsDir);
|
||||
return accepted(request, 'desktop');
|
||||
case 'show-diagnostics':
|
||||
case 'create-debug-bundle':
|
||||
this.options.sendRendererCommand(request);
|
||||
return accepted(request, 'renderer');
|
||||
case 'check-updates':
|
||||
await this.options.checkForUpdates();
|
||||
return accepted(request, 'desktop');
|
||||
case 'download-update':
|
||||
await this.options.downloadUpdate();
|
||||
return accepted(request, 'desktop');
|
||||
case 'install-update':
|
||||
this.options.installUpdate();
|
||||
return accepted(request, 'desktop');
|
||||
case 'test-notification':
|
||||
this.options.showTestNotification();
|
||||
return accepted(request, 'desktop');
|
||||
case 'test-external-delivery':
|
||||
this.options.sendRendererCommand(request);
|
||||
return accepted(
|
||||
request,
|
||||
'renderer',
|
||||
'External delivery test requires configured delivery.'
|
||||
);
|
||||
case 'copy-diagnostics':
|
||||
this.options.copyRedactedDiagnostics(this.options.runtime.snapshot());
|
||||
return accepted(request, 'desktop');
|
||||
case 'quit':
|
||||
this.options.quit();
|
||||
return accepted(request, 'desktop');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function accepted(
|
||||
request: DesktopCommandDispatchRequest,
|
||||
handledBy: DesktopCommandDispatchResult['handledBy'],
|
||||
message?: string
|
||||
): DesktopCommandDispatchResult {
|
||||
return {
|
||||
command: request.command,
|
||||
accepted: true,
|
||||
handledBy,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopCommandRequest(
|
||||
command: DesktopCommandName,
|
||||
source: DesktopCommandSource,
|
||||
payload?: Record<string, unknown>
|
||||
): DesktopCommandDispatchRequest {
|
||||
return {
|
||||
command,
|
||||
source,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
68
desktop/src/main/deep-links.ts
Normal file
68
desktop/src/main/deep-links.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
DESKTOP_COMMAND_NAMES,
|
||||
type DesktopCommandDispatchRequest,
|
||||
type DesktopCommandName,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
const RESOURCE_COMMANDS: Record<string, DesktopCommandName> = {
|
||||
task: 'open-command-center',
|
||||
workflow: 'open-command-center',
|
||||
invite: 'open-settings',
|
||||
pairing: 'open-settings',
|
||||
run: 'open-command-center',
|
||||
settings: 'open-settings',
|
||||
'command-center': 'open-command-center',
|
||||
search: 'open-search',
|
||||
'work-product': 'export-work-product',
|
||||
};
|
||||
|
||||
export interface DesktopDeepLinkResult {
|
||||
url: string;
|
||||
resource: string;
|
||||
resourceId: string | null;
|
||||
command: DesktopCommandDispatchRequest;
|
||||
}
|
||||
|
||||
export function parseDesktopDeepLink(rawUrl: string): DesktopDeepLinkResult {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error('Deep link URL is invalid');
|
||||
}
|
||||
|
||||
if (url.protocol !== 'veritas:') {
|
||||
throw new Error(`Deep link protocol is not supported: ${url.protocol}`);
|
||||
}
|
||||
|
||||
const resource = url.hostname || url.pathname.split('/').filter(Boolean)[0] || 'command-center';
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
const resourceId = segments[0] && segments[0] !== resource ? segments[0] : (segments[1] ?? null);
|
||||
const command = RESOURCE_COMMANDS[resource];
|
||||
|
||||
if (!command || !DESKTOP_COMMAND_NAMES.includes(command)) {
|
||||
throw new Error(`Deep link resource is not supported: ${resource}`);
|
||||
}
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
resource,
|
||||
resourceId,
|
||||
command: {
|
||||
command,
|
||||
source: 'deep-link',
|
||||
payload: {
|
||||
deepLink: {
|
||||
url: url.toString(),
|
||||
resource,
|
||||
resourceId,
|
||||
params: Object.fromEntries(url.searchParams.entries()),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extractDeepLinkFromArgv(argv: string[]): string | null {
|
||||
return argv.find((arg) => arg.startsWith('veritas://')) ?? null;
|
||||
}
|
||||
416
desktop/src/main/index.ts
Normal file
416
desktop/src/main/index.ts
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
import { app, BrowserWindow, clipboard, ipcMain, Notification, safeStorage, shell } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { DESKTOP_APP_ID, DESKTOP_APP_NAME, DESKTOP_MIN_WINDOW } from './app-metadata.js';
|
||||
import { registerDesktopBridge } from './bridge.js';
|
||||
import { DesktopCommandDispatcher } from './commands.js';
|
||||
import { extractDeepLinkFromArgv, parseDesktopDeepLink } from './deep-links.js';
|
||||
import { configureDesktopMenu, dispatchDesktopMenuCommand } from './menu.js';
|
||||
import { hasSameOriginNavigation, openValidatedExternalUrl } from './navigation.js';
|
||||
import { DesktopNotificationCenter, ElectronNotificationAdapter } from './notifications.js';
|
||||
import { createDesktopPaths, resolveRepoRoot } from './paths.js';
|
||||
import { findAvailablePort } from './ports.js';
|
||||
import { DesktopRuntime } from './runtime.js';
|
||||
import { DesktopSecretStore } from './secrets.js';
|
||||
import { statusPageUrl } from './status-page.js';
|
||||
import {
|
||||
DesktopUpdateService,
|
||||
ElectronAutoUpdaterAdapter,
|
||||
resolveDesktopUpdateChannel,
|
||||
} from './updates.js';
|
||||
import {
|
||||
createDesktopAboutPanelOptions,
|
||||
createDesktopAppInfo,
|
||||
formatDesktopVersionInfo,
|
||||
} from './version-info.js';
|
||||
import {
|
||||
DESKTOP_BRIDGE_EVENTS,
|
||||
redactDesktopBridgeValue,
|
||||
type DesktopUpdateStatus,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
import {
|
||||
applyDesktopWindowState,
|
||||
captureDesktopWindowState,
|
||||
readDesktopWindowState,
|
||||
writeDesktopWindowStateSync,
|
||||
type DesktopWindowState,
|
||||
} from './window-state.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { autoUpdater } = require('electron-updater') as typeof import('electron-updater');
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let runtime: DesktopRuntime | null = null;
|
||||
let commandDispatcher: DesktopCommandDispatcher | null = null;
|
||||
let updateService: DesktopUpdateService | null = null;
|
||||
let windowStatePaths: ReturnType<typeof createDesktopPaths> | null = null;
|
||||
let quitting = false;
|
||||
let shutdownStarted = false;
|
||||
const pendingDeepLinks: string[] = [];
|
||||
|
||||
function activeMainWindow(): BrowserWindow | null {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function showDesktopError(message: string): void {
|
||||
if (quitting) return;
|
||||
|
||||
const window = activeMainWindow();
|
||||
if (!window) return;
|
||||
|
||||
void window
|
||||
.loadURL(statusPageUrl('Veritas Kanban desktop error', message, runtime?.snapshot()))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function isPackagedRuntime(): boolean {
|
||||
return app.isPackaged || process.env.VERITAS_DESKTOP_PRODUCTION === 'true';
|
||||
}
|
||||
|
||||
const launchPackaged = isPackagedRuntime();
|
||||
const launchRepoRoot = resolveRepoRoot(app.getAppPath());
|
||||
const launchProfile = process.env.VERITAS_DESKTOP_PROFILE || 'default';
|
||||
const launchWorkspace = process.env.VERITAS_DESKTOP_WORKSPACE || 'local';
|
||||
|
||||
if (!launchPackaged) {
|
||||
const devUserDataPath = path.join(
|
||||
launchRepoRoot,
|
||||
'.veritas-desktop-dev',
|
||||
launchProfile,
|
||||
'app-home'
|
||||
);
|
||||
mkdirSync(devUserDataPath, { recursive: true });
|
||||
app.setPath('userData', devUserDataPath);
|
||||
}
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function createMainWindow(savedState: DesktopWindowState): BrowserWindow {
|
||||
const preloadPath = path.join(__dirname, '../preload/index.cjs');
|
||||
const windowBounds = applyDesktopWindowState(savedState);
|
||||
|
||||
const window = new BrowserWindow({
|
||||
title: DESKTOP_APP_NAME,
|
||||
minWidth: DESKTOP_MIN_WINDOW.width,
|
||||
minHeight: DESKTOP_MIN_WINDOW.height,
|
||||
...windowBounds,
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 18 } : undefined,
|
||||
backgroundColor: '#111318',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
|
||||
window.once('ready-to-show', () => window.show());
|
||||
if (savedState.maximized) {
|
||||
window.maximize();
|
||||
}
|
||||
window.on('close', () => {
|
||||
if (windowStatePaths) {
|
||||
writeDesktopWindowStateSync(windowStatePaths, captureDesktopWindowState(window));
|
||||
}
|
||||
});
|
||||
window.on('closed', () => {
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
}
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void openValidatedExternalUrl(shell, url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
window.webContents.on('will-navigate', (event, url) => {
|
||||
const current = runtime?.getRendererOrigin();
|
||||
if (current && !hasSameOriginNavigation(url, current)) {
|
||||
event.preventDefault();
|
||||
void openValidatedExternalUrl(shell, url);
|
||||
}
|
||||
});
|
||||
window.webContents.on('did-fail-load', (_event, _code, description) => {
|
||||
if (!quitting) {
|
||||
void window.loadURL(
|
||||
statusPageUrl('Veritas Kanban could not load', description, runtime?.snapshot())
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
function handleDeepLink(url: string): void {
|
||||
if (!commandDispatcher) {
|
||||
pendingDeepLinks.push(url);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deepLink = parseDesktopDeepLink(url);
|
||||
void commandDispatcher.dispatch(deepLink.command);
|
||||
} catch (error) {
|
||||
activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.communicationCheck.channel, {
|
||||
target: 'external',
|
||||
state: 'failed',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function flushPendingDeepLinks(): void {
|
||||
for (const deepLink of pendingDeepLinks.splice(0)) {
|
||||
handleDeepLink(deepLink);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshDesktopMenu(): void {
|
||||
if (!runtime || !commandDispatcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appInfo = createDesktopAppInfo(app.getVersion(), launchPackaged);
|
||||
configureDesktopMenu({
|
||||
status: runtime.snapshot(),
|
||||
updateStatus: updateService?.snapshot(),
|
||||
copyVersionInfo: () => clipboard.writeText(formatDesktopVersionInfo(appInfo)),
|
||||
dispatch: (command) => {
|
||||
if (commandDispatcher) {
|
||||
dispatchDesktopMenuCommand(commandDispatcher, command);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateServiceFallback(packaged: boolean): DesktopUpdateStatus {
|
||||
const appInfo = createDesktopAppInfo(app.getVersion(), packaged);
|
||||
return {
|
||||
state: 'unsupported',
|
||||
currentVersion: appInfo.version,
|
||||
channel: appInfo.channel,
|
||||
checkedAt: new Date().toISOString(),
|
||||
detail: 'Updater service is not initialized.',
|
||||
};
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
app.setName(DESKTOP_APP_NAME);
|
||||
app.setAppUserModelId(DESKTOP_APP_ID);
|
||||
|
||||
const packaged = launchPackaged;
|
||||
const appInfo = createDesktopAppInfo(app.getVersion(), packaged);
|
||||
app.setAboutPanelOptions(createDesktopAboutPanelOptions(appInfo));
|
||||
const repoRoot = launchRepoRoot;
|
||||
const profile = launchProfile;
|
||||
const workspace = launchWorkspace;
|
||||
const paths = createDesktopPaths({
|
||||
userDataPath: app.getPath('userData'),
|
||||
repoRoot,
|
||||
isPackaged: packaged,
|
||||
profile,
|
||||
workspace,
|
||||
});
|
||||
windowStatePaths = paths;
|
||||
|
||||
const serverPort = await findAvailablePort(
|
||||
Number(process.env.VERITAS_DESKTOP_SERVER_PORT || 3001)
|
||||
);
|
||||
const webPort = await findAvailablePort(
|
||||
Number(process.env.VERITAS_DESKTOP_WEB_PORT || 3000),
|
||||
'127.0.0.1',
|
||||
50,
|
||||
new Set([serverPort])
|
||||
);
|
||||
|
||||
mainWindow = createMainWindow(await readDesktopWindowState(paths));
|
||||
await mainWindow.loadURL(statusPageUrl('Starting Veritas Kanban', 'Preparing the local app.'));
|
||||
|
||||
const secretStore = new DesktopSecretStore({ safeStorage, paths });
|
||||
const secretState = secretStore.inspect();
|
||||
if (!secretState.available) {
|
||||
await mainWindow.loadURL(
|
||||
statusPageUrl(
|
||||
'Veritas Kanban needs Keychain access',
|
||||
`${secretState.error} ${secretState.recoveryActions.join(' ')}`,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let secrets;
|
||||
try {
|
||||
secrets = await secretStore.loadRuntimeSecrets();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await mainWindow.loadURL(statusPageUrl('Veritas Kanban secret recovery needed', message));
|
||||
return;
|
||||
}
|
||||
|
||||
runtime = new DesktopRuntime({
|
||||
repoRoot,
|
||||
resourcesPath: process.resourcesPath,
|
||||
paths,
|
||||
serverPort,
|
||||
webPort,
|
||||
isPackaged: packaged,
|
||||
profile,
|
||||
workspace,
|
||||
secrets,
|
||||
secretsBackedByKeychain: secretState.available,
|
||||
});
|
||||
|
||||
const notifications = new DesktopNotificationCenter(
|
||||
new ElectronNotificationAdapter(Notification),
|
||||
(request) => {
|
||||
mainWindow?.webContents.send(DESKTOP_BRIDGE_EVENTS.notificationAction.channel, request);
|
||||
}
|
||||
);
|
||||
updateService = new DesktopUpdateService({
|
||||
adapter: new ElectronAutoUpdaterAdapter(autoUpdater),
|
||||
packaged,
|
||||
currentVersion: app.getVersion(),
|
||||
channel: resolveDesktopUpdateChannel(
|
||||
process.env.VERITAS_UPDATE_CHANNEL,
|
||||
app.getVersion(),
|
||||
packaged
|
||||
),
|
||||
forceDevUpdateConfig: process.env.VERITAS_DESKTOP_UPDATER_FORCE_DEV === 'true',
|
||||
emitStatus: (status) => {
|
||||
activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.updateStatus.channel, status);
|
||||
refreshDesktopMenu();
|
||||
},
|
||||
});
|
||||
|
||||
commandDispatcher = new DesktopCommandDispatcher({
|
||||
runtime,
|
||||
shell,
|
||||
quit: () => app.quit(),
|
||||
sendRendererCommand: (command) => {
|
||||
activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.menuCommand.channel, command);
|
||||
},
|
||||
checkForUpdates: () =>
|
||||
updateService?.checkForUpdates() ?? Promise.resolve(updateServiceFallback(packaged)),
|
||||
downloadUpdate: () =>
|
||||
updateService?.downloadUpdate() ?? Promise.resolve(updateServiceFallback(packaged)),
|
||||
installUpdate: () => updateService?.installUpdate() ?? updateServiceFallback(packaged),
|
||||
showTestNotification: () => {
|
||||
notifications.show({
|
||||
id: `setup-test-${Date.now()}`,
|
||||
kind: 'setup-test',
|
||||
title: 'Veritas Kanban notification test',
|
||||
body: 'Local desktop notifications are working.',
|
||||
target: { type: 'settings' },
|
||||
privacyMode: 'private',
|
||||
});
|
||||
},
|
||||
copyRedactedDiagnostics: (status) => {
|
||||
clipboard.writeText(JSON.stringify(redactDesktopBridgeValue(status), null, 2));
|
||||
},
|
||||
});
|
||||
|
||||
registerDesktopBridge(
|
||||
ipcMain,
|
||||
runtime,
|
||||
shell,
|
||||
packaged,
|
||||
app.getVersion(),
|
||||
commandDispatcher,
|
||||
updateService,
|
||||
{
|
||||
toggleMaximize: () => {
|
||||
const window = activeMainWindow();
|
||||
if (!window) {
|
||||
return { maximized: false };
|
||||
}
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
return { maximized: window.isMaximized() };
|
||||
},
|
||||
}
|
||||
);
|
||||
refreshDesktopMenu();
|
||||
runtime.on('status', (status) => {
|
||||
activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.serverStatus.channel, status);
|
||||
refreshDesktopMenu();
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
flushPendingDeepLinks();
|
||||
await mainWindow.loadURL(runtime.getRendererOrigin());
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await mainWindow.loadURL(
|
||||
statusPageUrl('Veritas Kanban startup failed', message, runtime.snapshot())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
app.on('ready', () => {
|
||||
app.setAsDefaultProtocolClient('veritas');
|
||||
const initialDeepLink = extractDeepLinkFromArgv(process.argv);
|
||||
if (initialDeepLink) {
|
||||
pendingDeepLinks.push(initialDeepLink);
|
||||
}
|
||||
void boot();
|
||||
});
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
handleDeepLink(url);
|
||||
});
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
const deepLink = extractDeepLinkFromArgv(argv);
|
||||
if (deepLink) {
|
||||
handleDeepLink(deepLink);
|
||||
}
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
quitting = true;
|
||||
if (runtime && !shutdownStarted) {
|
||||
event.preventDefault();
|
||||
shutdownStarted = true;
|
||||
void runtime.stop().finally(() => app.quit());
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
void boot();
|
||||
}
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
showDesktopError(error.message);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const message = reason instanceof Error ? reason.message : String(reason);
|
||||
showDesktopError(message);
|
||||
});
|
||||
105
desktop/src/main/lifecycle.ts
Normal file
105
desktop/src/main/lifecycle.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import path from 'node:path';
|
||||
|
||||
import type { DesktopPaths, DesktopRuntimeSecrets, ManagedProcessConfig } from './types.js';
|
||||
|
||||
export interface DesktopLifecycleOptions {
|
||||
repoRoot: string;
|
||||
resourcesPath?: string;
|
||||
paths: DesktopPaths;
|
||||
serverPort: number;
|
||||
webPort: number;
|
||||
isPackaged: boolean;
|
||||
secrets: DesktopRuntimeSecrets;
|
||||
}
|
||||
|
||||
function pnpmCommand(): string {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
||||
}
|
||||
|
||||
export function buildServerEnvironment(options: DesktopLifecycleOptions): NodeJS.ProcessEnv {
|
||||
const serverOrigin = `http://127.0.0.1:${options.serverPort}`;
|
||||
const webOrigin = `http://127.0.0.1:${options.webPort}`;
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
NODE_ENV: options.isPackaged ? 'production' : 'development',
|
||||
...(options.isPackaged ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(options.serverPort),
|
||||
VERITAS_ADMIN_KEY: options.secrets.adminKey,
|
||||
VERITAS_JWT_SECRET: options.secrets.jwtSecret,
|
||||
VERITAS_AUTH_ENABLED: options.isPackaged ? 'true' : 'false',
|
||||
VERITAS_AUTH_LOCALHOST_BYPASS: 'false',
|
||||
VERITAS_DESKTOP_RUNTIME: options.isPackaged ? '1' : '0',
|
||||
VERITAS_STORAGE: 'sqlite',
|
||||
DATA_DIR: options.paths.dataDir,
|
||||
VERITAS_DATA_DIR: options.paths.dataDir,
|
||||
VERITAS_DISABLE_WATCHERS: '1',
|
||||
CORS_ORIGINS: `${serverOrigin},${webOrigin},http://localhost:${options.webPort}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWebEnvironment(options: DesktopLifecycleOptions): NodeJS.ProcessEnv {
|
||||
const serverOrigin = `http://127.0.0.1:${options.serverPort}`;
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
VITE_HOST: '127.0.0.1',
|
||||
VITE_API_PROXY_TARGET: serverOrigin,
|
||||
VITE_WS_PROXY_TARGET: serverOrigin.replace(/^http/, 'ws'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createManagedProcessConfigs(
|
||||
options: DesktopLifecycleOptions
|
||||
): ManagedProcessConfig[] {
|
||||
const packagedServerRoot = options.resourcesPath
|
||||
? path.join(options.resourcesPath, 'server')
|
||||
: path.join(options.repoRoot, 'server');
|
||||
const packagedServerEntry =
|
||||
process.env.VERITAS_DESKTOP_SERVER_ENTRY || path.join(packagedServerRoot, 'dist', 'index.js');
|
||||
|
||||
const serverConfig: ManagedProcessConfig = options.isPackaged
|
||||
? {
|
||||
name: 'server',
|
||||
command: process.execPath,
|
||||
args: [packagedServerEntry],
|
||||
cwd: packagedServerRoot,
|
||||
env: buildServerEnvironment(options),
|
||||
logFile: path.join(options.paths.logsDir, 'server.log'),
|
||||
readyUrl: `http://127.0.0.1:${options.serverPort}/api/health`,
|
||||
}
|
||||
: {
|
||||
name: 'server',
|
||||
command: pnpmCommand(),
|
||||
args: ['--filter', '@veritas-kanban/server', 'dev'],
|
||||
cwd: options.repoRoot,
|
||||
env: buildServerEnvironment(options),
|
||||
logFile: path.join(options.paths.logsDir, 'server.log'),
|
||||
readyUrl: `http://127.0.0.1:${options.serverPort}/api/health`,
|
||||
};
|
||||
|
||||
if (options.isPackaged) {
|
||||
return [serverConfig];
|
||||
}
|
||||
|
||||
const webConfig: ManagedProcessConfig = {
|
||||
name: 'web',
|
||||
command: pnpmCommand(),
|
||||
args: [
|
||||
'--filter',
|
||||
'@veritas-kanban/web',
|
||||
'dev',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(options.webPort),
|
||||
],
|
||||
cwd: options.repoRoot,
|
||||
env: buildWebEnvironment(options),
|
||||
logFile: path.join(options.paths.logsDir, 'web.log'),
|
||||
readyUrl: `http://127.0.0.1:${options.webPort}`,
|
||||
};
|
||||
|
||||
return [serverConfig, webConfig];
|
||||
}
|
||||
124
desktop/src/main/menu.ts
Normal file
124
desktop/src/main/menu.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { Menu, type MenuItemConstructorOptions } from 'electron';
|
||||
|
||||
import {
|
||||
createDesktopCommandRequest,
|
||||
DESKTOP_COMMAND_REGISTRY,
|
||||
type DesktopCommandDispatcher,
|
||||
} from './commands.js';
|
||||
import type { DesktopStatusSnapshot } from './types.js';
|
||||
import type {
|
||||
DesktopCommandName,
|
||||
DesktopUpdateStatus,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
export interface ConfigureDesktopMenuOptions {
|
||||
dispatch(command: DesktopCommandName): void;
|
||||
copyVersionInfo(): void;
|
||||
status: DesktopStatusSnapshot;
|
||||
updateStatus?: DesktopUpdateStatus;
|
||||
}
|
||||
|
||||
export function configureDesktopMenu(options: ConfigureDesktopMenuOptions): void {
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(createDesktopMenuTemplate(options)));
|
||||
}
|
||||
|
||||
export function createDesktopMenuTemplate(
|
||||
options: ConfigureDesktopMenuOptions
|
||||
): MenuItemConstructorOptions[] {
|
||||
const command = (name: DesktopCommandName): MenuItemConstructorOptions => {
|
||||
const definition = DESKTOP_COMMAND_REGISTRY[name];
|
||||
return {
|
||||
label: definition.label,
|
||||
accelerator: definition.accelerator,
|
||||
enabled: isCommandEnabled(name, options.status, options.updateStatus),
|
||||
click: () => options.dispatch(name),
|
||||
};
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Veritas Kanban',
|
||||
submenu: [
|
||||
{ role: 'about', label: 'About Veritas Kanban' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Copy Version Information',
|
||||
click: () => options.copyVersionInfo(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
command('open-onboarding'),
|
||||
command('open-settings'),
|
||||
command('communication-health'),
|
||||
{ type: 'separator' },
|
||||
command('check-for-updates'),
|
||||
command('download-update'),
|
||||
command('install-update'),
|
||||
{ type: 'separator' },
|
||||
command('quit'),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
command('new-task'),
|
||||
command('import-data'),
|
||||
command('export-data'),
|
||||
command('create-backup'),
|
||||
],
|
||||
},
|
||||
{ role: 'editMenu' },
|
||||
{
|
||||
label: 'Navigate',
|
||||
submenu: [
|
||||
command('open-command-center'),
|
||||
command('open-search'),
|
||||
command('open-settings'),
|
||||
{ type: 'separator' },
|
||||
command('reset-layout'),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Desktop',
|
||||
submenu: [
|
||||
command('restart-local-server'),
|
||||
command('open-logs'),
|
||||
command('show-diagnostics'),
|
||||
command('create-debug-bundle'),
|
||||
{ type: 'separator' },
|
||||
command('test-notification'),
|
||||
command('test-squad-webhook'),
|
||||
command('copy-redacted-diagnostics'),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function dispatchDesktopMenuCommand(
|
||||
dispatcher: DesktopCommandDispatcher,
|
||||
command: DesktopCommandName
|
||||
): void {
|
||||
void dispatcher.dispatch(createDesktopCommandRequest(command, 'menu'));
|
||||
}
|
||||
|
||||
function isCommandEnabled(
|
||||
command: DesktopCommandName,
|
||||
status: DesktopStatusSnapshot,
|
||||
updateStatus?: DesktopUpdateStatus
|
||||
): boolean {
|
||||
if (command === 'restart-local-server') {
|
||||
return status.mode === 'local-dev' || status.mode === 'local-production';
|
||||
}
|
||||
if (command === 'open-logs') {
|
||||
return Boolean(status.logsDir);
|
||||
}
|
||||
if (command === 'test-squad-webhook') {
|
||||
return status.server.state === 'ready';
|
||||
}
|
||||
if (command === 'download-update') {
|
||||
return updateStatus?.state === 'available';
|
||||
}
|
||||
if (command === 'install-update') {
|
||||
return updateStatus?.state === 'ready';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
25
desktop/src/main/navigation.ts
Normal file
25
desktop/src/main/navigation.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { Shell } from 'electron';
|
||||
|
||||
import {
|
||||
redactDesktopBridgeError,
|
||||
validateOpenExternalRequest,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
export function hasSameOriginNavigation(url: string, trustedRendererOrigin: string): boolean {
|
||||
try {
|
||||
return new URL(url).origin === new URL(trustedRendererOrigin).origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openValidatedExternalUrl(shell: Shell, rawUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const { url } = validateOpenExternalRequest({ url: rawUrl });
|
||||
await shell.openExternal(url);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Blocked unsafe external navigation', redactDesktopBridgeError(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
129
desktop/src/main/notifications.ts
Normal file
129
desktop/src/main/notifications.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type { Notification as ElectronNotificationConstructor } from 'electron';
|
||||
|
||||
import type {
|
||||
DesktopNotificationAction,
|
||||
DesktopNotificationActionRequest,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
export type DesktopNotificationKind =
|
||||
| 'mention'
|
||||
| 'approval'
|
||||
| 'blocked-workflow'
|
||||
| 'agent-complete'
|
||||
| 'failed-run'
|
||||
| 'update-available'
|
||||
| 'setup-test';
|
||||
|
||||
export interface DesktopNotificationRequest {
|
||||
id: string;
|
||||
kind: DesktopNotificationKind;
|
||||
title: string;
|
||||
body: string;
|
||||
target?: {
|
||||
type:
|
||||
| 'task'
|
||||
| 'run'
|
||||
| 'workflow-gate'
|
||||
| 'approval'
|
||||
| 'settings'
|
||||
| 'maintenance'
|
||||
| 'work-product';
|
||||
id?: string;
|
||||
};
|
||||
dedupeKey?: string;
|
||||
privacyMode?: 'full' | 'private';
|
||||
}
|
||||
|
||||
export interface DesktopNotificationPreview {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
dedupeKey: string;
|
||||
target?: DesktopNotificationRequest['target'];
|
||||
}
|
||||
|
||||
export interface DesktopNotificationAdapter {
|
||||
show(preview: DesktopNotificationPreview, onClick: () => void): void;
|
||||
}
|
||||
|
||||
export class ElectronNotificationAdapter implements DesktopNotificationAdapter {
|
||||
constructor(private readonly NotificationCtor: typeof ElectronNotificationConstructor) {}
|
||||
|
||||
show(preview: DesktopNotificationPreview, onClick: () => void): void {
|
||||
if (!this.NotificationCtor.isSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new this.NotificationCtor({
|
||||
title: preview.title,
|
||||
body: preview.body,
|
||||
});
|
||||
notification.once('click', onClick);
|
||||
notification.show();
|
||||
}
|
||||
}
|
||||
|
||||
export class DesktopNotificationCenter {
|
||||
private readonly displayed = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly adapter: DesktopNotificationAdapter,
|
||||
private readonly dispatchAction: (request: DesktopNotificationActionRequest) => void
|
||||
) {}
|
||||
|
||||
show(request: DesktopNotificationRequest): DesktopNotificationPreview | null {
|
||||
const preview = createNotificationPreview(request);
|
||||
if (this.displayed.has(preview.dedupeKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.displayed.add(preview.dedupeKey);
|
||||
this.adapter.show(preview, () => {
|
||||
this.dispatchAction({
|
||||
notificationId: preview.id,
|
||||
action: 'open',
|
||||
taskId: preview.target?.type === 'task' ? preview.target.id : undefined,
|
||||
});
|
||||
});
|
||||
return preview;
|
||||
}
|
||||
|
||||
markRead(notificationId: string, action: DesktopNotificationAction = 'dismiss'): void {
|
||||
this.dispatchAction({
|
||||
notificationId,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationPreview(
|
||||
request: DesktopNotificationRequest
|
||||
): DesktopNotificationPreview {
|
||||
const privateMode = request.privacyMode === 'private';
|
||||
return {
|
||||
id: request.id,
|
||||
title: privateMode ? privacyTitle(request.kind) : request.title,
|
||||
body: privateMode ? 'Open Veritas Kanban to view details.' : request.body,
|
||||
dedupeKey: request.dedupeKey ?? `${request.kind}:${request.id}`,
|
||||
target: request.target,
|
||||
};
|
||||
}
|
||||
|
||||
function privacyTitle(kind: DesktopNotificationKind): string {
|
||||
switch (kind) {
|
||||
case 'mention':
|
||||
return 'New mention';
|
||||
case 'approval':
|
||||
return 'Approval requested';
|
||||
case 'blocked-workflow':
|
||||
return 'Workflow blocked';
|
||||
case 'agent-complete':
|
||||
return 'Agent run completed';
|
||||
case 'failed-run':
|
||||
return 'Run failed';
|
||||
case 'update-available':
|
||||
return 'Update available';
|
||||
case 'setup-test':
|
||||
return 'Veritas Kanban notification test';
|
||||
}
|
||||
}
|
||||
149
desktop/src/main/paths.ts
Normal file
149
desktop/src/main/paths.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import path from 'node:path';
|
||||
import { cp, mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { DesktopPaths } from './types.js';
|
||||
|
||||
export interface CreateDesktopPathsOptions {
|
||||
userDataPath: string;
|
||||
repoRoot: string;
|
||||
isPackaged: boolean;
|
||||
profile?: string;
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
function profileSegment(profile: string | undefined): string {
|
||||
return (profile || 'default').replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||
}
|
||||
|
||||
function workspaceSegment(workspace: string | undefined): string {
|
||||
return (workspace || 'local').replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||
}
|
||||
|
||||
export function createDesktopPaths(options: CreateDesktopPathsOptions): DesktopPaths {
|
||||
const profile = profileSegment(options.profile);
|
||||
const workspace = workspaceSegment(options.workspace);
|
||||
const legacyAppHome = options.isPackaged
|
||||
? options.userDataPath
|
||||
: path.join(options.repoRoot, '.veritas-desktop-dev', profile);
|
||||
const profileDir = options.isPackaged
|
||||
? path.join(options.userDataPath, 'profiles', profile)
|
||||
: path.join(options.repoRoot, '.veritas-desktop-dev', 'profiles', profile);
|
||||
const workspaceDir = path.join(profileDir, 'workspaces', workspace);
|
||||
const appHome = workspaceDir;
|
||||
const configDir = path.join(appHome, 'config');
|
||||
|
||||
return {
|
||||
profile,
|
||||
workspace,
|
||||
appHome,
|
||||
profileDir,
|
||||
workspaceDir,
|
||||
legacyAppHome: legacyAppHome === appHome ? null : legacyAppHome,
|
||||
configDir,
|
||||
dataDir: path.join(appHome, 'data'),
|
||||
logsDir: path.join(appHome, 'logs'),
|
||||
runtimeDir: path.join(appHome, 'runtime'),
|
||||
exportsDir: path.join(appHome, 'exports'),
|
||||
backupsDir: path.join(appHome, 'backups'),
|
||||
debugBundlesDir: path.join(appHome, 'debug-bundles'),
|
||||
secretsFile: path.join(configDir, 'desktop-secrets.json'),
|
||||
migrationManifest: path.join(configDir, 'desktop-path-migration.json'),
|
||||
};
|
||||
}
|
||||
|
||||
const MIGRATED_ENTRIES = ['config', 'data', 'logs', 'exports', 'backups', 'debug-bundles'] as const;
|
||||
|
||||
export interface DesktopPathMigrationResult {
|
||||
migrated: boolean;
|
||||
from: string | null;
|
||||
to: string;
|
||||
copiedEntries: string[];
|
||||
skippedReason?: string;
|
||||
}
|
||||
|
||||
async function exists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDesktopPathLayout(
|
||||
paths: DesktopPaths
|
||||
): Promise<DesktopPathMigrationResult> {
|
||||
await Promise.all([
|
||||
mkdir(paths.profileDir, { recursive: true }),
|
||||
mkdir(paths.workspaceDir, { recursive: true }),
|
||||
mkdir(paths.configDir, { recursive: true }),
|
||||
mkdir(paths.dataDir, { recursive: true }),
|
||||
mkdir(paths.logsDir, { recursive: true }),
|
||||
mkdir(paths.runtimeDir, { recursive: true }),
|
||||
mkdir(paths.exportsDir, { recursive: true }),
|
||||
mkdir(paths.backupsDir, { recursive: true }),
|
||||
mkdir(paths.debugBundlesDir, { recursive: true }),
|
||||
]);
|
||||
|
||||
if (!paths.legacyAppHome) {
|
||||
return {
|
||||
migrated: false,
|
||||
from: null,
|
||||
to: paths.appHome,
|
||||
copiedEntries: [],
|
||||
skippedReason: 'no legacy app home',
|
||||
};
|
||||
}
|
||||
|
||||
if (!(await exists(paths.legacyAppHome))) {
|
||||
return {
|
||||
migrated: false,
|
||||
from: paths.legacyAppHome,
|
||||
to: paths.appHome,
|
||||
copiedEntries: [],
|
||||
skippedReason: 'legacy app home does not exist',
|
||||
};
|
||||
}
|
||||
|
||||
const copiedEntries: string[] = [];
|
||||
for (const entry of MIGRATED_ENTRIES) {
|
||||
const source = path.join(paths.legacyAppHome, entry);
|
||||
const target = path.join(paths.appHome, entry);
|
||||
if (await exists(source)) {
|
||||
await cp(source, target, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
});
|
||||
copiedEntries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const result: DesktopPathMigrationResult = {
|
||||
migrated: copiedEntries.length > 0,
|
||||
from: paths.legacyAppHome,
|
||||
to: paths.appHome,
|
||||
copiedEntries,
|
||||
skippedReason: copiedEntries.length > 0 ? undefined : 'workspace app home already initialized',
|
||||
};
|
||||
|
||||
await writeFile(paths.migrationManifest, JSON.stringify(result, null, 2), 'utf-8');
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveRepoRoot(appPath: string, cwd = process.cwd()): string {
|
||||
if (process.env.VERITAS_REPO_ROOT) {
|
||||
return process.env.VERITAS_REPO_ROOT;
|
||||
}
|
||||
|
||||
if (path.basename(cwd) === 'desktop') {
|
||||
return path.resolve(cwd, '..');
|
||||
}
|
||||
|
||||
const segments = appPath.split(path.sep);
|
||||
const desktopIndex = segments.lastIndexOf('desktop');
|
||||
if (desktopIndex > 0) {
|
||||
return segments.slice(0, desktopIndex).join(path.sep) || path.sep;
|
||||
}
|
||||
|
||||
return path.resolve(appPath, '..');
|
||||
}
|
||||
119
desktop/src/main/ports.ts
Normal file
119
desktop/src/main/ports.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import net from 'node:net';
|
||||
|
||||
async function isPortAcceptingConnections(port: number, host: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const socket = net.createConnection({ port, host });
|
||||
const finish = (accepting: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(accepting);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(250, () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function isPortAvailableOnHost(port: number, host: string): Promise<boolean> {
|
||||
// macOS can allow a specific-address bind beside an existing IPv6 wildcard
|
||||
// listener. Probe for an accepting server before trusting the bind check.
|
||||
if (await isPortAcceptingConnections(port, host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', (error: NodeJS.ErrnoException) => {
|
||||
if (host === '::1' && (error.code === 'EAFNOSUPPORT' || error.code === 'EADDRNOTAVAIL')) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
resolve(false);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
async function isPortAvailableOnComplementaryHost(port: number, host: string): Promise<boolean> {
|
||||
return host === '127.0.0.1' ? isPortAvailableOnHost(port, '::1') : true;
|
||||
}
|
||||
|
||||
export async function isPortAvailable(port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||
if (!(await isPortAvailableOnHost(port, host))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Chromium can reach an IPv6 wildcard listener even when the renderer URL
|
||||
// names 127.0.0.1. Reject the candidate if either loopback family is busy so
|
||||
// the desktop app cannot attach to an unrelated local Veritas/dev server.
|
||||
return isPortAvailableOnComplementaryHost(port, host);
|
||||
}
|
||||
|
||||
export async function findAvailablePort(
|
||||
preferredPort: number,
|
||||
host = '127.0.0.1',
|
||||
maxAttempts = 50,
|
||||
excludedPorts: ReadonlySet<number> = new Set()
|
||||
): Promise<number> {
|
||||
for (let offset = 0; offset < maxAttempts; offset += 1) {
|
||||
const candidate = preferredPort + offset;
|
||||
if (excludedPorts.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (await isPortAvailable(candidate, host)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const reservations: net.Server[] = [];
|
||||
try {
|
||||
// Keep rejected reservations open so the OS cannot hand the same
|
||||
// ephemeral candidate back to a later attempt. The extra attempts cover
|
||||
// candidates rejected by the complementary-family collision probe.
|
||||
const fallbackAttempts = excludedPorts.size + 50;
|
||||
for (let attempt = 0; attempt < fallbackAttempts; attempt += 1) {
|
||||
const reservation = net.createServer();
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
reservation.once('error', reject);
|
||||
reservation.once('listening', () => {
|
||||
const address = reservation.address();
|
||||
if (typeof address === 'object' && address) {
|
||||
resolve(address.port);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Unable to allocate an ephemeral port'));
|
||||
});
|
||||
reservation.listen(0, host);
|
||||
});
|
||||
reservations.push(reservation);
|
||||
|
||||
if (excludedPorts.has(port)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The live reservation proves the requested family is available. Probe
|
||||
// the other loopback family with the same connect + bind checks used by
|
||||
// isPortAvailable before releasing the candidate.
|
||||
if (!(await isPortAvailableOnComplementaryHost(port, host))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(
|
||||
reservations.map(
|
||||
(reservation) => new Promise<void>((resolve) => reservation.close(() => resolve()))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('Unable to allocate an unexcluded ephemeral port');
|
||||
}
|
||||
131
desktop/src/main/process-supervisor.ts
Normal file
131
desktop/src/main/process-supervisor.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { createWriteStream, type WriteStream } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
|
||||
import type { ManagedProcessConfig, ManagedProcessSnapshot, DesktopProcessState } from './types.js';
|
||||
|
||||
export class ProcessSupervisor extends EventEmitter {
|
||||
private child: ChildProcess | null = null;
|
||||
private state: DesktopProcessState = 'idle';
|
||||
private lastError: string | null = null;
|
||||
private startedAt: string | null = null;
|
||||
private exitedAt: string | null = null;
|
||||
private logStream: WriteStream | null = null;
|
||||
private stopping = false;
|
||||
|
||||
constructor(private readonly config: ManagedProcessConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
snapshot(): ManagedProcessSnapshot {
|
||||
return {
|
||||
name: this.config.name,
|
||||
state: this.state,
|
||||
pid: this.child?.pid ?? null,
|
||||
port: this.config.readyUrl ? Number(new URL(this.config.readyUrl).port) : null,
|
||||
lastError: this.lastError,
|
||||
startedAt: this.startedAt,
|
||||
exitedAt: this.exitedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.child) return;
|
||||
|
||||
await mkdir(path.dirname(this.config.logFile), { recursive: true });
|
||||
this.logStream = createWriteStream(this.config.logFile, { flags: 'a' });
|
||||
this.setState('starting');
|
||||
this.stopping = false;
|
||||
this.startedAt = new Date().toISOString();
|
||||
this.exitedAt = null;
|
||||
this.lastError = null;
|
||||
|
||||
const child = spawn(this.config.command, this.config.args, {
|
||||
cwd: this.config.cwd,
|
||||
env: this.config.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
this.child = child;
|
||||
this.log(`[desktop] started ${this.config.command} ${this.config.args.join(' ')}\n`);
|
||||
child.stdout?.on('data', (chunk: Buffer) => this.log(chunk));
|
||||
child.stderr?.on('data', (chunk: Buffer) => this.log(chunk));
|
||||
|
||||
child.once('error', (error) => {
|
||||
this.lastError = error.message;
|
||||
this.setState('failed');
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
this.exitedAt = new Date().toISOString();
|
||||
this.log(`[desktop] exited code=${code ?? 'null'} signal=${signal ?? 'null'}\n`);
|
||||
const wasReady = this.state === 'ready';
|
||||
this.child = null;
|
||||
this.closeLogStream();
|
||||
if (this.stopping || (code === 0 && wasReady)) {
|
||||
this.setState('stopped');
|
||||
return;
|
||||
}
|
||||
const exitDetail = `code ${code ?? 'null'} signal ${signal ?? 'null'}`;
|
||||
this.lastError = wasReady
|
||||
? `${this.config.name} exited unexpectedly with ${exitDetail}`
|
||||
: `${this.config.name} exited before becoming ready with ${exitDetail}`;
|
||||
this.setState('failed');
|
||||
});
|
||||
}
|
||||
|
||||
markReady(): void {
|
||||
if (this.child) {
|
||||
this.setState('ready');
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.child) {
|
||||
this.setState('stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopping = true;
|
||||
this.setState('stopping');
|
||||
const child = this.child;
|
||||
const timeoutMs = this.config.shutdownTimeoutMs ?? 5000;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private setState(state: DesktopProcessState): void {
|
||||
this.state = state;
|
||||
this.emit('state', this.snapshot());
|
||||
}
|
||||
|
||||
private log(chunk: Buffer | string): void {
|
||||
this.logStream?.write(chunk);
|
||||
}
|
||||
|
||||
private closeLogStream(): void {
|
||||
this.logStream?.end();
|
||||
this.logStream = null;
|
||||
}
|
||||
}
|
||||
177
desktop/src/main/runtime.ts
Normal file
177
desktop/src/main/runtime.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createManagedProcessConfigs } from './lifecycle.js';
|
||||
import { ensureDesktopPathLayout, type DesktopPathMigrationResult } from './paths.js';
|
||||
import { ProcessSupervisor } from './process-supervisor.js';
|
||||
import type { DesktopPaths, DesktopRuntimeSecrets, DesktopStatusSnapshot } from './types.js';
|
||||
|
||||
export interface DesktopRuntimeOptions {
|
||||
repoRoot: string;
|
||||
resourcesPath?: string;
|
||||
paths: DesktopPaths;
|
||||
serverPort: number;
|
||||
webPort: number;
|
||||
isPackaged: boolean;
|
||||
profile: string;
|
||||
workspace: string;
|
||||
secrets: DesktopRuntimeSecrets;
|
||||
secretsBackedByKeychain: boolean;
|
||||
}
|
||||
|
||||
export class DesktopRuntime extends EventEmitter {
|
||||
private readonly server: ProcessSupervisor;
|
||||
private readonly web: ProcessSupervisor | null;
|
||||
private lastError: string | null = null;
|
||||
private readonly serverOrigin: string;
|
||||
private readonly rendererOrigin: string;
|
||||
private pathMigration: DesktopPathMigrationResult | null = null;
|
||||
private readonly warnings: string[] = [];
|
||||
|
||||
constructor(private readonly options: DesktopRuntimeOptions) {
|
||||
super();
|
||||
this.warnings.push(...options.secrets.warnings);
|
||||
const [serverConfig, webConfig] = createManagedProcessConfigs(options);
|
||||
this.server = new ProcessSupervisor(serverConfig);
|
||||
this.web = webConfig ? new ProcessSupervisor(webConfig) : null;
|
||||
this.serverOrigin = `http://127.0.0.1:${options.serverPort}`;
|
||||
this.rendererOrigin = options.isPackaged
|
||||
? this.serverOrigin
|
||||
: `http://127.0.0.1:${options.webPort}`;
|
||||
|
||||
for (const supervisor of [this.server, this.web].filter(Boolean) as ProcessSupervisor[]) {
|
||||
supervisor.on('state', () => this.emitStatus());
|
||||
supervisor.on('error', (error) => {
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.emitStatus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getRendererOrigin(): string {
|
||||
return this.rendererOrigin;
|
||||
}
|
||||
|
||||
snapshot(): DesktopStatusSnapshot {
|
||||
return {
|
||||
mode: this.options.isPackaged ? 'local-production' : 'local-dev',
|
||||
profile: this.options.profile,
|
||||
workspace: this.options.workspace,
|
||||
server: this.server.snapshot(),
|
||||
web: this.web?.snapshot(),
|
||||
serverOrigin: this.serverOrigin,
|
||||
rendererOrigin: this.rendererOrigin,
|
||||
appHome: this.options.paths.appHome,
|
||||
dataDir: this.options.paths.dataDir,
|
||||
configDir: this.options.paths.configDir,
|
||||
logsDir: this.options.paths.logsDir,
|
||||
secretsBackedByKeychain: this.options.secretsBackedByKeychain,
|
||||
warnings: this.runtimeWarnings(),
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.ensureDirectories();
|
||||
await this.writeRuntimeState();
|
||||
await this.server.start();
|
||||
await this.waitForReady(this.serverOrigin + '/api/health', 'server', this.server);
|
||||
this.server.markReady();
|
||||
|
||||
if (this.web) {
|
||||
await this.web.start();
|
||||
await this.waitForReady(this.rendererOrigin, 'web', this.web);
|
||||
this.web.markReady();
|
||||
}
|
||||
|
||||
this.emitStatus();
|
||||
}
|
||||
|
||||
async restartLocalServer(): Promise<DesktopStatusSnapshot> {
|
||||
await this.server.restart();
|
||||
await this.waitForReady(this.serverOrigin + '/api/health', 'server', this.server);
|
||||
this.server.markReady();
|
||||
this.emitStatus();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await Promise.all([this.web?.stop(), this.server.stop()].filter(Boolean) as Promise<void>[]);
|
||||
await rm(path.join(this.options.paths.runtimeDir, 'server-state.json'), { force: true });
|
||||
}
|
||||
|
||||
private async ensureDirectories(): Promise<void> {
|
||||
this.pathMigration = await ensureDesktopPathLayout(this.options.paths);
|
||||
}
|
||||
|
||||
private async writeRuntimeState(): Promise<void> {
|
||||
await mkdir(this.options.paths.runtimeDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(this.options.paths.runtimeDir, 'server-state.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: this.options.isPackaged ? 'local-production' : 'local-dev',
|
||||
profile: this.options.profile,
|
||||
workspace: this.options.workspace,
|
||||
serverOrigin: this.serverOrigin,
|
||||
rendererOrigin: this.rendererOrigin,
|
||||
appHome: this.options.paths.appHome,
|
||||
dataDir: this.options.paths.dataDir,
|
||||
configDir: this.options.paths.configDir,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async waitForReady(
|
||||
url: string,
|
||||
label: string,
|
||||
supervisor: ProcessSupervisor
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 45_000;
|
||||
let lastError: string | null = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const processSnapshot = supervisor.snapshot();
|
||||
if (processSnapshot.state === 'failed' || processSnapshot.state === 'stopped') {
|
||||
const detail = processSnapshot.lastError ? `: ${processSnapshot.lastError}` : '';
|
||||
this.lastError = `${label} stopped before becoming ready${detail}`;
|
||||
this.emitStatus();
|
||||
throw new Error(this.lastError);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
lastError = `${label} returned ${response.status}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
this.lastError = `${label} did not become ready: ${lastError ?? 'timeout'}`;
|
||||
this.emitStatus();
|
||||
throw new Error(this.lastError);
|
||||
}
|
||||
|
||||
private emitStatus(): void {
|
||||
this.emit('status', this.snapshot());
|
||||
}
|
||||
|
||||
private runtimeWarnings(): string[] {
|
||||
const warnings = [...this.warnings];
|
||||
if (this.pathMigration?.migrated && this.pathMigration.from) {
|
||||
warnings.push(
|
||||
`Desktop data was copied from ${this.pathMigration.from} to ${this.pathMigration.to}.`
|
||||
);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
168
desktop/src/main/secrets.ts
Normal file
168
desktop/src/main/secrets.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { DesktopPaths, DesktopRuntimeSecrets } from './types.js';
|
||||
|
||||
export interface DesktopSafeStorage {
|
||||
isEncryptionAvailable(): boolean;
|
||||
encryptString(value: string): Buffer;
|
||||
decryptString(encrypted: Buffer): string;
|
||||
}
|
||||
|
||||
export interface DesktopSecretRecord {
|
||||
ciphertext: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DesktopSecretFile {
|
||||
version: 1;
|
||||
keychainProvider: 'electron-safeStorage';
|
||||
scope: {
|
||||
profile: string;
|
||||
workspace: string;
|
||||
};
|
||||
secrets: Record<string, DesktopSecretRecord>;
|
||||
}
|
||||
|
||||
export interface DesktopSecretState {
|
||||
available: boolean;
|
||||
filePath: string;
|
||||
recoveryActions: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopSecretStoreOptions {
|
||||
safeStorage: DesktopSafeStorage;
|
||||
paths: DesktopPaths;
|
||||
}
|
||||
|
||||
function createSecretValue(prefix: string): string {
|
||||
return `${prefix}_${randomBytes(48).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function emptySecretFile(paths: DesktopPaths): DesktopSecretFile {
|
||||
return {
|
||||
version: 1,
|
||||
keychainProvider: 'electron-safeStorage',
|
||||
scope: {
|
||||
profile: paths.profile,
|
||||
workspace: paths.workspace,
|
||||
},
|
||||
secrets: {},
|
||||
};
|
||||
}
|
||||
|
||||
function recoveryActions(paths: DesktopPaths): string[] {
|
||||
return [
|
||||
'Confirm macOS Keychain is unlocked and available.',
|
||||
`Quit Veritas Kanban and move ${path.basename(paths.secretsFile)} out of ${paths.configDir}.`,
|
||||
'Restart the app so desktop secrets can be regenerated for this profile and workspace.',
|
||||
];
|
||||
}
|
||||
|
||||
export class DesktopSecretStore {
|
||||
constructor(private readonly options: DesktopSecretStoreOptions) {}
|
||||
|
||||
inspect(): DesktopSecretState {
|
||||
if (!this.options.safeStorage.isEncryptionAvailable()) {
|
||||
return {
|
||||
available: false,
|
||||
filePath: this.options.paths.secretsFile,
|
||||
recoveryActions: recoveryActions(this.options.paths),
|
||||
error: 'Electron safeStorage encryption is unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
filePath: this.options.paths.secretsFile,
|
||||
recoveryActions: recoveryActions(this.options.paths),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
async loadRuntimeSecrets(): Promise<DesktopRuntimeSecrets> {
|
||||
const state = this.inspect();
|
||||
if (!state.available) {
|
||||
throw new Error(`${state.error} ${state.recoveryActions.join(' ')}`);
|
||||
}
|
||||
|
||||
const adminKey = await this.getOrCreateSecret('admin-key', () => createSecretValue('vk_admin'));
|
||||
const jwtSecret = await this.getOrCreateSecret('jwt-secret', () => createSecretValue('vk_jwt'));
|
||||
|
||||
return {
|
||||
adminKey,
|
||||
jwtSecret,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
async getOrCreateSecret(name: string, createValue: () => string): Promise<string> {
|
||||
const file = await this.readSecretFile();
|
||||
const existing = file.secrets[name];
|
||||
if (existing) {
|
||||
return this.decrypt(existing.ciphertext);
|
||||
}
|
||||
|
||||
const value = createValue();
|
||||
file.secrets[name] = {
|
||||
ciphertext: this.encrypt(value),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.writeSecretFile(file);
|
||||
return value;
|
||||
}
|
||||
|
||||
async clearSecrets(): Promise<void> {
|
||||
await this.writeSecretFile(emptySecretFile(this.options.paths));
|
||||
}
|
||||
|
||||
private async readSecretFile(): Promise<DesktopSecretFile> {
|
||||
try {
|
||||
const raw = await readFile(this.options.paths.secretsFile, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as DesktopSecretFile;
|
||||
if (
|
||||
parsed.version !== 1 ||
|
||||
parsed.scope?.profile !== this.options.paths.profile ||
|
||||
parsed.scope?.workspace !== this.options.paths.workspace ||
|
||||
typeof parsed.secrets !== 'object' ||
|
||||
parsed.secrets === null
|
||||
) {
|
||||
throw new Error('Desktop secret file scope or schema is invalid.');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return emptySecretFile(this.options.paths);
|
||||
}
|
||||
throw new Error(
|
||||
`Desktop secret state is unreadable. ${recoveryActions(this.options.paths).join(' ')}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async writeSecretFile(file: DesktopSecretFile): Promise<void> {
|
||||
await mkdir(path.dirname(this.options.paths.secretsFile), { recursive: true });
|
||||
await writeFile(this.options.paths.secretsFile, JSON.stringify(file, null, 2), {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
private encrypt(value: string): string {
|
||||
return this.options.safeStorage.encryptString(value).toString('base64');
|
||||
}
|
||||
|
||||
private decrypt(ciphertext: string): string {
|
||||
try {
|
||||
return this.options.safeStorage.decryptString(Buffer.from(ciphertext, 'base64'));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Desktop secret could not be decrypted. ${recoveryActions(this.options.paths).join(' ')}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
desktop/src/main/status-page.ts
Normal file
88
desktop/src/main/status-page.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { createHash } from 'crypto';
|
||||
import type { DesktopStatusSnapshot } from './types.js';
|
||||
|
||||
const STATUS_PAGE_CSS = `
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #111318;
|
||||
color: #eef1f7;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
main {
|
||||
width: min(720px, calc(100vw - 48px));
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 28px;
|
||||
font-weight: 650;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b8c0cf;
|
||||
line-height: 1.5;
|
||||
}
|
||||
pre {
|
||||
margin-top: 24px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid #2b3242;
|
||||
background: #171b24;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
color: #d8deea;
|
||||
font-size: 12px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function styleHash(style: string): string {
|
||||
return `'sha256-${createHash('sha256').update(style).digest('base64')}'`;
|
||||
}
|
||||
|
||||
export function statusPage(title: string, message: string, status?: DesktopStatusSnapshot): string {
|
||||
const statusJson = status ? escapeHtml(JSON.stringify(status, null, 2)) : '';
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; style-src ${styleHash(STATUS_PAGE_CSS)}; img-src data:; script-src 'none';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>${STATUS_PAGE_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${statusJson ? `<pre>${statusJson}</pre>` : ''}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function statusPageUrl(
|
||||
title: string,
|
||||
message: string,
|
||||
status?: DesktopStatusSnapshot
|
||||
): string {
|
||||
return `data:text/html;charset=utf-8,${encodeURIComponent(statusPage(title, message, status))}`;
|
||||
}
|
||||
79
desktop/src/main/types.ts
Normal file
79
desktop/src/main/types.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
export type DesktopProcessName = 'server' | 'web';
|
||||
|
||||
export type DesktopProcessState = 'idle' | 'starting' | 'ready' | 'stopping' | 'stopped' | 'failed';
|
||||
|
||||
export type DesktopConnectionMode = 'local-dev' | 'local-production';
|
||||
|
||||
export interface DesktopPaths {
|
||||
profile: string;
|
||||
workspace: string;
|
||||
appHome: string;
|
||||
profileDir: string;
|
||||
workspaceDir: string;
|
||||
legacyAppHome: string | null;
|
||||
configDir: string;
|
||||
dataDir: string;
|
||||
logsDir: string;
|
||||
runtimeDir: string;
|
||||
exportsDir: string;
|
||||
backupsDir: string;
|
||||
debugBundlesDir: string;
|
||||
secretsFile: string;
|
||||
migrationManifest: string;
|
||||
}
|
||||
|
||||
export interface DesktopRuntimeSecrets {
|
||||
adminKey: string;
|
||||
jwtSecret: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ManagedProcessConfig {
|
||||
name: DesktopProcessName;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logFile: string;
|
||||
readyUrl?: string;
|
||||
shutdownTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ManagedProcessSnapshot {
|
||||
name: DesktopProcessName;
|
||||
state: DesktopProcessState;
|
||||
pid: number | null;
|
||||
port: number | null;
|
||||
lastError: string | null;
|
||||
startedAt: string | null;
|
||||
exitedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopStatusSnapshot {
|
||||
mode: DesktopConnectionMode;
|
||||
profile: string;
|
||||
workspace: string;
|
||||
server: ManagedProcessSnapshot;
|
||||
web?: ManagedProcessSnapshot;
|
||||
serverOrigin: string | null;
|
||||
rendererOrigin: string | null;
|
||||
appHome: string;
|
||||
dataDir: string;
|
||||
configDir: string;
|
||||
logsDir: string;
|
||||
secretsBackedByKeychain: boolean;
|
||||
warnings: string[];
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopAppInfo {
|
||||
name: string;
|
||||
appId: string;
|
||||
version: string;
|
||||
buildIdentity: string | null;
|
||||
channel: 'dev' | 'beta' | 'stable';
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
osVersion: string;
|
||||
packaged: boolean;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue