mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
662 Commits
feat/issue
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
557ac45e93 | ||
|
|
7843da0315 | ||
|
|
caa18a6983 | ||
|
|
6e980925cf | ||
|
|
8bc20ce161 | ||
|
|
8816e1669c | ||
|
|
209300c86f | ||
|
|
3d98f64ea1 | ||
|
|
ec30e46947 | ||
|
|
6428a10046 | ||
|
|
fe6208c61f | ||
|
|
336f90fd26 | ||
|
|
6d6bc5a6f2 | ||
|
|
f3d20fd50d | ||
|
|
fe13259cc6 | ||
|
|
6a2432b16b | ||
|
|
3a5f94cbdd | ||
|
|
bfe407ac55 | ||
|
|
d12d690c38 | ||
|
|
a36252ca99 | ||
|
|
0fdd0054b9 | ||
|
|
9a97ee1f4c | ||
|
|
f029eb01b8 | ||
|
|
f0f3cb5c3a | ||
|
|
94c9d2807a | ||
|
|
fa804c2215 | ||
|
|
48a8a2793e | ||
|
|
cd50c31201 | ||
|
|
ac8b08e540 | ||
|
|
e3a1b951fb | ||
|
|
b5ee6f2579 | ||
|
|
c0b4e7e8b8 | ||
|
|
efb0c1dccf | ||
|
|
8c518c350a | ||
|
|
f8c6dd505f | ||
|
|
b5c6a9b8f0 | ||
|
|
7395b51aee | ||
|
|
ce52374d5d | ||
|
|
441554a520 | ||
|
|
93cf95f799 | ||
|
|
fe358feff0 | ||
|
|
a71aa6c544 | ||
|
|
1b30ad0ba6 | ||
|
|
b30fd98605 | ||
|
|
75d12c26c5 | ||
|
|
9b94914bc8 | ||
|
|
59ace95a1e | ||
|
|
3c46c5baa3 | ||
|
|
c38af55a8e | ||
|
|
df920e8641 | ||
|
|
0427fd8cc7 | ||
|
|
d930bcaa18 | ||
|
|
5a44c255fe | ||
|
|
8a55473bb8 | ||
|
|
ce94c80f5a | ||
|
|
176f1bfdbb | ||
|
|
a81a6b1578 | ||
|
|
0e8a7b1734 | ||
|
|
621526b38d | ||
|
|
244434bcfa | ||
|
|
970b7fd1d3 | ||
|
|
f76e3fb8f4 | ||
|
|
b6d30c0e00 | ||
|
|
129a8b927f | ||
|
|
ce447c7f06 | ||
|
|
5dad1f0915 | ||
|
|
c0db3e0e76 | ||
|
|
6bbe059055 | ||
|
|
cf70860a0b | ||
|
|
9f350e312d | ||
|
|
08c3513eef | ||
|
|
817e69a9eb | ||
|
|
f94b0100cd | ||
|
|
287a9eb546 | ||
|
|
45dad23074 | ||
|
|
762e64d469 | ||
|
|
f1415e9622 | ||
|
|
8030f1adbc | ||
|
|
eacf33299a | ||
|
|
cf012b2706 | ||
|
|
2cbebfc568 | ||
|
|
100146c49e | ||
|
|
de982f3a4e | ||
|
|
53cb01cc91 | ||
|
|
afa711b442 | ||
|
|
8d6e5f2bcc | ||
|
|
c460206846 | ||
|
|
70e4f44860 | ||
|
|
4b10c9354a | ||
|
|
d88fe2608e | ||
|
|
c79cfaf330 | ||
|
|
60c5848794 | ||
|
|
642c6ae5ee | ||
|
|
1163f684fb | ||
|
|
ff1d348274 | ||
|
|
b4b69f89f6 | ||
|
|
a3c6f07668 | ||
|
|
b2649fb47f | ||
|
|
c2a5ed73e8 | ||
|
|
f0c0a64ddd | ||
|
|
2ecddc8fc8 | ||
|
|
2a2e6f4746 | ||
|
|
6538496ee4 | ||
|
|
69ef002bbb | ||
|
|
7dad45d444 | ||
|
|
7ade4b432d | ||
|
|
cbb2cf0c6c | ||
|
|
d94b704a71 | ||
|
|
76ba9cfb0b | ||
|
|
40aa23a528 | ||
|
|
d3f7570177 | ||
|
|
34e452776b | ||
|
|
2551aa53ef | ||
|
|
d779cbd183 | ||
|
|
10b6afc1ec | ||
|
|
4f58f0c8eb | ||
|
|
0399e387f8 | ||
|
|
a744cd4f45 | ||
|
|
bfa9bec8c4 | ||
|
|
bf71802451 | ||
|
|
09e6190400 | ||
|
|
0798b5f8bb | ||
|
|
e568896357 | ||
|
|
8748557c7b | ||
|
|
7f0c23a6ba | ||
|
|
e6767d2ba3 | ||
|
|
1ceb75e218 | ||
|
|
9138c05993 | ||
|
|
091ed7370a | ||
|
|
35557c0b11 | ||
|
|
03ad47200b | ||
|
|
93b754de53 | ||
|
|
609d2e06ae | ||
|
|
7c436c0dcb | ||
|
|
55ae78b902 | ||
|
|
cc00fda513 | ||
|
|
04e571b02f | ||
|
|
c62bd0ca12 | ||
|
|
51c7dbbeee | ||
|
|
46d745cb60 | ||
|
|
0a998d1cef | ||
|
|
a366984014 | ||
|
|
9ba9ea66f8 | ||
|
|
2be6fdae90 | ||
|
|
653c0adeee | ||
|
|
4458753102 | ||
|
|
3c0ed0f732 | ||
|
|
999d0728c5 | ||
|
|
b6a69c113e | ||
|
|
7995f7368f | ||
|
|
ed1a1dc6b1 | ||
|
|
97755ae45d | ||
|
|
7a896d3852 | ||
|
|
da63165cdc | ||
|
|
013584ef80 | ||
|
|
bb4944bae2 | ||
|
|
42e392c727 | ||
|
|
158a100779 | ||
|
|
e178682acd | ||
|
|
8779db976c | ||
|
|
eba68c15fd | ||
|
|
345cb984a9 | ||
|
|
f3355049bc | ||
|
|
dca86acc69 | ||
|
|
c71525e198 | ||
|
|
977dc6479d | ||
|
|
a97bd3da0b | ||
|
|
9dfe119f47 | ||
|
|
f2efd4b529 | ||
|
|
a1de20e971 | ||
|
|
27d0865f5f | ||
|
|
2cd6024851 | ||
|
|
5e74c411dc | ||
|
|
418049856f | ||
|
|
00042c0ec7 | ||
|
|
7c7d7feed3 | ||
|
|
6a451c1ce7 | ||
|
|
8c0708bb5d | ||
|
|
9170b01739 | ||
|
|
d37595b85e | ||
|
|
03310a581a | ||
|
|
fe0d450471 | ||
|
|
bc1185f525 | ||
|
|
0d95a7c7ef | ||
|
|
8587243ab6 | ||
|
|
740d8e773d | ||
|
|
9550e6c4e0 | ||
|
|
880c614039 | ||
|
|
f1f693afa5 | ||
|
|
c148288d5a | ||
|
|
ff5f6ac2ee | ||
|
|
a0d43ca31a | ||
|
|
a29ecfe02a | ||
|
|
8d3cb21c03 | ||
|
|
2b16cbb27a | ||
|
|
a757f3a8c4 | ||
|
|
56c38dc521 | ||
|
|
4bc9969765 | ||
|
|
5b4ee7c5e1 | ||
|
|
b2b909a90f | ||
|
|
bf5395f9ee | ||
|
|
cd92aad9e1 | ||
|
|
017f69c123 | ||
|
|
1e9266f063 | ||
|
|
1d71df8622 | ||
|
|
576f20f2c7 | ||
|
|
e01fa6bd9e | ||
|
|
f1236b2358 | ||
|
|
0b60f78e8a | ||
|
|
5cd58183b2 | ||
|
|
83ff80c3ed | ||
|
|
8fb3bd322e | ||
|
|
06b1b99638 | ||
|
|
156982dc83 | ||
|
|
b239aa383e | ||
|
|
e2e5de1b26 | ||
|
|
0faf1363ee | ||
|
|
6c92108b09 | ||
|
|
a94c6481dd | ||
|
|
b4de4c9e9f | ||
|
|
7cac8014c9 | ||
|
|
be8b099c12 | ||
|
|
458b1e19e2 | ||
|
|
acad93163b | ||
|
|
526e336081 | ||
|
|
f4ce4c249d | ||
|
|
69f8380b9c | ||
|
|
2e5af72cdc | ||
|
|
0a0a86da2c | ||
|
|
96e87f7200 | ||
|
|
9e7d1eb764 | ||
|
|
007a1ca284 | ||
|
|
c5fce56887 | ||
|
|
04747b45a2 | ||
|
|
01232fc2f9 | ||
|
|
4372c5f4fa | ||
|
|
a73a9d4036 | ||
|
|
12bf7cac34 | ||
|
|
64ed0806ff | ||
|
|
b927684e3d | ||
|
|
e9bed4eb13 | ||
|
|
297b436e65 | ||
|
|
4165401d16 | ||
|
|
6097f7392e | ||
|
|
a749d310dd | ||
|
|
a473110078 | ||
|
|
2f1000d815 | ||
|
|
dbc6308c20 | ||
|
|
9e8c20df3d | ||
|
|
4d31b1ecee | ||
|
|
17ea7797df | ||
|
|
418fe4b18e | ||
|
|
e5881601ad | ||
|
|
e044c7e84b | ||
|
|
afab4dfdef | ||
|
|
99e973ba3e | ||
|
|
6ce0ba46a9 | ||
|
|
547da4c3e5 | ||
|
|
14beaa6ce2 | ||
|
|
a3eefcf2c4 | ||
|
|
20809052f5 | ||
|
|
265d1854c9 | ||
|
|
ff206baa6f | ||
|
|
1d64ea4ba6 | ||
|
|
c8275605c9 | ||
|
|
c54f9a0bc4 | ||
|
|
30725392ac | ||
|
|
3f13605b4c | ||
|
|
93fffad82a | ||
|
|
2fd344511e | ||
|
|
9581e4d870 | ||
|
|
cb4f5071ab | ||
|
|
c76ba2f58e | ||
|
|
bec84e2013 | ||
|
|
2ea778796a | ||
|
|
43466a6402 | ||
|
|
68b101fe01 | ||
|
|
e20c507dcc | ||
|
|
77dbcaefad | ||
|
|
95bfd7dd96 | ||
|
|
3bf7f467a2 | ||
|
|
04238bea22 | ||
|
|
c13d365015 | ||
|
|
b271e8915e | ||
|
|
47eb6cb612 | ||
|
|
1ee4e0501a | ||
|
|
544b9bc971 | ||
|
|
0c19f0d16f | ||
|
|
d74d7f2b7b | ||
|
|
0c2102b951 | ||
|
|
0c28d3cd08 | ||
|
|
7312b5650c | ||
|
|
c7e0863419 | ||
|
|
d7c83bc285 | ||
|
|
4285549381 | ||
|
|
9ed80120e0 | ||
|
|
ec586ebc25 | ||
|
|
ea8cb18f9e | ||
|
|
d011039c58 | ||
|
|
471d4a6838 | ||
|
|
bd42552854 | ||
|
|
31eeb00b59 | ||
|
|
d32c419b6d | ||
|
|
f31a322978 | ||
|
|
f99f50eb0c | ||
|
|
5bae3368d7 | ||
|
|
f100b5b707 | ||
|
|
701399536f | ||
|
|
4ca607f888 | ||
|
|
29f7959db7 | ||
|
|
bd1a7eb680 | ||
|
|
3198972d15 | ||
|
|
d78be3b621 | ||
|
|
b0ee214154 | ||
|
|
02c9480f44 | ||
|
|
3e4ae17596 | ||
|
|
c95ee27991 | ||
|
|
f9f061de4c | ||
|
|
d11824807a | ||
|
|
7c063a0e6f | ||
|
|
e477d64548 | ||
|
|
2e33084097 | ||
|
|
b3f98ef95d | ||
|
|
ff241af8d7 | ||
|
|
d9be9465c3 | ||
|
|
5def4b62e0 | ||
|
|
c72df9b127 | ||
|
|
1de88a9412 | ||
|
|
3cd26c1d82 | ||
|
|
cc9a8ad6ec | ||
|
|
41d4ac3877 | ||
|
|
a76194744a | ||
|
|
34695ad78b | ||
|
|
7008d03b02 | ||
|
|
5956280d56 | ||
|
|
21fea91d23 | ||
|
|
82bbce98fd | ||
|
|
f4016fc721 | ||
|
|
6c5879215d | ||
|
|
2610d2dc3f | ||
|
|
faee939312 | ||
|
|
ea15f94341 | ||
|
|
762bc92b2d | ||
|
|
8db9099207 | ||
|
|
904192b45c | ||
|
|
0cceeee690 | ||
|
|
f1d81cdfaa | ||
|
|
2d4b959407 | ||
|
|
54d452e20d | ||
|
|
9b62485a86 | ||
|
|
cce210ed3a | ||
|
|
356ff002dd | ||
|
|
c234359857 | ||
|
|
8bcb773304 | ||
|
|
b52c048c8e | ||
|
|
f53cdf3157 | ||
|
|
8056c49909 | ||
|
|
d0edf2e4d5 | ||
|
|
6793f041ce | ||
|
|
b743db35af | ||
|
|
a3149858f5 | ||
|
|
0f86611c41 | ||
|
|
17ae320dd2 | ||
|
|
6b8afb1d3d | ||
|
|
bf8abba24d | ||
|
|
63ca8d7d89 | ||
|
|
28b9bf85ee | ||
|
|
de88219edc | ||
|
|
1e0d2b8606 | ||
|
|
85cff15427 | ||
|
|
a35f71f65d | ||
|
|
ee46fd6064 | ||
|
|
b439cfe9ea | ||
|
|
17ad3b2f3b | ||
|
|
ee3c849c52 | ||
|
|
5f888c75c4 | ||
|
|
a25886102a | ||
|
|
2c1d1d989c | ||
|
|
4268b7891a | ||
|
|
cc672b8009 | ||
|
|
66cb5d924a | ||
|
|
c7e5aedb14 | ||
|
|
66dec60f71 | ||
|
|
ec71a41d8f | ||
|
|
ca7ba48934 | ||
|
|
63895343e3 | ||
|
|
88982ad23f | ||
|
|
7620a5a7e9 | ||
|
|
289e3c3ad0 | ||
|
|
abe005b403 | ||
|
|
e867076bde | ||
|
|
303a4b3144 | ||
|
|
0998a3a87d | ||
|
|
5878bddd6b | ||
|
|
102831919c | ||
|
|
1dd8ca86c3 | ||
|
|
aa6577c5b7 | ||
|
|
ef1db9e754 | ||
|
|
2d8c0a2d60 | ||
|
|
5647c129da | ||
|
|
254871635e | ||
|
|
cb81aa48d3 | ||
|
|
6340b560c7 | ||
|
|
cc5e2e1712 | ||
|
|
b067eee487 | ||
|
|
1f9ce6582c | ||
|
|
a4383e051f | ||
|
|
c1b1a55808 | ||
|
|
547b8839b2 | ||
|
|
4c88a1318d | ||
|
|
fb1554c0bf | ||
|
|
33768a2d3a | ||
|
|
05067f4960 | ||
|
|
715f196434 | ||
|
|
add8bf9f4f | ||
|
|
ba32f3a187 | ||
|
|
a8c3137f3b | ||
|
|
79b4c75303 | ||
|
|
18b16f2936 | ||
|
|
8567dacd55 | ||
|
|
a012d912fe | ||
|
|
042985d961 | ||
|
|
02cdfcb93f | ||
|
|
25080c6719 | ||
|
|
89fd2ce96e | ||
|
|
7d5db1ce8b | ||
|
|
825e40358b | ||
|
|
b5cccc8ac6 | ||
|
|
aec07456fc | ||
|
|
6209e2f3ae | ||
|
|
0a5a3b2450 | ||
|
|
90b2cb7848 | ||
|
|
bb34bd3db9 | ||
|
|
7950ac72af | ||
|
|
db55b79aa1 | ||
|
|
21484e506a | ||
|
|
63d01f5d6c | ||
|
|
6fa68fe20e | ||
|
|
141d7fd0aa | ||
|
|
c057741e22 | ||
|
|
5ebadefcd7 | ||
|
|
70aea76bf6 | ||
|
|
fb475915c1 | ||
|
|
1f717c9059 | ||
|
|
8a73251b15 | ||
|
|
c283288133 | ||
|
|
c8f0f3dc9d | ||
|
|
821b6ece57 | ||
|
|
3ffebd097c | ||
|
|
d911cdf5ac | ||
|
|
245beed829 | ||
|
|
741247c5cc | ||
|
|
ef11bcd2d1 | ||
|
|
9da6a911cd | ||
|
|
b669b1c3a6 | ||
|
|
8c51614cfa | ||
|
|
1b7c3d7d94 | ||
|
|
b7ffba4d2f | ||
|
|
072ccc90aa | ||
|
|
8cf27af3b2 | ||
|
|
0696532a99 | ||
|
|
0ff9e2ba39 | ||
|
|
3916a0ed1d | ||
|
|
b6c369ef17 | ||
|
|
870d9d9465 | ||
|
|
fee8f41ea5 | ||
|
|
80afd1cc00 | ||
|
|
8526f013da | ||
|
|
3046f51300 | ||
|
|
d5f18c23cb | ||
|
|
dab9c7cf9b | ||
|
|
83769c4780 | ||
|
|
68e2a14ba2 | ||
|
|
848d79df11 | ||
|
|
1caa7f6324 | ||
|
|
f9a430e100 | ||
|
|
d7a37f60b5 | ||
|
|
0e0c5f4cdb | ||
|
|
bea274492c | ||
|
|
f7c1ae4d77 | ||
|
|
784111a498 | ||
|
|
77f48d9f26 | ||
|
|
6e475b9521 | ||
|
|
dafd51e327 | ||
|
|
f9eeafb568 | ||
|
|
4585306bfc | ||
|
|
0c4f1027e8 | ||
|
|
74cc1d488e | ||
|
|
a135c44838 | ||
|
|
ec2b48a616 | ||
|
|
50f9e673e8 | ||
|
|
e2d98181c7 | ||
|
|
a9e68abb9d | ||
|
|
7ca5a97ec8 | ||
|
|
e3f34ace8e | ||
|
|
a9b3d4e6f4 | ||
|
|
a2a021a0dd | ||
|
|
711ab886e2 | ||
|
|
a092443a09 | ||
|
|
de73d39310 | ||
|
|
ff27a249cc | ||
|
|
4668aad039 | ||
|
|
b484b78cbd | ||
|
|
23136da34f | ||
|
|
5d1cc2a9bb | ||
|
|
f41a0cf423 | ||
|
|
35828492d5 | ||
|
|
e1e7f68330 | ||
|
|
e2da970344 | ||
|
|
b3fa5557ca | ||
|
|
19a1bbba4a | ||
|
|
f57cf44eba | ||
|
|
ae797811d2 | ||
|
|
7d01cf8c68 | ||
|
|
e79eabcc18 | ||
|
|
d2e4b9753d | ||
|
|
fab17b48b3 | ||
|
|
4f8969ef52 | ||
|
|
2e5b8b9a87 | ||
|
|
f4ba27f2f5 | ||
|
|
e6f840ca11 | ||
|
|
aa770f2333 | ||
|
|
bd6731525e | ||
|
|
68d052625c | ||
|
|
3d053345fd | ||
|
|
180c6966db | ||
|
|
0c45864ef0 | ||
|
|
c6ba954eb8 | ||
|
|
76354cd968 | ||
|
|
4bdb86057e | ||
|
|
a8a8ff6eca | ||
|
|
0dcaa60919 | ||
|
|
17e37ec4db | ||
|
|
060afc848c | ||
|
|
1903b886f6 | ||
|
|
240813c605 | ||
|
|
7d74b1f0b9 | ||
|
|
39ca8ed9e8 | ||
|
|
3c08395741 | ||
|
|
ec934f3a8b | ||
|
|
25cf64588d | ||
|
|
301a4a3882 | ||
|
|
102b19d948 | ||
|
|
a7afd4b959 | ||
|
|
8403c97688 | ||
|
|
7df5750979 | ||
|
|
990cc8b3ae | ||
|
|
7ee2450297 | ||
|
|
d58f6cdb33 | ||
|
|
af156040cb | ||
|
|
5e770b2e2f | ||
|
|
92e76dea81 | ||
|
|
4df32a853b | ||
|
|
fa0c0fe747 | ||
|
|
8a8d3ea20e | ||
|
|
88c2f4ddc4 | ||
|
|
98af9f442c | ||
|
|
34c39b765e | ||
|
|
efe131591f | ||
|
|
104bbbef41 | ||
|
|
eed8e36a69 | ||
|
|
8cf78b7a47 | ||
|
|
862b85e064 | ||
|
|
857ec7d4d4 | ||
|
|
7c79611309 | ||
|
|
99dad49052 | ||
|
|
6296629831 | ||
|
|
7ed565da6b | ||
|
|
030627c8c5 | ||
|
|
fe9479d6fc | ||
|
|
b94108768e | ||
|
|
348133b63d | ||
|
|
6032b5dfcb | ||
|
|
23198f3c26 | ||
|
|
e40341ab73 | ||
|
|
c695de5314 | ||
|
|
d6b59aade6 | ||
|
|
1d812bd446 | ||
|
|
abcc7bf3cd | ||
|
|
06fa65d4b5 | ||
|
|
9d1570b301 | ||
|
|
7f2ea9857d | ||
|
|
1ad057fb0f | ||
|
|
b85c068e83 | ||
|
|
30cda933bc | ||
|
|
b5537077bc | ||
|
|
638033c9ff | ||
|
|
7560f7be85 | ||
|
|
b84104b421 | ||
|
|
0c92fb2674 | ||
|
|
14fe8e9df9 | ||
|
|
f9c0fcba24 | ||
|
|
47917825d1 | ||
|
|
eab5f8e7e8 | ||
|
|
9495179923 | ||
|
|
f16b36fbc8 | ||
|
|
dd2ce90b1d | ||
|
|
88b87e2fa6 | ||
|
|
2be9f6cd2f | ||
|
|
5cf4ba803d | ||
|
|
cfb0365cb3 | ||
|
|
81d430d870 | ||
|
|
96d81f9836 | ||
|
|
5fe1ec806d | ||
|
|
2f63714dba | ||
|
|
4cf18e122d | ||
|
|
02a7598906 | ||
|
|
0263ecce9e | ||
|
|
d450b3d454 | ||
|
|
f1140222a1 | ||
|
|
66067a267a | ||
|
|
76c6b41033 | ||
|
|
29507a2e3a | ||
|
|
ceec6d3795 | ||
|
|
08ba74b399 | ||
|
|
ed7a288946 | ||
|
|
a26f9e965b | ||
|
|
6574d68d2b | ||
|
|
3bf094ebf7 | ||
|
|
72da372eba | ||
|
|
5fba76f010 | ||
|
|
09565bc40f | ||
|
|
4036d64996 | ||
|
|
5b0a537302 | ||
|
|
0d9d4e6b69 | ||
|
|
4c0dbbf1c8 | ||
|
|
52a9a6ae5f | ||
|
|
d6a5ba4d5e | ||
|
|
4afef09a03 | ||
|
|
0771c15a59 | ||
|
|
3a96567fc1 | ||
|
|
9d9e0317c0 | ||
|
|
5f2ac17129 | ||
|
|
4df3a52c4e | ||
|
|
9aee403ff9 | ||
|
|
7883fe7bd7 | ||
|
|
cbfb7d58b6 | ||
|
|
2832a06fe3 | ||
|
|
451715f5a1 | ||
|
|
fdf594155c | ||
|
|
c39470a53f | ||
|
|
e5dfb34a2a | ||
|
|
58549975e0 | ||
|
|
0bbc6bc1c5 | ||
|
|
beeb8bc107 | ||
|
|
5548d60dbb | ||
|
|
9fb25f4543 | ||
|
|
33140d4c5a | ||
|
|
2787bd60be | ||
|
|
e879d82e7d | ||
|
|
9b8cc0870b | ||
|
|
ce40b66c60 | ||
|
|
56b49cb2a6 | ||
|
|
4353340ea6 | ||
|
|
91cbf32fd1 | ||
|
|
10b482fac2 | ||
|
|
0024208354 | ||
|
|
32a3a3543d | ||
|
|
e314badf18 | ||
|
|
ad0615a08f | ||
|
|
6799458807 | ||
|
|
b1f7364097 |
38
.dockerignore
Normal file
38
.dockerignore
Normal file
@@ -0,0 +1,38 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
dist
|
||||
server/bin
|
||||
server/tmp
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test
|
||||
e2e/test-results
|
||||
coverage
|
||||
|
||||
# Docs
|
||||
docs/
|
||||
|
||||
# Desktop app (not needed for web self-hosting)
|
||||
apps/desktop
|
||||
19
.env.example
19
.env.example
@@ -22,6 +22,8 @@ MULTICA_CODEX_WORKDIR=
|
||||
MULTICA_CODEX_TIMEOUT=20m
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
|
||||
@@ -29,6 +31,7 @@ RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||
|
||||
# S3 / CloudFront
|
||||
S3_BUCKET=
|
||||
@@ -39,11 +42,23 @@ CLOUDFRONT_PRIVATE_KEY=
|
||||
CLOUDFRONT_DOMAIN=
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
LOCAL_UPLOAD_DIR=./data/uploads
|
||||
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
|
||||
# Security
|
||||
# Comma-separated list of allowed origins for CORS and WebSocket connections.
|
||||
# Defaults to localhost dev origins when unset.
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
||||
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
|
||||
# Only set explicitly if frontend and backend are on different domains.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_WS_URL=
|
||||
|
||||
# Remote API (optional) — set to proxy local frontend to a remote backend
|
||||
# Leave empty to use local backend (localhost:8080)
|
||||
|
||||
File diff suppressed because one or more lines are too long
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Ensure shell scripts always use LF line endings (needed for Docker on Windows)
|
||||
*.sh text eol=lf
|
||||
docker/entrypoint.sh text eol=lf
|
||||
|
||||
# Default behavior
|
||||
* text=auto
|
||||
50
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
50
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: "Bug Report"
|
||||
description: Report a bug — something that's broken, crashes, or behaves incorrectly.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the bug and what you expected instead. Screenshots, error messages, or screen recordings are welcome.
|
||||
placeholder: |
|
||||
When I do X, Y happens. I expected Z instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: How can we trigger this bug?
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots (optional)
|
||||
description: If applicable, add screenshots or screen recordings to help explain the problem.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context (optional)
|
||||
description: Environment info, logs, or anything else that might help.
|
||||
render: shell
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: "Feature Request"
|
||||
description: Suggest a new feature or improvement.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What do you want and why?
|
||||
description: Describe the problem you're trying to solve or the improvement you'd like to see.
|
||||
placeholder: |
|
||||
I'm trying to do X but there's no way to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution (optional)
|
||||
description: If you have an idea for how this should work, describe it here.
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots / mockups (optional)
|
||||
description: If applicable, add screenshots, mockups, or sketches to illustrate your idea.
|
||||
58
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
58
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
|
||||
|
||||
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Refactor / code improvement (no behavior change)
|
||||
- [ ] Documentation update
|
||||
- [ ] Tests (adding or improving test coverage)
|
||||
- [ ] CI / infrastructure
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!-- List the specific changes. Include file paths for code changes. -->
|
||||
|
||||
-
|
||||
|
||||
## How to Test
|
||||
|
||||
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have included a thinking path that traces from project context to this change
|
||||
- [ ] I have run tests locally and they pass
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
|
||||
|
||||
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
|
||||
|
||||
**Prompt / approach:**
|
||||
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
|
||||
|
||||
|
||||
## Screenshots (optional)
|
||||
|
||||
<!-- If applicable, add screenshots showing the change in action. -->
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -12,10 +12,17 @@ build
|
||||
bin
|
||||
dist-electron
|
||||
*.tsbuildinfo
|
||||
# ...except electron-builder's source resources dir, which holds tracked
|
||||
# config files (entitlements, icons) — not build output.
|
||||
!apps/desktop/build/
|
||||
!apps/desktop/build/**
|
||||
|
||||
# env
|
||||
.env*
|
||||
!.env.example
|
||||
# Desktop production config is public (backend URL, etc.) — track it so
|
||||
# `pnpm package` produces a release-ready build without extra setup.
|
||||
!apps/desktop/.env.production
|
||||
|
||||
# test coverage
|
||||
coverage
|
||||
@@ -41,7 +48,12 @@ apps/web/test-results/
|
||||
# feature tracking
|
||||
_features/
|
||||
|
||||
# runtime
|
||||
*.pid
|
||||
|
||||
# platform specific
|
||||
*.dmg
|
||||
*.app
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
|
||||
@@ -11,19 +11,28 @@ builds:
|
||||
- -s -w
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.commit={{.ShortCommit}}
|
||||
- -X main.date={{.Date}}
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
|
||||
283
AGENTS.md
283
AGENTS.md
@@ -2,273 +2,46 @@
|
||||
|
||||
This file provides guidance to AI agents when working with code in this repository.
|
||||
|
||||
## Project Context
|
||||
> **Single source of truth:** This file is a concise pointer document.
|
||||
> All authoritative architecture, coding rules, commands, and conventions
|
||||
> live in **CLAUDE.md** at the project root. Read that file first.
|
||||
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
## Quick Reference
|
||||
|
||||
- Agents can be assigned issues, create issues, comment, and change status
|
||||
- Supports local (daemon) and cloud agent runtimes
|
||||
- Built for 2-10 person AI-native teams
|
||||
### Architecture
|
||||
|
||||
## Architecture
|
||||
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
|
||||
|
||||
**Go backend + standalone Next.js frontend.**
|
||||
- `server/` — Go backend (Chi router, sqlc, gorilla/websocket)
|
||||
- `apps/web/` — Next.js frontend (App Router)
|
||||
- `apps/desktop/` — Electron desktop app
|
||||
- `packages/core/` — Headless business logic (Zustand stores, React Query hooks, API client)
|
||||
- `packages/ui/` — Atomic UI components (shadcn/Base UI, zero business logic)
|
||||
- `packages/views/` — Shared business pages/components
|
||||
- `packages/tsconfig/` — Shared TypeScript config
|
||||
|
||||
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
|
||||
- `e2e/` — Playwright end-to-end tests
|
||||
- `scripts/` and root `Makefile` — local setup and verification
|
||||
### State Management (critical)
|
||||
|
||||
### Web App Structure (`apps/web/`)
|
||||
- **React Query** owns all server state (issues, members, agents, inbox, workspace list)
|
||||
- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals)
|
||||
- All Zustand stores live in `packages/core/` — never in `packages/views/` or app directories
|
||||
- WS events invalidate React Query — never write directly to stores
|
||||
|
||||
The frontend uses a **feature-based architecture** with four layers:
|
||||
### Package Boundaries (hard rules)
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Routing layer (thin shells — import from features/)
|
||||
├── features/ # Business logic, organized by domain
|
||||
├── shared/ # Cross-feature utilities (api client, types, logger)
|
||||
├── test/ # Shared test utilities and setup
|
||||
├── public/ # Static assets
|
||||
```
|
||||
- `packages/core/` — zero react-dom, zero localStorage, zero process.env
|
||||
- `packages/ui/` — zero `@multica/core` imports
|
||||
- `packages/views/` — zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
|
||||
- `apps/web/platform/` — only place for Next.js APIs
|
||||
|
||||
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
|
||||
|
||||
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
|
||||
|
||||
| Feature | Purpose | Exports |
|
||||
|---|---|---|
|
||||
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
|
||||
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
|
||||
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
||||
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
||||
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||
| `features/modals/` | Modal registry and state | Modal store and components |
|
||||
| `features/skills/` | Skill management | Skill components |
|
||||
|
||||
**`shared/`** — Code used across multiple features:
|
||||
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
||||
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
|
||||
- `shared/logger.ts` — Logger utility.
|
||||
|
||||
### State Management
|
||||
|
||||
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
|
||||
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
|
||||
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||
- Do not use React Context for data that can be a zustand store.
|
||||
|
||||
**Store conventions:**
|
||||
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
|
||||
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
|
||||
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
|
||||
- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse.
|
||||
|
||||
### Import Aliases
|
||||
|
||||
Use `@/` alias (maps to `apps/web/`):
|
||||
```typescript
|
||||
import { api } from "@/shared/api";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { StatusIcon } from "@/features/issues/components";
|
||||
```
|
||||
|
||||
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
```
|
||||
|
||||
### Backend Structure (`server/`)
|
||||
|
||||
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
||||
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
|
||||
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
|
||||
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
|
||||
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
|
||||
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
|
||||
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
|
||||
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
|
||||
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
|
||||
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
|
||||
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
|
||||
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
### Agent Assignees
|
||||
|
||||
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||
|
||||
## Commands
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# One-click setup & run
|
||||
make setup # First-time: ensure shared DB, create app DB, migrate
|
||||
make start # Start backend + frontend together
|
||||
make stop # Stop app processes for the current checkout
|
||||
make db-down # Stop the shared PostgreSQL container
|
||||
|
||||
# Frontend
|
||||
pnpm install
|
||||
pnpm dev:web # Next.js dev server (port 3000)
|
||||
pnpm build # Build frontend
|
||||
make dev # Auto-setup + start everything
|
||||
pnpm typecheck # TypeScript check
|
||||
pnpm lint # ESLint via Next.js
|
||||
pnpm test # TS tests (Vitest)
|
||||
|
||||
# Backend (Go)
|
||||
make dev # Run Go server (port 8080)
|
||||
make daemon # Run local daemon
|
||||
make build # Build server + CLI binaries to server/bin/
|
||||
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
|
||||
pnpm test # TS unit tests (Vitest)
|
||||
make test # Go tests
|
||||
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
|
||||
make migrate-up # Run database migrations
|
||||
make migrate-down # Rollback migrations
|
||||
|
||||
# Run a single Go test
|
||||
cd server && go test ./internal/handler/ -run TestName
|
||||
|
||||
# Run a single TS test
|
||||
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
|
||||
|
||||
# Run a single E2E test (requires backend + frontend running)
|
||||
pnpm exec playwright test e2e/tests/specific-test.spec.ts
|
||||
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
make db-down # Stop shared PostgreSQL
|
||||
make check # Full verification pipeline
|
||||
```
|
||||
|
||||
### CI Requirements
|
||||
|
||||
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||
|
||||
### Worktree Support
|
||||
|
||||
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
|
||||
|
||||
```bash
|
||||
make worktree-env # Generate .env.worktree with unique DB/ports
|
||||
make setup-worktree # Setup using .env.worktree
|
||||
make start-worktree # Start using .env.worktree
|
||||
```
|
||||
|
||||
## Coding Rules
|
||||
|
||||
- TypeScript strict mode is enabled; keep types explicit.
|
||||
- TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons.
|
||||
- Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`.
|
||||
- Go code follows standard Go conventions (gofmt, go vet). Use domain-oriented filenames like `issue.go` or `cmd_issue.go`.
|
||||
- Do not hand-edit generated code in `server/pkg/db/generated/`.
|
||||
- Keep comments in code **English only**.
|
||||
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
|
||||
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
|
||||
- **Feature-specific components** → `features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module.
|
||||
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
|
||||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||
- When unsure about interaction or state design, ask — the user will provide direction.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
- **TypeScript**: Vitest with Testing Library. Shared test setup lives in `apps/web/test/`. Mock external/third-party dependencies only.
|
||||
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
- End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running.
|
||||
- Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
|
||||
|
||||
## Commit & Pull Request Rules
|
||||
|
||||
- Use atomic commits grouped by logical intent.
|
||||
- Conventional format with scopes:
|
||||
- `feat(web): ...`, `feat(cli): ...`
|
||||
- `fix(web): ...`, `fix(cli): ...`
|
||||
- `refactor(daemon): ...`
|
||||
- `test(cli): ...`
|
||||
- `docs: ...`
|
||||
- `chore(scope): ...`
|
||||
- Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes.
|
||||
- Before opening a PR, run `make check` or the relevant frontend/backend subset.
|
||||
|
||||
## Minimum Pre-Push Checks
|
||||
|
||||
```bash
|
||||
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
|
||||
```
|
||||
|
||||
Run verification only when the user explicitly asks for it.
|
||||
|
||||
For targeted checks when requested:
|
||||
```bash
|
||||
pnpm typecheck # TypeScript type errors only
|
||||
pnpm test # TS unit tests only (Vitest)
|
||||
make test # Go tests only
|
||||
pnpm exec playwright test # E2E only (requires backend + frontend running)
|
||||
```
|
||||
|
||||
## AI Agent Verification Loop
|
||||
|
||||
After writing or modifying code, always run the full verification pipeline:
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
This runs all checks in sequence:
|
||||
1. TypeScript typecheck (`pnpm typecheck`)
|
||||
2. TypeScript unit tests (`pnpm test`)
|
||||
3. Go tests (`go test ./...`)
|
||||
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
|
||||
|
||||
**Workflow:**
|
||||
- Write code to satisfy the requirement
|
||||
- Run `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run `make check`
|
||||
- Repeat until all checks pass
|
||||
- Only then consider the task complete
|
||||
|
||||
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
|
||||
|
||||
## E2E Test Patterns
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
|
||||
```typescript
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
let api: TestApiClient;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi(); // logged-in API client
|
||||
await loginAsDefault(page); // browser session
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup(); // delete any data created during the test
|
||||
});
|
||||
|
||||
test("example", async ({ page }) => {
|
||||
const issue = await api.createIssue("Test Issue"); // create via API
|
||||
await page.goto(`/issues/${issue.id}`); // test via UI
|
||||
// api.cleanup() in afterEach removes the issue
|
||||
});
|
||||
```
|
||||
See CLAUDE.md for the complete command reference.
|
||||
|
||||
316
CLAUDE.md
316
CLAUDE.md
@@ -12,119 +12,71 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
|
||||
|
||||
## Architecture
|
||||
|
||||
**Go backend + standalone Next.js frontend.**
|
||||
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
|
||||
|
||||
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
|
||||
- `apps/web/` — Next.js frontend (App Router)
|
||||
- `apps/desktop/` — Electron desktop app (electron-vite)
|
||||
- `packages/core/` — Headless business logic (zero react-dom, all-platform reuse)
|
||||
- `packages/ui/` — Atomic UI components (zero business logic)
|
||||
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
|
||||
- `packages/tsconfig/` — Shared TypeScript configuration
|
||||
|
||||
### Web App Structure (`apps/web/`)
|
||||
### Key Architectural Decisions
|
||||
|
||||
The frontend uses a **feature-based architecture** with four layers:
|
||||
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Routing layer (thin shells — import from features/)
|
||||
├── features/ # Business logic, organized by domain
|
||||
├── shared/ # Cross-feature utilities (api client, types, logger)
|
||||
```
|
||||
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*`, `react-router-dom`, or app-specific code.
|
||||
|
||||
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
|
||||
**Platform bridge:** `packages/core/platform/` provides `CoreProvider` — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider>` and provides its own `NavigationAdapter` for routing.
|
||||
|
||||
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
|
||||
|
||||
| Feature | Purpose | Exports |
|
||||
|---|---|---|
|
||||
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
|
||||
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
|
||||
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
||||
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
||||
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||
| `features/modals/` | Modal registry and state | Modal store and components |
|
||||
| `features/skills/` | Skill management | Skill components |
|
||||
|
||||
**`shared/`** — Code used across multiple features:
|
||||
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
||||
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
|
||||
- `shared/logger.ts` — Logger utility.
|
||||
**pnpm catalog** — `pnpm-workspace.yaml` defines `catalog:` for version pinning. All shared deps use `catalog:` references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
|
||||
|
||||
### State Management
|
||||
|
||||
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
|
||||
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
|
||||
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||
- Do not use React Context for data that can be a zustand store.
|
||||
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
|
||||
|
||||
**Store conventions:**
|
||||
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
|
||||
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
|
||||
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
|
||||
- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse.
|
||||
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
|
||||
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so both apps share them.
|
||||
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
|
||||
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
|
||||
|
||||
### Import Aliases
|
||||
**Hard rules — these are how the architecture stays coherent:**
|
||||
|
||||
Use `@/` alias (maps to `apps/web/`):
|
||||
```typescript
|
||||
import { api } from "@/shared/api";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { StatusIcon } from "@/features/issues/components";
|
||||
```
|
||||
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
|
||||
- **Workspace-scoped queries must key on `wsId`.** This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed.
|
||||
- **Mutations are optimistic by default.** Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
|
||||
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
|
||||
- **Persist what's worth preserving across restarts** (user preferences, drafts, tab layout). **Don't persist ephemeral UI state** (modal open/close, transient selections) or server data.
|
||||
|
||||
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
||||
**Common Zustand footguns to avoid:**
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
```
|
||||
|
||||
### Backend Structure (`server/`)
|
||||
|
||||
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
||||
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
|
||||
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
|
||||
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
|
||||
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
|
||||
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
|
||||
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
|
||||
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
|
||||
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
|
||||
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
|
||||
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
|
||||
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
### Agent Assignees
|
||||
|
||||
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
|
||||
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# One-click setup & run
|
||||
# One-command dev (auto-setup + start everything)
|
||||
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
|
||||
|
||||
# Explicit setup & run (if you prefer separate steps)
|
||||
make setup # First-time: ensure shared DB, create app DB, migrate
|
||||
make start # Start backend + frontend together
|
||||
make stop # Stop app processes for the current checkout
|
||||
make db-down # Stop the shared PostgreSQL container
|
||||
|
||||
# Frontend
|
||||
# Frontend (all commands go through Turborepo)
|
||||
pnpm install
|
||||
pnpm dev:web # Next.js dev server (port 3000)
|
||||
pnpm build # Build frontend
|
||||
pnpm typecheck # TypeScript check
|
||||
pnpm lint # ESLint via Next.js
|
||||
pnpm test # TS tests (Vitest)
|
||||
pnpm dev:desktop # Electron dev (electron-vite, HMR)
|
||||
pnpm build # Build all frontend apps
|
||||
pnpm typecheck # TypeScript check (all packages + apps via turbo)
|
||||
pnpm lint # ESLint
|
||||
pnpm test # TS tests (Vitest, all packages + apps via turbo)
|
||||
|
||||
# Backend (Go)
|
||||
make dev # Run Go server (port 8080)
|
||||
make server # Run Go server only (port 8080)
|
||||
make daemon # Run local daemon
|
||||
make build # Build server + CLI binaries to server/bin/
|
||||
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
|
||||
@@ -133,15 +85,24 @@ make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/
|
||||
make migrate-up # Run database migrations
|
||||
make migrate-down # Rollback migrations
|
||||
|
||||
# Run a single TS test (works for any package with a test script)
|
||||
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
|
||||
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
|
||||
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
|
||||
|
||||
# Run a single Go test
|
||||
cd server && go test ./internal/handler/ -run TestName
|
||||
|
||||
# Run a single TS test
|
||||
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
|
||||
|
||||
# Run a single E2E test (requires backend + frontend running)
|
||||
pnpm exec playwright test e2e/tests/specific-test.spec.ts
|
||||
|
||||
# Desktop build & package
|
||||
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
|
||||
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
|
||||
|
||||
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
|
||||
pnpm ui:add badge # Adds component to packages/ui/components/ui/
|
||||
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
make db-down # Stop shared PostgreSQL
|
||||
@@ -155,6 +116,8 @@ CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL serv
|
||||
|
||||
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
|
||||
|
||||
`make dev` auto-detects worktrees and handles everything. For explicit control:
|
||||
|
||||
```bash
|
||||
make worktree-env # Generate .env.worktree with unique DB/ports
|
||||
make setup-worktree # Setup using .env.worktree
|
||||
@@ -169,43 +132,130 @@ make start-worktree # Start using .env.worktree
|
||||
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
|
||||
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
These are hard constraints. Violating them breaks the cross-platform architecture:
|
||||
|
||||
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **All shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
|
||||
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
|
||||
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
|
||||
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
|
||||
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
|
||||
|
||||
### The No-Duplication Rule
|
||||
|
||||
**If the same logic exists in both apps, it must be extracted to a shared package.**
|
||||
|
||||
This applies to everything: components, hooks, guards, providers, utility functions. The decision process:
|
||||
|
||||
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
|
||||
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
|
||||
3. Everything else → belongs in `packages/core/` (headless logic) or `packages/views/` (UI components).
|
||||
|
||||
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
|
||||
|
||||
### Cross-Platform Development Rules
|
||||
|
||||
When adding a new page or feature:
|
||||
|
||||
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
|
||||
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
|
||||
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
|
||||
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
|
||||
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
|
||||
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
|
||||
|
||||
### CSS Architecture
|
||||
|
||||
Both apps share the same CSS foundation from `packages/ui/styles/`.
|
||||
|
||||
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
|
||||
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
|
||||
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
|
||||
- **Feature-specific components** → `features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module.
|
||||
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
|
||||
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
|
||||
- Use shadcn design tokens for styling. Avoid hardcoded color values.
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
|
||||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||
- When unsure about interaction or state design, ask — the user will provide direction.
|
||||
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
|
||||
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
### Where to write tests
|
||||
|
||||
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
|
||||
|
||||
| What you're testing | Where the test lives | Why |
|
||||
|---|---|---|
|
||||
| Shared business logic (stores, queries, hooks) | `packages/core/*.test.ts` | No DOM needed, pure logic |
|
||||
| Shared UI components (pages, forms, modals) | `packages/views/*.test.tsx` | jsdom, no framework mocks |
|
||||
| Platform-specific wiring (cookies, redirects, searchParams) | `apps/web/*.test.tsx` or `apps/desktop/` | Needs framework-specific mocks |
|
||||
| End-to-end user flows | `e2e/*.spec.ts` | Real browser, real backend |
|
||||
|
||||
**Never test shared component behavior in an app's test file.** If a test requires mocking `next/navigation` or `react-router-dom` to test a component from `@multica/views`, the test is in the wrong place — move it to `packages/views/` and mock `@multica/core` instead.
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- `packages/core/` — Vitest, Node environment (no DOM)
|
||||
- `packages/views/` — Vitest, jsdom environment, `@testing-library/react`
|
||||
- `apps/web/` — Vitest, jsdom environment, framework-specific mocks
|
||||
- `e2e/` — Playwright
|
||||
- `server/` — Go standard `go test`
|
||||
|
||||
All test deps are in the pnpm catalog for unified versioning.
|
||||
|
||||
### Mocking conventions
|
||||
|
||||
- Mock `@multica/core` stores with `vi.hoisted()` + `Object.assign(selectorFn, { getState })` pattern (Zustand stores are both callable and have `.getState()`).
|
||||
- Mock `@multica/core/api` for API calls.
|
||||
- In `packages/views/` tests: never mock `next/*` or `react-router-dom` — those don't exist here.
|
||||
- In `apps/web/` tests: mock framework-specific APIs only for platform-specific behavior.
|
||||
|
||||
### TDD workflow
|
||||
|
||||
1. Write failing test in the **correct package** first.
|
||||
2. Write implementation.
|
||||
3. Run `pnpm test` (Turborepo discovers all packages).
|
||||
4. Green → done.
|
||||
|
||||
### Go tests
|
||||
|
||||
Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
|
||||
### E2E tests
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
|
||||
```typescript
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
let api: TestApiClient;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi();
|
||||
await loginAsDefault(page);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
});
|
||||
|
||||
test("example", async ({ page }) => {
|
||||
const issue = await api.createIssue("Test Issue");
|
||||
await page.goto(`/issues/${issue.id}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Commit Rules
|
||||
|
||||
- Use atomic commits grouped by logical intent.
|
||||
- Conventional format:
|
||||
- `feat(scope): ...`
|
||||
- `fix(scope): ...`
|
||||
- `refactor(scope): ...`
|
||||
- `docs: ...`
|
||||
- `test(scope): ...`
|
||||
- `chore(scope): ...`
|
||||
|
||||
## CLI Release
|
||||
|
||||
**Prerequisite:** A CLI release must accompany every Production deployment. When deploying to Production, always release a new CLI version as part of the process.
|
||||
|
||||
1. Create a tag on the `main` branch: `git tag v0.x.x`
|
||||
2. Push the tag: `git push origin v0.x.x`
|
||||
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
|
||||
|
||||
By default, bump the patch version each release (e.g. `v0.1.12` → `v0.1.13`), unless the user specifies a specific version.
|
||||
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
|
||||
|
||||
## Minimum Pre-Push Checks
|
||||
|
||||
@@ -218,7 +268,7 @@ Run verification only when the user explicitly asks for it.
|
||||
For targeted checks when requested:
|
||||
```bash
|
||||
pnpm typecheck # TypeScript type errors only
|
||||
pnpm test # TS unit tests only (Vitest)
|
||||
pnpm test # TS unit tests only (Vitest, all packages)
|
||||
make test # Go tests only
|
||||
pnpm exec playwright test # E2E only (requires backend + frontend running)
|
||||
```
|
||||
@@ -231,43 +281,29 @@ After writing or modifying code, always run the full verification pipeline:
|
||||
make check
|
||||
```
|
||||
|
||||
This runs all checks in sequence:
|
||||
1. TypeScript typecheck (`pnpm typecheck`)
|
||||
2. TypeScript unit tests (`pnpm test`)
|
||||
3. Go tests (`go test ./...`)
|
||||
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
|
||||
|
||||
**Workflow:**
|
||||
- Write code to satisfy the requirement
|
||||
- Run `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run
|
||||
- Repeat until all checks pass
|
||||
- Only then consider the task complete
|
||||
|
||||
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
|
||||
|
||||
## E2E Test Patterns
|
||||
## CLI Release
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
**Prerequisite:** A CLI release must accompany every Production deployment.
|
||||
|
||||
```typescript
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
1. Create a tag on the `main` branch: `git tag v0.x.x`
|
||||
2. Push the tag: `git push origin v0.x.x`
|
||||
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
|
||||
|
||||
let api: TestApiClient;
|
||||
By default, bump the patch version each release (e.g. `v0.1.12` → `v0.1.13`), unless the user specifies a specific version.
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi(); // logged-in API client
|
||||
await loginAsDefault(page); // browser session
|
||||
});
|
||||
## Multi-tenancy
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup(); // delete any data created during the test
|
||||
});
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
test("example", async ({ page }) => {
|
||||
const issue = await api.createIssue("Test Issue"); // create via API
|
||||
await page.goto(`/issues/${issue.id}`); // test via UI
|
||||
// api.cleanup() in afterEach removes the issue
|
||||
});
|
||||
```
|
||||
## Agent Assignees
|
||||
|
||||
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||
|
||||
@@ -7,8 +7,7 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica-cli
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
@@ -22,14 +21,30 @@ cp server/bin/multica /usr/local/bin/multica
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
brew upgrade multica-ai/tap/multica
|
||||
```
|
||||
|
||||
For install script or manual installs, use:
|
||||
|
||||
```bash
|
||||
multica update
|
||||
```
|
||||
|
||||
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
|
||||
`multica update` auto-detects your installation method and upgrades accordingly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# One-command setup: configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
|
||||
# For self-hosted (local) deployments:
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
Or step by step:
|
||||
|
||||
```bash
|
||||
# 1. Authenticate (opens browser for login)
|
||||
multica login
|
||||
@@ -125,6 +140,12 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
|-----|---------|-------------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
|
||||
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
| Gemini | `gemini` | Google's coding agent |
|
||||
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
|
||||
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -159,34 +180,56 @@ Agent-specific overrides:
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
When connecting to a self-hosted Multica instance, point the CLI to your server before logging in:
|
||||
When connecting to a self-hosted Multica instance, the easiest approach is:
|
||||
|
||||
```bash
|
||||
export MULTICA_APP_URL=https://app.example.com
|
||||
export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
# One command — configures for localhost, authenticates, starts daemon
|
||||
multica setup self-host
|
||||
|
||||
# Or for on-premise with custom domains:
|
||||
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
|
||||
```
|
||||
|
||||
Or configure manually:
|
||||
|
||||
```bash
|
||||
# Set URLs individually
|
||||
multica config set server_url http://localhost:8080
|
||||
multica config set app_url http://localhost:3000
|
||||
|
||||
# For production with TLS:
|
||||
# multica config set server_url https://api.example.com
|
||||
# multica config set app_url https://app.example.com
|
||||
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
Or set them persistently:
|
||||
|
||||
```bash
|
||||
multica config set app_url https://app.example.com
|
||||
multica config set server_url wss://api.example.com/ws
|
||||
```
|
||||
|
||||
### Profiles
|
||||
|
||||
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
|
||||
|
||||
```bash
|
||||
# Start a daemon for the staging server
|
||||
multica --profile staging login
|
||||
multica --profile staging daemon start
|
||||
# Set up a staging profile
|
||||
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
|
||||
|
||||
# Start its daemon
|
||||
multica daemon start --profile staging
|
||||
|
||||
# Default profile runs separately
|
||||
multica daemon start
|
||||
@@ -306,6 +349,24 @@ multica issue run-messages <task-id> --since 42 --output json
|
||||
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# One-command setup for Multica Cloud: configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
|
||||
# For local self-hosted deployments
|
||||
multica setup self-host
|
||||
|
||||
# Custom ports
|
||||
multica setup self-host --port 9090 --frontend-port 4000
|
||||
|
||||
# On-premise with custom domains
|
||||
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
|
||||
```
|
||||
|
||||
`multica setup` configures the CLI, opens your browser for authentication, and starts the daemon — all in one step. Use `multica setup self-host` to connect to a self-hosted server instead of Multica Cloud.
|
||||
|
||||
## Configuration
|
||||
|
||||
### View Config
|
||||
@@ -319,7 +380,7 @@ Shows config file path, server URL, app URL, and default workspace.
|
||||
### Set Values
|
||||
|
||||
```bash
|
||||
multica config set server_url wss://api.example.com/ws
|
||||
multica config set server_url https://api.example.com
|
||||
multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
200
CLI_INSTALL.md
Normal file
200
CLI_INSTALL.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Multica CLI — Installation Guide for AI Agents
|
||||
|
||||
> **Audience:** This document is designed for AI agents (Claude Code, Codex, etc.) to read and execute step-by-step. Each step includes the exact shell commands to run, expected outcomes, and error handling.
|
||||
|
||||
## One-Line Instruction for Users
|
||||
|
||||
Give this instruction to your AI agent:
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Check if Multica CLI is already installed
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
- **If it prints a version string** (e.g. `multica v0.x.x`): skip to **Step 3**.
|
||||
- **If command not found**: continue to **Step 2**.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Install the Multica CLI
|
||||
|
||||
> **Windows users:** Skip to [Option C: Windows (PowerShell)](#option-c-windows-powershell) below.
|
||||
|
||||
### Option A: Homebrew (preferred — macOS/Linux)
|
||||
|
||||
Check if Homebrew is available:
|
||||
|
||||
```bash
|
||||
which brew
|
||||
```
|
||||
|
||||
If `brew` is found, install via Homebrew:
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
Then verify:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
If the version prints successfully, skip to **Step 3**.
|
||||
|
||||
To upgrade later, run:
|
||||
|
||||
```bash
|
||||
brew upgrade multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### Option B: Download from GitHub Releases (macOS/Linux, no Homebrew)
|
||||
|
||||
If Homebrew is not available, download the binary directly.
|
||||
|
||||
Detect OS and architecture, then download the correct archive:
|
||||
|
||||
```bash
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # "darwin" or "linux"
|
||||
ARCH=$(uname -m) # "x86_64" or "arm64"
|
||||
|
||||
# Normalize architecture name
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
ARCH="amd64"
|
||||
fi
|
||||
|
||||
# Get the latest release tag from GitHub
|
||||
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
|
||||
|
||||
# Download and extract
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
tar -xzf /tmp/multica.tar.gz -C /tmp multica
|
||||
sudo mv /tmp/multica /usr/local/bin/multica
|
||||
rm /tmp/multica.tar.gz
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
**If this fails:**
|
||||
- Check that `/usr/local/bin` is in `$PATH`.
|
||||
- On Linux, you may need `chmod +x /usr/local/bin/multica`.
|
||||
- If `sudo` is not available, install to a user-writable directory: `mv /tmp/multica ~/.local/bin/multica` and ensure `~/.local/bin` is in `$PATH`.
|
||||
|
||||
### Option C: Windows (PowerShell)
|
||||
|
||||
Run in PowerShell (no admin required):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
This downloads the latest Windows binary from GitHub Releases, installs it to `%USERPROFILE%\.multica\bin\`, and adds it to your user PATH.
|
||||
|
||||
Verify:
|
||||
|
||||
```powershell
|
||||
multica version
|
||||
```
|
||||
|
||||
**If this fails:**
|
||||
- Restart your terminal so the updated PATH takes effect.
|
||||
- If you use Scoop, the installer will use it automatically: `scoop bucket add multica https://github.com/multica-ai/scoop-bucket.git && scoop install multica`
|
||||
- If your execution policy blocks the script: `Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned` then re-run.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Log in
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
multica login
|
||||
```
|
||||
|
||||
**Important:** This command opens a browser window for OAuth authentication. Tell the user:
|
||||
|
||||
> "A browser window will open for Multica login. Please complete the authentication in your browser, then come back here."
|
||||
|
||||
Wait for the command to complete. It will automatically discover and watch all workspaces the user belongs to.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
multica auth status
|
||||
```
|
||||
|
||||
Expected output should show the authenticated user and server URL.
|
||||
|
||||
**If login fails:**
|
||||
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
|
||||
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Start the daemon
|
||||
|
||||
First, check if the daemon is already running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
- **If status is "running"**: skip to **Step 5**.
|
||||
- **If status is "stopped"**: start it:
|
||||
|
||||
```bash
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
Wait 3 seconds, then verify:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
|
||||
|
||||
**If daemon fails to start:**
|
||||
- Check logs: `multica daemon logs`
|
||||
- If a port conflict occurs, the daemon may already be running under a different profile.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Verify everything is working
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, tell the user:
|
||||
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
When all steps are complete, inform the user:
|
||||
|
||||
> "Multica CLI is installed and the daemon is running. Agents in your workspaces can now execute tasks on this machine. You can manage workspaces with `multica workspace list` and view daemon logs with `multica daemon logs -f`."
|
||||
271
CONTRIBUTING.md
271
CONTRIBUTING.md
@@ -9,6 +9,7 @@ It covers:
|
||||
- isolated worktree development
|
||||
- the shared PostgreSQL model
|
||||
- testing and verification
|
||||
- full-stack isolated testing (backend + frontend + daemon from source)
|
||||
- troubleshooting and destructive reset options
|
||||
|
||||
## Development Model
|
||||
@@ -94,59 +95,52 @@ FORCE=1 make worktree-env
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
### Main Checkout
|
||||
### Quick Start (recommended)
|
||||
|
||||
From the main checkout:
|
||||
From any checkout (main or worktree):
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
This single command:
|
||||
|
||||
- auto-detects whether you're in a main checkout or a worktree
|
||||
- creates the appropriate env file (`.env` or `.env.worktree`) if it doesn't exist
|
||||
- checks that prerequisites (Node.js, pnpm, Go, Docker) are installed
|
||||
- installs JavaScript dependencies
|
||||
- ensures the shared PostgreSQL container is running
|
||||
- creates the application database if it does not exist
|
||||
- runs all migrations
|
||||
- starts both backend and frontend
|
||||
|
||||
### Explicit Setup (advanced)
|
||||
|
||||
If you prefer separate control over setup and startup:
|
||||
|
||||
#### Main Checkout
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make setup-main
|
||||
```
|
||||
|
||||
What `make setup-main` does:
|
||||
|
||||
- installs JavaScript dependencies with `pnpm install`
|
||||
- ensures the shared PostgreSQL container is running
|
||||
- creates the application database if it does not exist
|
||||
- runs all migrations against that database
|
||||
|
||||
Start the app:
|
||||
|
||||
```bash
|
||||
make start-main
|
||||
```
|
||||
|
||||
Stop the app processes:
|
||||
Stop:
|
||||
|
||||
```bash
|
||||
make stop-main
|
||||
```
|
||||
|
||||
This does not stop PostgreSQL.
|
||||
|
||||
### Worktree
|
||||
|
||||
From the worktree directory:
|
||||
#### Worktree
|
||||
|
||||
```bash
|
||||
make worktree-env
|
||||
make setup-worktree
|
||||
```
|
||||
|
||||
What `make setup-worktree` does:
|
||||
|
||||
- uses `.env.worktree`
|
||||
- ensures the shared PostgreSQL container is running
|
||||
- creates the worktree database if it does not exist
|
||||
- runs migrations against the worktree database
|
||||
|
||||
Start the worktree app:
|
||||
|
||||
```bash
|
||||
make start-worktree
|
||||
```
|
||||
|
||||
Stop the worktree app processes:
|
||||
Stop:
|
||||
|
||||
```bash
|
||||
make stop-worktree
|
||||
@@ -171,17 +165,15 @@ Use a worktree when you want isolated data and separate app ports.
|
||||
```bash
|
||||
git worktree add ../multica-feature -b feat/my-change main
|
||||
cd ../multica-feature
|
||||
make worktree-env
|
||||
make setup-worktree
|
||||
make start-worktree
|
||||
make dev
|
||||
```
|
||||
|
||||
After that, day-to-day commands are:
|
||||
|
||||
```bash
|
||||
make start-worktree
|
||||
make stop-worktree
|
||||
make check-worktree
|
||||
make dev # start (re-runs setup if needed, idempotent)
|
||||
make stop-worktree # stop
|
||||
make check-worktree # verify
|
||||
```
|
||||
|
||||
## Running Main and Worktree at the Same Time
|
||||
@@ -317,6 +309,199 @@ make daemon
|
||||
The daemon authenticates using the CLI's stored token (`multica login`).
|
||||
It registers runtimes for all watched workspaces from the CLI config.
|
||||
|
||||
## Full-Stack Isolated Testing
|
||||
|
||||
This section covers running the complete stack (backend, frontend, daemon) from
|
||||
source in a fully isolated environment. Useful for testing end-to-end changes
|
||||
that span multiple components, or for automated CI/AI workflows that need zero
|
||||
human intervention.
|
||||
|
||||
### Why Not Just `make daemon`?
|
||||
|
||||
`make daemon` uses the system-installed CLI's stored token and connects to
|
||||
whatever server is configured in `~/.multica/config.json`. That's fine for
|
||||
day-to-day development against a shared server, but for fully isolated testing
|
||||
you need:
|
||||
|
||||
- a local backend and frontend (from source)
|
||||
- a local daemon (from source) with its own profile
|
||||
- automated authentication (no browser login)
|
||||
- no interference with your production CLI config
|
||||
|
||||
### Dynamic Profile Naming
|
||||
|
||||
Each worktree must use a unique daemon profile to avoid collisions when
|
||||
multiple features run in parallel.
|
||||
|
||||
The profile name is derived from the worktree directory using the same
|
||||
slug + hash pattern as `scripts/init-worktree-env.sh`:
|
||||
|
||||
```bash
|
||||
WORKTREE_DIR="$(basename "$PWD")"
|
||||
SLUG="$(printf '%s' "$WORKTREE_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
|
||||
HASH="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
|
||||
OFFSET=$((HASH % 1000))
|
||||
PROFILE="dev-${SLUG}-${OFFSET}"
|
||||
```
|
||||
|
||||
Example: worktree at `../multica-feat-auth` produces profile
|
||||
`dev-multica_feat_auth-347`, matching that worktree's port and database
|
||||
allocation.
|
||||
|
||||
### Start the Isolated Environment
|
||||
|
||||
Run all steps from the worktree root (where the Makefile is).
|
||||
|
||||
#### 1. Start backend, frontend, and database
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
Wait for the backend to be healthy:
|
||||
|
||||
```bash
|
||||
PORT=$(grep '^PORT=' .env.worktree 2>/dev/null || grep '^PORT=' .env | head -1 | cut -d= -f2)
|
||||
PORT=${PORT:-8080}
|
||||
SERVER="http://localhost:${PORT}"
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf "$SERVER/health" > /dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
```
|
||||
|
||||
#### 2. Create a test user and token (automated auth)
|
||||
|
||||
In non-production environments the verification code is fixed at `888888`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$SERVER/auth/send-code" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "dev@localhost"}'
|
||||
|
||||
JWT=$(curl -s -X POST "$SERVER/auth/verify-code" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "dev@localhost", "code": "888888"}' | jq -r '.token')
|
||||
|
||||
PAT=$(curl -s -X POST "$SERVER/api/tokens" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "auto-dev", "expires_in_days": 365}' | jq -r '.token')
|
||||
```
|
||||
|
||||
#### 3. Create a workspace
|
||||
|
||||
```bash
|
||||
WS=$(curl -s -X POST "$SERVER/api/workspaces" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Dev", "slug": "dev"}' | jq -r '.id')
|
||||
```
|
||||
|
||||
#### 4. Compute profile name and write CLI config
|
||||
|
||||
```bash
|
||||
# Compute profile (see Dynamic Profile Naming above)
|
||||
WORKTREE_DIR="$(basename "$PWD")"
|
||||
SLUG="$(printf '%s' "$WORKTREE_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
|
||||
HASH="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
|
||||
OFFSET=$((HASH % 1000))
|
||||
PROFILE="dev-${SLUG}-${OFFSET}"
|
||||
|
||||
FRONTEND_PORT=$(grep '^FRONTEND_PORT=' .env.worktree 2>/dev/null || grep '^FRONTEND_PORT=' .env | head -1 | cut -d= -f2)
|
||||
FRONTEND_PORT=${FRONTEND_PORT:-3000}
|
||||
|
||||
CONFIG_DIR="$HOME/.multica/profiles/$PROFILE"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
cat > "$CONFIG_DIR/config.json" << EOF
|
||||
{
|
||||
"server_url": "$SERVER",
|
||||
"app_url": "http://localhost:${FRONTEND_PORT}",
|
||||
"token": "$PAT",
|
||||
"workspace_id": "$WS",
|
||||
"watched_workspaces": [{"id": "$WS", "name": "Dev"}]
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 5. Start the daemon from source
|
||||
|
||||
```bash
|
||||
make cli ARGS="daemon start --profile $PROFILE"
|
||||
```
|
||||
|
||||
The daemon runs from the current worktree's Go source, connecting to the
|
||||
local backend. Agent-executed `multica` commands automatically use the same
|
||||
binary (the daemon prepends its own directory to `PATH`).
|
||||
|
||||
### Stop the Isolated Environment
|
||||
|
||||
```bash
|
||||
# Compute profile (same formula)
|
||||
PROFILE="dev-$(printf '%s' "$(basename "$PWD")" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')-$(( $(printf '%s' "$PWD" | cksum | awk '{print $1}') % 1000 ))"
|
||||
|
||||
# 1. Stop daemon
|
||||
make cli ARGS="daemon stop --profile $PROFILE"
|
||||
|
||||
# 2. Stop backend + frontend
|
||||
make stop # main checkout
|
||||
make stop-worktree # worktree checkout
|
||||
|
||||
# 3. (Optional) Stop shared PostgreSQL
|
||||
make db-down
|
||||
|
||||
# 4. (Optional) Clean build artifacts
|
||||
make clean
|
||||
|
||||
# 5. (Optional) Remove profile config
|
||||
rm -rf "$HOME/.multica/profiles/$PROFILE"
|
||||
```
|
||||
|
||||
### Desktop App Local Testing
|
||||
|
||||
To test the Electron desktop app against a local backend:
|
||||
|
||||
```bash
|
||||
# After backend is running (make dev)
|
||||
pnpm dev:desktop
|
||||
```
|
||||
|
||||
This automatically:
|
||||
|
||||
1. Compiles the `multica` CLI from `server/cmd/multica` into
|
||||
`apps/desktop/resources/bin/multica`
|
||||
2. Creates an isolated profile named `desktop-localhost-<PORT>`
|
||||
3. Starts and manages its own daemon instance
|
||||
4. Connects to the local backend
|
||||
|
||||
Login in the Desktop UI with `dev@localhost` and code `888888`.
|
||||
|
||||
If the backend runs on a non-default port (worktree), create
|
||||
`apps/desktop/.env.development.local`:
|
||||
|
||||
```bash
|
||||
VITE_API_URL=http://localhost:<backend-port>
|
||||
VITE_WS_URL=ws://localhost:<backend-port>/ws
|
||||
```
|
||||
|
||||
### Isolation Guarantee
|
||||
|
||||
Nothing in this flow touches the system-installed `multica` or the default
|
||||
`~/.multica/config.json`:
|
||||
|
||||
| Resource | System / Production | Local Dev (per-worktree) |
|
||||
|---|---|---|
|
||||
| Config | `~/.multica/config.json` | `~/.multica/profiles/dev-<slug>-<hash>/config.json` |
|
||||
| Daemon PID | `~/.multica/daemon.pid` | `~/.multica/profiles/dev-<slug>-<hash>/daemon.pid` |
|
||||
| Health port | `19514` | `19514 + 1 + (name_hash % 1000)` |
|
||||
| Workspaces dir | `~/multica_workspaces/` | `~/multica_workspaces_dev-<slug>-<hash>/` |
|
||||
| Database | remote / production | local Docker: `multica_<slug>_<hash>` |
|
||||
| Desktop profile | `desktop-api.multica.ai` | `desktop-localhost-<port>` |
|
||||
|
||||
Multiple worktrees can run simultaneously without conflict.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Env File
|
||||
@@ -424,9 +609,7 @@ Warning:
|
||||
### Stable Main Environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make setup-main
|
||||
make start-main
|
||||
make dev
|
||||
```
|
||||
|
||||
### Feature Worktree
|
||||
@@ -434,9 +617,7 @@ make start-main
|
||||
```bash
|
||||
git worktree add ../multica-feature -b feat/my-change main
|
||||
cd ../multica-feature
|
||||
make worktree-env
|
||||
make setup-worktree
|
||||
make start-worktree
|
||||
make dev
|
||||
```
|
||||
|
||||
### Return to a Previously Configured Worktree
|
||||
|
||||
@@ -30,7 +30,9 @@ COPY --from=builder /src/server/bin/server .
|
||||
COPY --from=builder /src/server/bin/multica .
|
||||
COPY --from=builder /src/server/bin/migrate .
|
||||
COPY server/migrations/ ./migrations/
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./server"]
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
72
Dockerfile.web
Normal file
72
Dockerfile.web
Normal file
@@ -0,0 +1,72 @@
|
||||
# --- Dependencies ---
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace config and all package.json files for dependency resolution
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/core/package.json packages/core/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/views/package.json packages/views/
|
||||
COPY packages/tsconfig/package.json packages/tsconfig/
|
||||
COPY packages/eslint-config/package.json packages/eslint-config/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# --- Build ---
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed dependencies (preserves pnpm symlink structure)
|
||||
COPY --from=deps /app ./
|
||||
|
||||
# Copy source
|
||||
COPY package.json turbo.json pnpm-workspace.yaml ./
|
||||
COPY apps/web/ apps/web/
|
||||
COPY packages/ packages/
|
||||
|
||||
# Re-link after source overlay (fixes any symlinks overwritten by COPY)
|
||||
RUN pnpm install --frozen-lockfile --offline
|
||||
|
||||
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
|
||||
ARG REMOTE_API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_WS_URL
|
||||
ENV REMOTE_API_URL=$REMOTE_API_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV STANDALONE=true
|
||||
|
||||
# Build the web app (standalone output for minimal runtime)
|
||||
RUN pnpm --filter @multica/web build
|
||||
|
||||
# --- Runtime ---
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone output (includes traced node_modules)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
# Copy static files (not included in standalone)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
# Copy public assets
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
383
HANDOFF_ARCHITECTURE_AUDIT.md
Normal file
383
HANDOFF_ARCHITECTURE_AUDIT.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Architecture Audit — Workspace & Realtime Cache
|
||||
|
||||
> 基于代码审计整理的 4 个任务。优先级:P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1 — [P0] 空闲后列表数据陈旧
|
||||
|
||||
**关联 issue**:[#951](https://github.com/multica-ai/multica/issues/951)
|
||||
|
||||
### 问题
|
||||
|
||||
用户登录后静置一段时间,Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
|
||||
|
||||
### 根因
|
||||
|
||||
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
|
||||
|
||||
- `packages/core/query-client.ts:7` — `staleTime: Infinity`,cache 永不主动过期
|
||||
- `packages/core/query-client.ts:9` — `refetchOnWindowFocus: false`,tab 重新获得焦点也不 refetch
|
||||
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
|
||||
|
||||
但 WS 层存在一个**不对称**:
|
||||
|
||||
- **服务端**:`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait,会清理死连接
|
||||
- **客户端**:`packages/core/api/ws-client.ts`(142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
|
||||
|
||||
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS,所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
|
||||
|
||||
1. 浏览器 `readyState` 仍是 `OPEN`
|
||||
2. `onclose` 不触发
|
||||
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
|
||||
4. `packages/core/realtime/use-realtime-sync.ts:462-487` 的 `onReconnect` 全量 invalidate 不跑
|
||||
5. 期间的 WS 事件进黑洞
|
||||
6. cache 保持旧快照
|
||||
|
||||
### 复现
|
||||
|
||||
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
|
||||
|
||||
**方法 A(推荐,最接近真实场景)**:macOS 用 pfctl 丢包
|
||||
|
||||
```bash
|
||||
# 假设后端在 8080
|
||||
sudo pfctl -E
|
||||
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
|
||||
|
||||
# 观察:
|
||||
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
|
||||
# - Network 里 WS 连接仍显示 Pending / 101
|
||||
# 用另一个账号/CLI 创建一个 issue
|
||||
# 回到原客户端: 列表不更新
|
||||
# 登出再登入: 列表恢复完整
|
||||
|
||||
sudo pfctl -d # 解除
|
||||
```
|
||||
|
||||
**方法 B(不动网络)**:临时修改代码,在 `packages/core/api/ws-client.ts:52` 的 `onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
|
||||
|
||||
### 修复方案(三个选项,推荐 C)
|
||||
|
||||
#### 选项 A — 浏览器端心跳探活(治本,改动大)
|
||||
|
||||
在 `ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
|
||||
|
||||
- 优点:从根本上解决半开问题
|
||||
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
|
||||
|
||||
#### 选项 B — Page Visibility API 触发 invalidate(治标,改动小)
|
||||
|
||||
在 `packages/core/platform/core-provider.tsx` 加 `visibilitychange` 监听,tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key)。
|
||||
|
||||
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab)
|
||||
- 缺点:treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
|
||||
|
||||
#### 选项 C — **A + B 组合**(推荐)
|
||||
|
||||
- 短期上 B,立刻止血
|
||||
- 中期上 A,把 cache 新鲜度从"只信 WS"改成"WS 是优化,Visibility 是兜底"
|
||||
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min),作为第三层保险
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 方案 | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
|
||||
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
|
||||
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
|
||||
|
||||
### 验证
|
||||
|
||||
修完之后:
|
||||
|
||||
1. 跑方法 A 复现流程,确认数据不再丢失
|
||||
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
|
||||
|
||||
---
|
||||
|
||||
## 任务 2 — [P1] Workspace 不在 URL 路径中
|
||||
|
||||
**关联 issue**:MUL-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
|
||||
|
||||
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
|
||||
|
||||
### 问题
|
||||
|
||||
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
### 根因
|
||||
|
||||
**数据库和 API 已经支持 slug**:
|
||||
|
||||
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
|
||||
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
|
||||
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
|
||||
|
||||
**但前端路由和导航层没用它**:
|
||||
|
||||
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
|
||||
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
|
||||
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
|
||||
|
||||
**workspace 切换只靠 sidebar UI**(`packages/views/layout/app-sidebar.tsx:284-286`):
|
||||
|
||||
```tsx
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues"); // 硬跳 /issues(workspace-implicit!)
|
||||
switchWorkspace(ws); // 然后改 store
|
||||
}
|
||||
```
|
||||
|
||||
这种设计使得:
|
||||
|
||||
- 手机端因为没 sidebar UI,也没 URL 层切换入口,**完全切不了 workspace**(MUL-509)
|
||||
- 把 `/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列)
|
||||
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
|
||||
|
||||
### 复现
|
||||
|
||||
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
|
||||
2. **MUL-43**:
|
||||
- 加入两个 workspace A 和 B
|
||||
- 在 A 中打开某个 issue `/issues/abc123`
|
||||
- 切到 B,URL 不变 → 访问失败 / 显示错数据
|
||||
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
|
||||
|
||||
### 修复方案(三个选项,推荐 A)
|
||||
|
||||
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
|
||||
|
||||
所有路径加上 workspace slug 前缀。例如 `/issues/abc123` → `/ws/my-team/issues/abc123`。
|
||||
|
||||
**要改的地方**:
|
||||
|
||||
1. **Web 路由目录结构**:`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`(~25 个文件)
|
||||
2. **Desktop 路由**:`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
|
||||
3. **Navigation 适配器**:
|
||||
- `apps/web/platform/navigation.tsx` — `push(path)` 内部前置 `/ws/${workspace.slug}`,`pathname` 读取时去掉前缀
|
||||
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
|
||||
4. **Sidebar 切换逻辑**:`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
|
||||
5. **服务端中间件**:`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
|
||||
|
||||
**预计改动**:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
|
||||
|
||||
**不改也能工作的部分**:
|
||||
- `packages/core/api/client.ts` — 仍旧走 header,不用改
|
||||
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
|
||||
|
||||
**风险**:
|
||||
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
|
||||
- E2E 测试需要更新所有 URL 断言
|
||||
|
||||
#### 选项 B — `?ws=slug` query param(折中)
|
||||
|
||||
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
|
||||
|
||||
#### 选项 C — 只修症状不动架构
|
||||
|
||||
在 `switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。**不推荐**。
|
||||
|
||||
### 改动范围(选项 A)
|
||||
|
||||
| 模块 | 文件数 | 备注 |
|
||||
|---|---|---|
|
||||
| Web routes | ~25 | 目录搬迁 |
|
||||
| Desktop routes | 1 | 路径前缀 |
|
||||
| Navigation adapters | 2 | 前缀逻辑 |
|
||||
| Server middleware | 1-2 | slug → ID 解析 |
|
||||
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
|
||||
| E2E tests | 20-30 | URL 断言更新 |
|
||||
|
||||
---
|
||||
|
||||
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
|
||||
|
||||
**关联 issue**:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
|
||||
|
||||
> 同上,这两个编号建议交接时核对症状。
|
||||
|
||||
### 问题
|
||||
|
||||
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`(key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
|
||||
|
||||
### 根因
|
||||
|
||||
**`packages/core/navigation/store.ts:15-31`**:
|
||||
|
||||
```typescript
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
// ← 没有调 registerForWorkspaceRehydration
|
||||
```
|
||||
|
||||
**对比:其他 store 都是正确的**:
|
||||
|
||||
| Store | 是否 workspace-aware | 是否注册 rehydration |
|
||||
|---|---|---|
|
||||
| useNavigationStore | ❌ | ❌ |
|
||||
| useIssuesScopeStore | ✅ | ✅ |
|
||||
| useIssueDraftStore | ✅ | ✅ |
|
||||
| useRecentIssuesStore | ✅ | ✅ |
|
||||
| useIssueViewStore | ✅ | ✅ |
|
||||
| myIssuesViewStore | ✅ | ✅ |
|
||||
| useChatStore | ✅(手动用 wsKey)| ✅ |
|
||||
|
||||
另外 `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`。
|
||||
|
||||
**现有的 workaround**:`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
|
||||
|
||||
### 复现
|
||||
|
||||
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
|
||||
2. 切到 workspace B
|
||||
3. 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B,导致 404 或错误
|
||||
|
||||
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
|
||||
|
||||
### 修复方案(推荐 Option C:组合)
|
||||
|
||||
**三处改动**:
|
||||
|
||||
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
|
||||
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
|
||||
3. `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
|
||||
|
||||
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285` 的 `push("/issues")` workaround(改完之后不再需要)。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
|
||||
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
|
||||
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
|
||||
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
|
||||
|
||||
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4 — [P2] Workspace 生命周期副作用散落
|
||||
|
||||
**关联 issue**:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
|
||||
|
||||
### 问题
|
||||
|
||||
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug:
|
||||
|
||||
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
|
||||
|
||||
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
|
||||
|
||||
- `packages/core/workspace/mutations.ts:7-21` 的 `useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand,`/issues` 路由开始用新 workspace 渲染
|
||||
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
|
||||
|
||||
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`。
|
||||
|
||||
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx` 的 `onSuccess` 里先 `switchWorkspace` 再 `push`,保证同一个微任务里完成。
|
||||
|
||||
**文件**:`packages/core/workspace/mutations.ts`、`packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
|
||||
|
||||
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
|
||||
|
||||
**核查结果**:`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
|
||||
|
||||
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
|
||||
|
||||
**修复**:在 `handleDeleteWorkspace` 的 `onConfirm` 成功分支里加 `push("/issues")`。
|
||||
|
||||
**文件**:`packages/views/settings/components/workspace-tab.tsx`(加一行)
|
||||
|
||||
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
|
||||
|
||||
**核查结果**:有两条路径:
|
||||
|
||||
- ✅ `/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**:accept → switchWorkspace → push("/issues")
|
||||
- ❌ **Sidebar 下拉里的 "Join" 按钮**(`packages/views/layout/app-sidebar.tsx:203-209, 321-324`)**是错的**:只 invalidate cache,不切也不跳
|
||||
|
||||
**修复(推荐 Option 2)**:Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
|
||||
{inv.workspace_name}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**文件**:`packages/views/layout/app-sidebar.tsx`(~10 行)
|
||||
|
||||
### 复现
|
||||
|
||||
| Issue | 步骤 |
|
||||
|---|---|
|
||||
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
|
||||
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面(BUG: 没有自动跳走) |
|
||||
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切)|
|
||||
|
||||
### 长期架构建议(可选)
|
||||
|
||||
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| Issue | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
|
||||
| MUL-728 | workspace-tab.tsx | ~1 行 |
|
||||
| MUL-820 | app-sidebar.tsx | ~10 行 |
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行;Option C ~1-2 天 | 低 |
|
||||
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中(影响面大、e2e 要改)|
|
||||
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
|
||||
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
|
||||
|
||||
### 建议推进顺序
|
||||
|
||||
1. **立刻做**:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
|
||||
2. **同步开始**:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
|
||||
3. **规划立项**:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
|
||||
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
|
||||
|
||||
### 重要澄清
|
||||
|
||||
- **Issue 编号核对**:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
|
||||
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
|
||||
- **MUL-820 实际状态**:`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
|
||||
|
||||
### 所有关键代码位置索引
|
||||
|
||||
```
|
||||
packages/core/query-client.ts:7-10 # staleTime: Infinity
|
||||
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
|
||||
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
|
||||
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
|
||||
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
|
||||
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
|
||||
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
|
||||
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
|
||||
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
|
||||
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
|
||||
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
|
||||
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
|
||||
|
||||
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
|
||||
server/internal/middleware/workspace.go:41-46 # wsId resolution
|
||||
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
|
||||
```
|
||||
221
LICENSE
221
LICENSE
@@ -1,199 +1,44 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
# Open Source License
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
|
||||
|
||||
1. Definitions.
|
||||
1. Multica may be utilized commercially, including as a backend service for
|
||||
other applications or as a task management platform for enterprises.
|
||||
Should the conditions below be met, a commercial license must be obtained
|
||||
from the producer:
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
a. Hosted or embedded service: Unless explicitly authorized by Multica
|
||||
in writing, you may not use the Multica source code to provide a
|
||||
hosted service to third parties, or embed Multica as a component of
|
||||
a product or service that is sold, licensed, or otherwise
|
||||
commercially distributed to third parties.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
- This restriction applies to offering Multica (in whole or
|
||||
substantial part) as a SaaS platform, a managed service, or as
|
||||
an integrated component within another commercial offering.
|
||||
- Internal use within a single organization (including multiple
|
||||
workspaces) does not require a commercial license.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
b. LOGO and copyright information: In the process of using Multica's
|
||||
frontend, you may not remove or modify the LOGO or copyright
|
||||
information in the Multica console or applications. This restriction
|
||||
is inapplicable to uses of Multica that do not involve its frontend.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
- Frontend Definition: For the purposes of this license, the
|
||||
"frontend" of Multica includes all components located in the
|
||||
`apps/web/` directory when running Multica from the raw source
|
||||
code, or the "web" image when running Multica with Docker.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
2. As a contributor, you should agree that:
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
a. The producer can adjust the open-source agreement to be more strict
|
||||
or relaxed as deemed necessary.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
b. Your contributed code may be used for commercial purposes, including
|
||||
but not limited to its cloud business operations.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
Apart from the specific conditions mentioned above, all other rights and
|
||||
restrictions follow the Apache License 2.0. Detailed information about the
|
||||
Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an
|
||||
"Implied Patent License" from your patent counsel.
|
||||
|
||||
Copyright 2025 Multica
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
© 2025 Multica, Inc.
|
||||
|
||||
79
Makefile
79
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: dev daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
|
||||
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
|
||||
|
||||
MAIN_ENV_FILE ?= .env
|
||||
WORKTREE_ENV_FILE ?= .env.worktree
|
||||
@@ -36,6 +36,53 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# ---------- Self-hosting (Docker Compose) ----------
|
||||
|
||||
# One-command self-host: create env, start Docker Compose, wait for health
|
||||
selfhost:
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
JWT=$$(openssl rand -hex 32); \
|
||||
if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
else \
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Starting Multica via Docker Compose..."
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
@echo "==> Waiting for backend to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
break; \
|
||||
fi; \
|
||||
sleep 2; \
|
||||
done
|
||||
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
echo ""; \
|
||||
echo "✓ Multica is running!"; \
|
||||
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in with any email + verification code: 888888"; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
echo " multica setup self-host"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "Services are still starting. Check logs:"; \
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
# Stop all Docker Compose self-host services
|
||||
selfhost-stop:
|
||||
@echo "==> Stopping Multica services..."
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
@echo "✓ All services stopped."
|
||||
|
||||
# ---------- One-click commands ----------
|
||||
|
||||
# First-time setup: install deps, start DB, run migrations
|
||||
@@ -57,6 +104,8 @@ start:
|
||||
@echo "Backend: http://localhost:$(PORT)"
|
||||
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
@echo "Running migrations..."
|
||||
cd server && go run ./cmd/migrate up
|
||||
@echo "Starting backend and frontend..."
|
||||
@trap 'kill 0' EXIT; \
|
||||
(cd server && go run ./cmd/server) & \
|
||||
@@ -69,7 +118,12 @@ stop:
|
||||
@echo "Stopping services..."
|
||||
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
|
||||
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
|
||||
@echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
|
||||
@case "$(DATABASE_URL)" in \
|
||||
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) \
|
||||
echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:$(POSTGRES_PORT)." ;; \
|
||||
*) \
|
||||
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
|
||||
esac
|
||||
|
||||
# Full verification: typecheck + unit tests + Go tests + E2E
|
||||
check:
|
||||
@@ -98,8 +152,12 @@ check-main:
|
||||
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
setup-worktree:
|
||||
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
|
||||
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
|
||||
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
|
||||
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
|
||||
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
|
||||
else \
|
||||
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
|
||||
fi
|
||||
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
start-worktree:
|
||||
@@ -113,14 +171,18 @@ check-worktree:
|
||||
|
||||
# ---------- Individual commands ----------
|
||||
|
||||
# Go server
|
||||
# One-command dev: auto-setup env/deps/db/migrations, then start all services
|
||||
dev:
|
||||
@bash scripts/dev.sh
|
||||
|
||||
# Go server only
|
||||
server:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/server
|
||||
|
||||
daemon:
|
||||
@$(MAKE) multica MULTICA_ARGS="daemon"
|
||||
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
|
||||
|
||||
cli:
|
||||
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
|
||||
@@ -130,14 +192,17 @@ multica:
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
build:
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
test:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
cd server && go test ./...
|
||||
|
||||
# Database
|
||||
|
||||
166
README.md
166
README.md
@@ -14,14 +14,13 @@
|
||||
|
||||
**Your next 10 hires won't be human.**
|
||||
|
||||
Open-source platform that turns coding agents into real teammates.<br/>
|
||||
Assign tasks, track progress, compound skills — manage your human + agent workforce in one place.
|
||||
The open-source managed agents platform.<br/>
|
||||
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
@@ -31,7 +30,7 @@ Assign tasks, track progress, compound skills — manage your human + agent work
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Works with **Claude Code** and **Codex**.
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
@@ -39,63 +38,66 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
|
||||
|
||||
## Features
|
||||
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
|
||||
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
|
||||
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
|
||||
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
|
||||
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
|
||||
|
||||
---
|
||||
|
||||
## Quick Install
|
||||
|
||||
### macOS / Linux (Homebrew - recommended)
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
Use `brew upgrade multica-ai/tap/multica` to keep the CLI current.
|
||||
|
||||
### macOS / Linux (install script)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Use this if Homebrew is not available. The script installs the Multica CLI on macOS and Linux by using Homebrew when it is on `PATH`, otherwise it downloads the binary directly.
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Then configure, authenticate, and start the daemon in one command:
|
||||
|
||||
```bash
|
||||
multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
```
|
||||
|
||||
> **Self-hosting?** Add `--with-server` to deploy a full Multica server on your machine:
|
||||
>
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
> multica setup self-host
|
||||
> ```
|
||||
>
|
||||
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Multica Cloud
|
||||
|
||||
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
|
||||
|
||||
### Self-Host with Docker
|
||||
### 1. Set up and start the daemon
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
cp .env.example .env
|
||||
# Edit .env — at minimum, change JWT_SECRET
|
||||
|
||||
docker compose up -d # Start PostgreSQL
|
||||
cd server && go run ./cmd/migrate up && cd .. # Run migrations
|
||||
make start # Start the app
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
|
||||
# Authenticate and start
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
|
||||
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent:
|
||||
|
||||
### 1. Log in and start the daemon
|
||||
|
||||
```bash
|
||||
multica login # Authenticate with your Multica account
|
||||
multica daemon start # Start the local agent runtime
|
||||
```
|
||||
|
||||
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -105,13 +107,47 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
|
||||
|
||||
That's it! Your agent is now part of the team. 🎉
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
|
||||
| **User model** | Multi-user teams with roles & permissions | Single board operator |
|
||||
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
|
||||
| **Deployment** | Cloud-first | Local-first |
|
||||
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
|
||||
| **Extensibility** | Skills system | Skills + Plugin system |
|
||||
|
||||
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `multica login` | Authenticate (opens browser) |
|
||||
| `multica daemon start` | Start the local agent runtime |
|
||||
| `multica daemon status` | Check daemon status |
|
||||
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
|
||||
| `multica setup self-host` | Same, but for self-hosted deployments |
|
||||
| `multica issue list` | List issues in your workspace |
|
||||
| `multica issue create` | Create a new issue |
|
||||
| `multica update` | Update to the latest version |
|
||||
|
||||
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -122,9 +158,10 @@ That's it! Your agent is now part of the team. 🎉
|
||||
└──────────────┘ └──────┬───────┘ └──────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (runs on your machine)
|
||||
│ Claude/Codex │
|
||||
└──────────────┘
|
||||
│ Agent Daemon │ runs on your machine
|
||||
└──────────────┘ (Claude Code, Codex, OpenCode,
|
||||
OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent)
|
||||
```
|
||||
|
||||
| Layer | Stack |
|
||||
@@ -132,7 +169,7 @@ That's it! Your agent is now part of the team. 🎉
|
||||
| Frontend | Next.js 16 (App Router) |
|
||||
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| Database | PostgreSQL 17 with pgvector |
|
||||
| Agent Runtime | Local daemon executing Claude Code or Codex |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -141,14 +178,19 @@ For contributors working on the Multica codebase, see the [Contributing Guide](C
|
||||
**Prerequisites:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
make setup
|
||||
make start
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## License
|
||||
## Star History
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
110
README.zh-CN.md
110
README.zh-CN.md
@@ -14,14 +14,13 @@
|
||||
|
||||
**你的下一批员工,不是人类。**
|
||||
|
||||
开源平台,将编码 Agent 变成真正的队友。<br/>
|
||||
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队。
|
||||
开源的 Managed Agents 平台。<br/>
|
||||
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
@@ -31,7 +30,7 @@
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code** 和 **Codex**。
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi** 和 **Cursor Agent**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
@@ -39,63 +38,68 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
|
||||
|
||||
## 功能特性
|
||||
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI,实时监控。
|
||||
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
|
||||
|
||||
## 快速开始
|
||||
---
|
||||
|
||||
### Multica 云服务
|
||||
## 快速安装
|
||||
|
||||
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
|
||||
|
||||
### Docker 自部署
|
||||
### macOS / Linux(推荐 Homebrew)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
cp .env.example .env
|
||||
# 编辑 .env — 至少修改 JWT_SECRET
|
||||
|
||||
docker compose up -d # 启动 PostgreSQL
|
||||
cd server && go run ./cmd/migrate up && cd .. # 运行数据库迁移
|
||||
make start # 启动应用
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)。
|
||||
后续可用 `brew upgrade multica-ai/tap/multica` 更新 CLI。
|
||||
|
||||
## CLI
|
||||
|
||||
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
|
||||
### macOS / Linux(安装脚本)
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
|
||||
# 认证并启动
|
||||
multica login
|
||||
multica daemon start
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
daemon 会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`)。当 Agent 被分配任务时,daemon 会创建隔离环境、运行 Agent、并将结果回传。
|
||||
如果没有 Homebrew,可以使用安装脚本。脚本会安装 Multica CLI:检测到 `brew` 时通过 Homebrew 安装,否则直接下载二进制。
|
||||
|
||||
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
安装完成后,一条命令完成配置、认证和启动:
|
||||
|
||||
```bash
|
||||
multica setup # 连接 Multica Cloud,登录,启动 daemon
|
||||
```
|
||||
|
||||
> **自部署?** 加上 `--with-server` 在本地部署完整的 Multica 服务:
|
||||
>
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
> multica setup self-host
|
||||
> ```
|
||||
>
|
||||
> 需要 Docker。详见 [自部署指南](SELF_HOSTING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
|
||||
安装好 CLI(或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent:
|
||||
|
||||
### 1. 登录并启动 daemon
|
||||
### 1. 配置并启动 daemon
|
||||
|
||||
```bash
|
||||
multica login # 使用你的 Multica 账号认证
|
||||
multica daemon start # 启动本地 Agent 运行时
|
||||
multica setup # 配置、认证、启动 daemon(一条命令搞定)
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`)。
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
@@ -105,7 +109,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code 或 Codex),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
@@ -113,6 +117,21 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
大功告成!你的 Agent 现在是团队的一员了。 🎉
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
|
||||
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
|
||||
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
|
||||
| **部署** | 云端优先 | 本地优先 |
|
||||
| **管理深度** | 轻量(Issue / Project / Labels) | 重度(组织架构 / 审批 / 预算) |
|
||||
| **扩展** | Skills 系统 | Skills + 插件系统 |
|
||||
|
||||
**简单来说:Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
@@ -122,9 +141,10 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
└──────────────┘ └──────┬───────┘ └──────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (运行在你的机器上)
|
||||
│ Claude/Codex │
|
||||
└──────────────┘
|
||||
│ Agent Daemon │ 运行在你的机器上
|
||||
└──────────────┘ (Claude Code、Codex、OpenCode、
|
||||
OpenClaw、Hermes、Gemini、
|
||||
Pi、Cursor Agent)
|
||||
```
|
||||
|
||||
| 层级 | 技术栈 |
|
||||
@@ -132,7 +152,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
| 前端 | Next.js 16 (App Router) |
|
||||
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| 数据库 | PostgreSQL 17 with pgvector |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code 或 Codex |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent |
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -152,3 +172,13 @@ make start
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
417
SELF_HOSTING.md
417
SELF_HOSTING.md
@@ -1,10 +1,8 @@
|
||||
# Self-Hosting Guide
|
||||
|
||||
This guide walks you through deploying Multica on your own infrastructure.
|
||||
Deploy Multica on your own infrastructure in minutes.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Multica has three components:
|
||||
## Architecture
|
||||
|
||||
| Component | Description | Technology |
|
||||
|-----------|-------------|------------|
|
||||
@@ -12,16 +10,162 @@ Multica has three components:
|
||||
| **Frontend** | Web application | Next.js 16 |
|
||||
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
|
||||
|
||||
Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
|
||||
Each user who runs AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
|
||||
|
||||
## Prerequisites
|
||||
## Quick Install (Recommended)
|
||||
|
||||
- Docker and Docker Compose (recommended), or:
|
||||
- Go 1.26+ (to build from source)
|
||||
- Node.js 20+ and pnpm 10.28+ (to build the frontend)
|
||||
- PostgreSQL 17 with the pgvector extension
|
||||
Two commands to set up everything — server, CLI, and configuration:
|
||||
|
||||
## Quick Start (Docker Compose)
|
||||
```bash
|
||||
# 1. Install CLI + provision the self-host server
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
|
||||
# 2. Configure CLI, authenticate, and start the daemon
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
|
||||
Open http://localhost:3000, log in with any email + verification code **`888888`**.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
>
|
||||
> **CLI only?** If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew:
|
||||
>
|
||||
> ```bash
|
||||
> brew install multica-ai/tap/multica
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Setup (Alternative)
|
||||
|
||||
If you prefer to run each step manually:
|
||||
|
||||
### Step 1 — Start the Server
|
||||
|
||||
**Prerequisites:** Docker and Docker Compose.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make selfhost
|
||||
```
|
||||
|
||||
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
> **Note:** If you prefer to run the Docker Compose steps manually, see [Manual Docker Compose Setup](#manual-docker-compose-setup) below.
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
|
||||
|
||||
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks when agents are assigned work.
|
||||
|
||||
Each team member who wants to run AI agents locally needs to:
|
||||
|
||||
### a) Install the CLI and an AI agent
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
You also need at least one AI agent CLI installed:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
|
||||
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
|
||||
- Gemini (`gemini` on PATH)
|
||||
- [Pi](https://pi.dev/) (`pi` on PATH)
|
||||
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. Configures the CLI to connect to `localhost` (ports 8080/3000)
|
||||
2. Opens your browser for authentication
|
||||
3. Discovers your workspaces
|
||||
4. Starts the daemon in the background
|
||||
|
||||
For on-premise deployments with custom domains:
|
||||
|
||||
```bash
|
||||
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
|
||||
```
|
||||
|
||||
To verify the daemon is running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
> **Alternative:** If you prefer manual steps, see [Manual CLI Configuration](#manual-cli-configuration) below.
|
||||
|
||||
### Step 4 — Verify & Start Using
|
||||
|
||||
1. Open your workspace in the web app at http://localhost:3000
|
||||
2. Navigate to **Settings → Runtimes** — you should see your machine listed
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent — it will pick up the task automatically
|
||||
|
||||
## Stopping Services
|
||||
|
||||
If you installed via the install script:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop
|
||||
```
|
||||
|
||||
If you cloned the repo manually:
|
||||
|
||||
```bash
|
||||
# Stop the Docker Compose services (backend, frontend, database)
|
||||
make selfhost-stop
|
||||
|
||||
# Stop the local daemon
|
||||
multica daemon stop
|
||||
```
|
||||
|
||||
## Switching to Multica Cloud
|
||||
|
||||
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
|
||||
|
||||
```bash
|
||||
multica setup
|
||||
```
|
||||
|
||||
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
|
||||
|
||||
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
## Manual Docker Compose Setup
|
||||
|
||||
If you prefer running Docker Compose steps manually instead of `make selfhost`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
@@ -29,250 +173,43 @@ cd multica
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
|
||||
Edit `.env` — at minimum, change `JWT_SECRET`:
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL
|
||||
docker compose up -d
|
||||
|
||||
# Build the backend
|
||||
make build
|
||||
|
||||
# Run database migrations
|
||||
DATABASE_URL="your-database-url" ./server/bin/migrate up
|
||||
|
||||
# Start the backend server
|
||||
DATABASE_URL="your-database-url" PORT=8080 ./server/bin/server
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
For the frontend:
|
||||
Then start everything:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## Manual CLI Configuration
|
||||
|
||||
All configuration is done via environment variables. Copy `.env.example` as a starting point.
|
||||
|
||||
### Required Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
|
||||
### CLI / Daemon
|
||||
|
||||
These are configured on each user's machine, not on the server:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
|
||||
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
|
||||
|
||||
## Database Setup
|
||||
|
||||
Multica requires PostgreSQL 17 with the pgvector extension.
|
||||
|
||||
### Using the Included Docker Compose
|
||||
If you prefer configuring the CLI step by step instead of `multica setup`:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
# Point CLI to your local server
|
||||
multica config set server_url http://localhost:8080
|
||||
multica config set app_url http://localhost:3000
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
|
||||
# Start the daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
This starts a `pgvector/pgvector:pg17` container on port 5432 with default credentials (`multica`/`multica`).
|
||||
|
||||
### Using Your Own PostgreSQL
|
||||
|
||||
Ensure the pgvector extension is available:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
### Running Migrations
|
||||
|
||||
Migrations must be run before starting the server:
|
||||
For production deployments with TLS:
|
||||
|
||||
```bash
|
||||
# Using the built binary
|
||||
./server/bin/migrate up
|
||||
|
||||
# Or from source
|
||||
cd server && go run ./cmd/migrate up
|
||||
multica config set app_url https://app.example.com
|
||||
multica config set server_url https://api.example.com
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
## Advanced Configuration
|
||||
|
||||
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Frontend
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket support
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When using separate domains for frontend and backend, set these environment variables accordingly:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
FRONTEND_ORIGIN=https://app.example.com
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
|
||||
# Frontend
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## Setting Up the Agent Daemon
|
||||
|
||||
Each team member who wants to run AI agents locally needs to:
|
||||
|
||||
1. **Install the CLI**
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica-cli
|
||||
```
|
||||
|
||||
2. **Install an AI agent CLI** — at least one of:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
|
||||
3. **Authenticate and start**
|
||||
|
||||
```bash
|
||||
# Point CLI to your server
|
||||
export MULTICA_APP_URL=https://app.example.com
|
||||
export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
|
||||
# Start the daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
The daemon auto-detects installed agent CLIs and registers itself with the server. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Pull the latest code or image
|
||||
2. Run migrations: `./server/bin/migrate up`
|
||||
3. Restart the backend and frontend
|
||||
|
||||
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.
|
||||
For environment variables, manual setup (without Docker), reverse proxy configuration, database setup, and more, see the [Advanced Configuration Guide](SELF_HOSTING_ADVANCED.md).
|
||||
|
||||
265
SELF_HOSTING_ADVANCED.md
Normal file
265
SELF_HOSTING_ADVANCED.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Self-Hosting — Advanced Configuration
|
||||
|
||||
This document covers advanced configuration for self-hosted Multica deployments. For the quick start guide, see [SELF_HOSTING.md](SELF_HOSTING.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is done via environment variables. Copy `.env.example` as a starting point.
|
||||
|
||||
### Required Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
|
||||
### CLI / Daemon
|
||||
|
||||
These are configured on each user's machine, not on the server:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
|
||||
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
|
||||
|
||||
## Database Setup
|
||||
|
||||
Multica requires PostgreSQL 17 with the pgvector extension.
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
|
||||
The `docker-compose.selfhost.yml` includes PostgreSQL. No separate setup needed.
|
||||
|
||||
### Using Your Own PostgreSQL
|
||||
|
||||
If you prefer to use an existing PostgreSQL instance, ensure the pgvector extension is available:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
Set `DATABASE_URL` in your `.env` and remove the `postgres` service from the compose file.
|
||||
|
||||
### Running Migrations Manually
|
||||
|
||||
The Docker Compose setup runs migrations automatically. If you need to run them manually:
|
||||
|
||||
```bash
|
||||
# Using the built binary
|
||||
./server/bin/migrate up
|
||||
|
||||
# Or from source
|
||||
cd server && go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
## Manual Setup (Without Docker Compose)
|
||||
|
||||
If you prefer to build and run services manually:
|
||||
|
||||
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
|
||||
|
||||
```bash
|
||||
# Start your PostgreSQL (or use: docker compose up -d postgres)
|
||||
|
||||
# Build the backend
|
||||
make build
|
||||
|
||||
# Run database migrations
|
||||
DATABASE_URL="your-database-url" ./server/bin/migrate up
|
||||
|
||||
# Start the backend server
|
||||
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
|
||||
```
|
||||
|
||||
For the frontend:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Frontend
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket support
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When using separate domains for frontend and backend, set these environment variables accordingly:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
FRONTEND_ORIGIN=https://app.example.com
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
|
||||
# Frontend (set before building the frontend image)
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
```
|
||||
|
||||
## LAN / Non-localhost Access
|
||||
|
||||
By default, Multica works on `localhost`. If you access it from another machine on the LAN (e.g. `http://192.168.1.100:3000`), you need to tell the backend to accept that origin:
|
||||
|
||||
```bash
|
||||
# .env — replace with your server's LAN IP
|
||||
FRONTEND_ORIGIN=http://192.168.1.100:3000
|
||||
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
|
||||
```
|
||||
|
||||
Then rebuild:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
|
||||
|
||||
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
76
SELF_HOSTING_AI.md
Normal file
76
SELF_HOSTING_AI.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Self-Hosting Setup (for AI Agents)
|
||||
|
||||
This document is designed for AI agents to execute. Follow these steps exactly to deploy a local Multica instance and connect to it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Homebrew installed (for CLI)
|
||||
- At least one AI agent CLI on PATH: `claude` or `codex`
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Install CLI + provision self-host server
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
|
||||
# Configure CLI for localhost, authenticate, and start daemon
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
Wait for the server output `✓ Multica server is running and CLI is ready!` before running `multica setup self-host`.
|
||||
|
||||
**Expected result:**
|
||||
- Frontend at http://localhost:3000
|
||||
- Backend at http://localhost:8080
|
||||
- `multica` CLI installed and configured for localhost
|
||||
|
||||
## Alternative: Manual Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make selfhost
|
||||
brew install multica-ai/tap/multica
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
The `multica setup self-host` command will:
|
||||
1. Configure CLI to connect to localhost:8080 / localhost:3000
|
||||
2. Open a browser for login — use verification code `888888` with any email
|
||||
3. Discover workspaces automatically
|
||||
4. Start the daemon in the background
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Should show `running` with detected agents.
|
||||
|
||||
## Stopping
|
||||
|
||||
```bash
|
||||
# Stop the daemon
|
||||
multica daemon stop
|
||||
|
||||
# Stop all Docker services
|
||||
cd multica
|
||||
make selfhost-stop
|
||||
```
|
||||
|
||||
## Custom Ports
|
||||
|
||||
If the default ports (8080/3000) are in use:
|
||||
|
||||
1. Edit `.env` and change `PORT` and `FRONTEND_PORT`
|
||||
2. Run `make selfhost`
|
||||
3. Run `multica setup self-host --port <PORT> --frontend-port <FRONTEND_PORT>`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
|
||||
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
|
||||
- **Daemon issues:** `multica daemon logs`
|
||||
- **Health check:** `curl http://localhost:8080/health`
|
||||
12
apps/desktop/.env.production
Normal file
12
apps/desktop/.env.production
Normal file
@@ -0,0 +1,12 @@
|
||||
# Production environment for `pnpm package` / `pnpm build`.
|
||||
# electron-vite (Vite under the hood) reads this automatically in
|
||||
# production mode and inlines the values into the renderer bundle via
|
||||
# import.meta.env.VITE_*. These are public URLs, not secrets.
|
||||
|
||||
# Backend API + websocket the desktop app talks to.
|
||||
VITE_API_URL=https://api.multica.ai
|
||||
VITE_WS_URL=wss://api.multica.ai/ws
|
||||
|
||||
# Public web app URL — used to build shareable links like "Copy link to
|
||||
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
|
||||
VITE_APP_URL=https://multica.ai
|
||||
8
apps/desktop/.gitignore
vendored
Normal file
8
apps/desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
# CLI binary bundled at build time (from server/bin/)
|
||||
resources/bin/
|
||||
24
apps/desktop/build/entitlements.mac.plist
Normal file
24
apps/desktop/build/entitlements.mac.plist
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Electron / V8 need JIT and unsigned executable memory under the
|
||||
hardened runtime. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Required so the app can spawn the bundled `multica` Go binary and
|
||||
any other child processes (e.g. agent CLIs) without Gatekeeper
|
||||
blocking exec. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<!-- Network client — the daemon talks to the backend + GitHub releases. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
apps/desktop/build/icon.icns
Normal file
BIN
apps/desktop/build/icon.icns
Normal file
Binary file not shown.
BIN
apps/desktop/build/icon.ico
Normal file
BIN
apps/desktop/build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
BIN
apps/desktop/build/icon.png
Normal file
BIN
apps/desktop/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
45
apps/desktop/electron-builder.yml
Normal file
45
apps/desktop/electron-builder.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
appId: ai.multica.desktop
|
||||
productName: Multica
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- "!**/.vscode/*"
|
||||
- "!src/*"
|
||||
- "!electron.vite.config.*"
|
||||
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
|
||||
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
|
||||
protocols:
|
||||
- name: Multica
|
||||
schemes:
|
||||
- multica
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
|
||||
# `${name}` produces for scoped package names.
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
|
||||
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
|
||||
# unaffected because `pnpm package` already requires the Developer ID
|
||||
# signing cert — notarization is a strict superset.
|
||||
notarize: true
|
||||
dmg:
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
publish:
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
repo: multica
|
||||
npmRebuild: false
|
||||
29
apps/desktop/electron.vite.config.ts
Normal file
29
apps/desktop/electron.vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
server: {
|
||||
// Allow parallel worktrees to run `pnpm dev:desktop` side-by-side
|
||||
// (e.g. Multica Canary alongside a primary checkout) by overriding
|
||||
// the renderer port via env. Falls back to 5173 for the common case.
|
||||
port: Number(process.env.DESKTOP_RENDERER_PORT) || 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve("src/renderer/src"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
},
|
||||
});
|
||||
13
apps/desktop/eslint.config.mjs
Normal file
13
apps/desktop/eslint.config.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import globals from "globals";
|
||||
import reactConfig from "@multica/eslint-config/react";
|
||||
|
||||
export default [
|
||||
...reactConfig,
|
||||
{ ignores: ["out/", "dist/"] },
|
||||
{
|
||||
files: ["scripts/**/*.{mjs,js}"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
];
|
||||
59
apps/desktop/package.json
Normal file
59
apps/desktop/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@multica/desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
|
||||
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "node scripts/package.mjs",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"electron-updater": "^6.8.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/react": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"jsdom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
BIN
apps/desktop/resources/icon.png
Normal file
BIN
apps/desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 735 KiB |
73
apps/desktop/scripts/brand-dev-electron.mjs
Normal file
73
apps/desktop/scripts/brand-dev-electron.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
// Rebrand the bundled Electron.app's Info.plist so `pnpm dev:desktop`
|
||||
// shows "Multica Canary" in the menu bar, Cmd+Tab switcher, and
|
||||
// Activity Monitor. On macOS these titles come from CFBundleName at
|
||||
// launch time — `app.setName()` cannot override them at runtime, so
|
||||
// patching the plist in node_modules is the only working fix.
|
||||
//
|
||||
// Idempotent: runs on every dev launch and no-ops once the plist already
|
||||
// matches. The patch is isolated to this worktree's node_modules — we
|
||||
// unlink the file before rewriting so we never mutate a pnpm-store inode
|
||||
// shared with another project.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
if (process.platform !== "darwin") process.exit(0);
|
||||
|
||||
const DESIRED_NAME = "Multica Canary";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// `require('electron')` returns the path to the executable
|
||||
// (.../Electron.app/Contents/MacOS/Electron). Walk up to Contents/Info.plist.
|
||||
const electronBin = require("electron");
|
||||
const plistPath = resolve(electronBin, "../../Info.plist");
|
||||
|
||||
function plistGet(key) {
|
||||
try {
|
||||
return execFileSync(
|
||||
"/usr/libexec/PlistBuddy",
|
||||
["-c", `Print :${key}`, plistPath],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
||||
).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function plistSet(key, value) {
|
||||
try {
|
||||
execFileSync("/usr/libexec/PlistBuddy", [
|
||||
"-c",
|
||||
`Set :${key} ${value}`,
|
||||
plistPath,
|
||||
]);
|
||||
} catch {
|
||||
execFileSync("/usr/libexec/PlistBuddy", [
|
||||
"-c",
|
||||
`Add :${key} string ${value}`,
|
||||
plistPath,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
plistGet("CFBundleName") === DESIRED_NAME &&
|
||||
plistGet("CFBundleDisplayName") === DESIRED_NAME
|
||||
) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Break any pnpm hardlink to the global store: read, unlink, rewrite.
|
||||
// PlistBuddy would otherwise write through the hardlink and mutate the
|
||||
// shared store file (and every other project's Electron.app with it).
|
||||
const original = readFileSync(plistPath);
|
||||
unlinkSync(plistPath);
|
||||
writeFileSync(plistPath, original);
|
||||
|
||||
plistSet("CFBundleName", DESIRED_NAME);
|
||||
plistSet("CFBundleDisplayName", DESIRED_NAME);
|
||||
|
||||
console.log(`[brand-dev-electron] ${plistPath} → CFBundleName="${DESIRED_NAME}"`);
|
||||
110
apps/desktop/scripts/bundle-cli.mjs
Normal file
110
apps/desktop/scripts/bundle-cli.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the `multica` CLI from server/cmd/multica and copies the binary
|
||||
// into apps/desktop/resources/bin/ so electron-vite (dev) and electron-
|
||||
// builder (prod) pick it up. Running this on every dev/build/package
|
||||
// invocation guarantees the bundled CLI always matches the current Go
|
||||
// source — no more stale binary surprises. Go's build cache makes the
|
||||
// no-op case (nothing changed) effectively free.
|
||||
//
|
||||
// ldflags mirror `make build` so `multica --version` reports a meaningful
|
||||
// version / commit / date.
|
||||
//
|
||||
// Graceful: if `go` is not installed (e.g. frontend-only contributor), we
|
||||
// skip the build and fall through to auto-install at runtime. A genuine
|
||||
// Go compile error is fatal — you want that to block dev, not hide.
|
||||
|
||||
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..", "..");
|
||||
const serverDir = join(repoRoot, "server");
|
||||
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
const srcBinary = join(serverDir, "bin", binName);
|
||||
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
|
||||
const destBinary = join(destDir, binName);
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function hasGo() {
|
||||
try {
|
||||
execSync("go version", { stdio: "pipe" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await access(p, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasGo()) {
|
||||
const version = sh("git describe --tags --always --dirty") || "dev";
|
||||
const commit = sh("git rev-parse --short HEAD") || "unknown";
|
||||
const date = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
|
||||
|
||||
console.log(
|
||||
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
|
||||
);
|
||||
execFileSync(
|
||||
"go",
|
||||
[
|
||||
"build",
|
||||
"-ldflags",
|
||||
ldflags,
|
||||
"-o",
|
||||
join("bin", binName),
|
||||
"./cmd/multica",
|
||||
],
|
||||
{ cwd: serverDir, stdio: "inherit" },
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[bundle-cli] `go` not found in PATH — skipping CLI build. " +
|
||||
"Desktop will use whatever is already in resources/bin/, or fall back " +
|
||||
"to auto-installing the latest release at runtime.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await exists(srcBinary))) {
|
||||
console.warn(
|
||||
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
|
||||
`auto-installing the latest release at runtime.`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await mkdir(destDir, { recursive: true });
|
||||
await copyFile(srcBinary, destBinary);
|
||||
await chmod(destBinary, 0o755);
|
||||
|
||||
// macOS: ad-hoc sign so Gatekeeper doesn't complain when the parent app
|
||||
// (which itself may be unsigned in dev) spawns the child.
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
execSync(`codesign -s - --force ${JSON.stringify(destBinary)}`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal. Unsigned binaries still run when the parent app is trusted.
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[bundle-cli] bundled ${srcBinary} → ${destBinary}`);
|
||||
161
apps/desktop/scripts/package.mjs
Normal file
161
apps/desktop/scripts/package.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
// Wrapper around `electron-builder` that keeps the Desktop version in
|
||||
// lockstep with the CLI. Both are derived from `git describe --tags
|
||||
// --always --dirty` — the same source GoReleaser reads for the CLI
|
||||
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
|
||||
// produces matching CLI and Desktop versions.
|
||||
//
|
||||
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
|
||||
// into resources/bin/), then `electron-vite build` to produce the
|
||||
// main/preload/renderer bundles under out/, then invokes electron-builder
|
||||
// with `-c.extraMetadata.version=<derived>` so the override applies at
|
||||
// build time without mutating the tracked package.json.
|
||||
//
|
||||
// The electron-vite step is important: electron-builder only packages
|
||||
// whatever is already in out/, so skipping it (or relying on stale
|
||||
// artifacts from a prior partial build) ships an app with missing
|
||||
// renderer code and white-screens on launch.
|
||||
//
|
||||
// Extra CLI args after `pnpm package --` are forwarded to electron-builder
|
||||
// unchanged (e.g. `--mac --arm64`). For an unsigned local smoke-test
|
||||
// build, set `CSC_IDENTITY_AUTO_DISCOVERY=false` so electron-builder falls
|
||||
// back to an ad-hoc signature instead of requiring a Developer ID cert.
|
||||
//
|
||||
// The `normalizeGitVersion` helper is exported so tests can cover the
|
||||
// version-derivation logic without shelling out.
|
||||
|
||||
import { execFileSync, spawnSync, execSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(here, "..");
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the leading `--` that npm/pnpm insert to separate their own
|
||||
* flags from the ones meant for the underlying script. Without this,
|
||||
* `pnpm package -- --mac --arm64 --publish always` forwards the bare
|
||||
* `--` into electron-builder's argv, which terminates option parsing
|
||||
* and turns `--publish always` into ignored positional arguments.
|
||||
*/
|
||||
export function stripLeadingSeparator(argv) {
|
||||
if (argv.length > 0 && argv[0] === "--") return argv.slice(1);
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transformation from the `git describe --tags --always --dirty`
|
||||
* output to the value we feed into electron-builder's extraMetadata.version.
|
||||
*
|
||||
* - empty input → null (caller should fall back)
|
||||
* - "v0.1.36" → "0.1.36"
|
||||
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
|
||||
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
|
||||
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
|
||||
*
|
||||
* Leading `v` is stripped so the result is valid semver for package.json.
|
||||
*/
|
||||
export function normalizeGitVersion(raw) {
|
||||
if (!raw) return null;
|
||||
const stripped = raw.replace(/^v/, "");
|
||||
if (!/^\d/.test(stripped)) {
|
||||
// No reachable tag — `git describe` fell back to just the commit hash.
|
||||
return `0.0.0-${stripped}`;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function deriveVersion() {
|
||||
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Step 1: build + bundle the Go CLI via the existing script.
|
||||
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
// Step 2: build the Electron main/preload/renderer bundles. Without
|
||||
// this step electron-builder silently packages whatever is already in
|
||||
// out/, which on a fresh checkout (or after a partial build) ships an
|
||||
// app that white-screens because the renderer bundle is missing.
|
||||
const viteResult = spawnSync("electron-vite", ["build"], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
if (viteResult.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-vite:",
|
||||
viteResult.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (viteResult.status !== 0) {
|
||||
process.exit(viteResult.status ?? 1);
|
||||
}
|
||||
|
||||
// Step 3: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
} else {
|
||||
console.warn(
|
||||
"[package] could not derive version from git; falling back to package.json",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: assemble electron-builder args.
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// sets `notarize: true` so real releases notarize in-build (keeping the
|
||||
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
|
||||
// who just wants to smoke-test a local package doesn't have Apple
|
||||
// credentials, and would otherwise hit a hard failure at the notarize
|
||||
// step. Detect the missing env and flip notarize off for this run only.
|
||||
if (!process.env.APPLE_TEAM_ID) {
|
||||
console.warn(
|
||||
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
|
||||
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
|
||||
);
|
||||
builderArgs.push("-c.mac.notarize=false");
|
||||
}
|
||||
|
||||
builderArgs.push(...passthrough);
|
||||
|
||||
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// for the script run, so spawnSync finds the binary without needing a
|
||||
// shell wrapper (avoids any risk of argv interpolation).
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Only run when invoked as a CLI, not when imported by a test file.
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
main();
|
||||
}
|
||||
61
apps/desktop/scripts/package.test.mjs
Normal file
61
apps/desktop/scripts/package.test.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
|
||||
|
||||
describe("normalizeGitVersion", () => {
|
||||
it("returns null for empty / nullish input", () => {
|
||||
expect(normalizeGitVersion("")).toBe(null);
|
||||
expect(normalizeGitVersion(null)).toBe(null);
|
||||
expect(normalizeGitVersion(undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("strips the leading v on a clean tag", () => {
|
||||
expect(normalizeGitVersion("v0.1.36")).toBe("0.1.36");
|
||||
expect(normalizeGitVersion("v1.0.0")).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("preserves the prerelease suffix between tags", () => {
|
||||
expect(normalizeGitVersion("v0.1.35-14-gf1415e96")).toBe(
|
||||
"0.1.35-14-gf1415e96",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the dirty suffix on a modified worktree", () => {
|
||||
expect(normalizeGitVersion("v0.1.35-14-gf1415e96-dirty")).toBe(
|
||||
"0.1.35-14-gf1415e96-dirty",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles v-prefixed prerelease tags", () => {
|
||||
expect(normalizeGitVersion("v1.0.0-alpha")).toBe("1.0.0-alpha");
|
||||
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
|
||||
});
|
||||
|
||||
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
|
||||
// `git describe --tags --always` returns just the short commit hash
|
||||
// when there are no tags in the history at all.
|
||||
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
|
||||
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLeadingSeparator", () => {
|
||||
it("removes the leading -- inserted by npm/pnpm", () => {
|
||||
expect(stripLeadingSeparator(["--", "--mac", "--arm64", "--publish", "always"])).toEqual([
|
||||
"--mac", "--arm64", "--publish", "always",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves args untouched when there is no leading --", () => {
|
||||
expect(stripLeadingSeparator(["--mac", "--arm64"])).toEqual(["--mac", "--arm64"]);
|
||||
});
|
||||
|
||||
it("does not strip a -- that appears mid-argv", () => {
|
||||
expect(stripLeadingSeparator(["--mac", "--", "--arm64"])).toEqual([
|
||||
"--mac", "--", "--arm64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an empty array", () => {
|
||||
expect(stripLeadingSeparator([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
173
apps/desktop/src/main/cli-bootstrap.ts
Normal file
173
apps/desktop/src/main/cli-bootstrap.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { app } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { createReadStream, createWriteStream, existsSync } from "fs";
|
||||
import { chmod, mkdir, rename, rm } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { Readable } from "stream";
|
||||
|
||||
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
|
||||
// launch, so users never have to brew-install anything. Build-time decoupled:
|
||||
// we don't bundle the binary into the .app, we download whatever the upstream
|
||||
// release is at first run.
|
||||
|
||||
const GITHUB_LATEST_BASE =
|
||||
"https://github.com/multica-ai/multica/releases/latest/download";
|
||||
|
||||
function platformAssetName(): string {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
if (!os || !arch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
const ext = process.platform === "win32" ? "zip" : "tar.gz";
|
||||
return `multica_${os}_${arch}.${ext}`;
|
||||
}
|
||||
|
||||
function binaryName(): string {
|
||||
return process.platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
|
||||
export function managedCliPath(): string {
|
||||
return join(app.getPath("userData"), "bin", binaryName());
|
||||
}
|
||||
|
||||
function run(cmd: string, args: string[], cwd?: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { cwd }, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, dest: string): Promise<void> {
|
||||
const res = await fetch(url, { redirect: "follow" });
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`download failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
// Node's fetch returns a web ReadableStream; adapt to a Node stream for pipeline.
|
||||
const nodeStream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
await pipeline(nodeStream, createWriteStream(dest));
|
||||
}
|
||||
|
||||
// Fetch goreleaser's published checksums.txt and parse it into a
|
||||
// filename → sha256 lookup. Format is `<hex> <filename>` per line.
|
||||
async function fetchChecksums(): Promise<Map<string, string>> {
|
||||
const url = `${GITHUB_LATEST_BASE}/checksums.txt`;
|
||||
const res = await fetch(url, { redirect: "follow" });
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`checksums.txt fetch failed: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
const text = await res.text();
|
||||
const map = new Map<string, string>();
|
||||
for (const rawLine of text.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const match = line.match(/^([a-f0-9]{64})\s+\*?(\S+)$/i);
|
||||
if (match) map.set(match[2], match[1].toLowerCase());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function sha256OfFile(path: string): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
await pipeline(createReadStream(path), hash);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function verifyChecksum(
|
||||
archivePath: string,
|
||||
assetName: string,
|
||||
): Promise<void> {
|
||||
const checksums = await fetchChecksums();
|
||||
const expected = checksums.get(assetName);
|
||||
if (!expected) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const actual = await sha256OfFile(archivePath);
|
||||
if (actual.toLowerCase() !== expected) {
|
||||
throw new Error(
|
||||
`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArchive(archive: string, dest: string): Promise<void> {
|
||||
await mkdir(dest, { recursive: true });
|
||||
// Modern OSes all ship a `tar` that auto-detects tar.gz and zip:
|
||||
// - macOS/Linux: GNU tar or bsdtar
|
||||
// - Windows 10+: bsdtar is bundled as `tar.exe` since build 17063
|
||||
await run("tar", ["-xf", archive, "-C", dest]);
|
||||
}
|
||||
|
||||
async function installFresh(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
const assetName = platformAssetName();
|
||||
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
|
||||
|
||||
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
|
||||
await mkdir(workDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const archivePath = join(workDir, assetName);
|
||||
console.log(`[cli-bootstrap] downloading ${url}`);
|
||||
await downloadToFile(url, archivePath);
|
||||
|
||||
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
|
||||
await verifyChecksum(archivePath, assetName);
|
||||
|
||||
console.log(`[cli-bootstrap] extracting ${assetName}`);
|
||||
await extractArchive(archivePath, workDir);
|
||||
|
||||
const extractedBin = join(workDir, binaryName());
|
||||
if (!existsSync(extractedBin)) {
|
||||
throw new Error(
|
||||
`archive ${assetName} did not contain ${binaryName()} at its root`,
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await rename(extractedBin, target);
|
||||
await chmod(target, 0o755);
|
||||
|
||||
// macOS: ad-hoc sign so spawning the child never hits a gatekeeper quirk.
|
||||
// Non-fatal: unsigned binaries still execute when the parent app is trusted.
|
||||
if (process.platform === "darwin") {
|
||||
await run("codesign", ["-s", "-", "--force", target]).catch((err) => {
|
||||
console.warn("[cli-bootstrap] ad-hoc codesign failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[cli-bootstrap] installed CLI at ${target}`);
|
||||
return target;
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to a usable `multica` binary. If one is already present at
|
||||
* the managed userData location, returns it immediately. Otherwise downloads
|
||||
* the latest release asset for the current platform and installs it.
|
||||
*/
|
||||
export async function ensureManagedCli(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
if (existsSync(target)) return target;
|
||||
return installFresh();
|
||||
}
|
||||
902
apps/desktop/src/main/daemon-manager.ts
Normal file
902
apps/desktop/src/main/daemon-manager.ts
Normal file
@@ -0,0 +1,902 @@
|
||||
import { app, ipcMain, BrowserWindow } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import {
|
||||
readFile,
|
||||
writeFile,
|
||||
mkdir,
|
||||
rm,
|
||||
open,
|
||||
stat,
|
||||
} from "fs/promises";
|
||||
import {
|
||||
existsSync,
|
||||
watchFile,
|
||||
unwatchFile,
|
||||
type StatsListener,
|
||||
} from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
|
||||
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
|
||||
import { decideVersionAction } from "./version-decision";
|
||||
|
||||
const DEFAULT_HEALTH_PORT = 19514;
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json");
|
||||
const LOG_TAIL_RETRY_MS = 2_000;
|
||||
const LOG_TAIL_MAX_RETRIES = 5;
|
||||
|
||||
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
|
||||
|
||||
interface ActiveProfile {
|
||||
name: string; // "" = default profile
|
||||
port: number;
|
||||
}
|
||||
|
||||
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let logTailWatcher: { path: string; listener: StatsListener } | null = null;
|
||||
let currentState: DaemonStatus["state"] = "installing_cli";
|
||||
let getMainWindow: () => BrowserWindow | null = () => null;
|
||||
let operationInProgress = false;
|
||||
let cachedCliBinary: string | null | undefined = undefined;
|
||||
let cliResolvePromise: Promise<string | null> | null = null;
|
||||
let cachedCliBinaryVersion: string | null | undefined = undefined;
|
||||
// Set when a CLI version mismatch was detected but the running daemon is
|
||||
// busy executing tasks. The poll loop retries the check on each tick and
|
||||
// fires the restart once active_task_count drops to 0.
|
||||
let pendingVersionRestart = false;
|
||||
let targetApiBaseUrl: string | null = null;
|
||||
let activeProfile: ActiveProfile | null = null;
|
||||
|
||||
// Serialize all writes to any profile config file. Multiple paths
|
||||
// (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers)
|
||||
// may try to write concurrently; chaining them avoids interleaved writes
|
||||
// corrupting the JSON.
|
||||
let configWriteChain: Promise<void> = Promise.resolve();
|
||||
|
||||
// Keep the Go impl in sync: server/cmd/multica/cmd_daemon.go healthPortForProfile.
|
||||
function healthPortForProfile(profile: string): number {
|
||||
if (!profile) return DEFAULT_HEALTH_PORT;
|
||||
let sum = 0;
|
||||
for (const b of Buffer.from(profile, "utf-8")) sum += b;
|
||||
return DEFAULT_HEALTH_PORT + 1 + (sum % 1000);
|
||||
}
|
||||
|
||||
function profileDir(profile: string): string {
|
||||
return profile
|
||||
? join(homedir(), ".multica", "profiles", profile)
|
||||
: join(homedir(), ".multica");
|
||||
}
|
||||
|
||||
function profileConfigPath(profile: string): string {
|
||||
return join(profileDir(profile), "config.json");
|
||||
}
|
||||
|
||||
function profileLogPath(profile: string): string {
|
||||
return join(profileDir(profile), "daemon.log");
|
||||
}
|
||||
|
||||
// Sidecar file that records which Multica user the cached PAT in config.json
|
||||
// was minted for. The Go CLI/daemon never read or write this file, so it
|
||||
// survives Go-side config rewrites. Used to detect user switches and mint a
|
||||
// fresh PAT instead of reusing a token that belongs to a previous user.
|
||||
function profileUserIdPath(profile: string): string {
|
||||
return join(profileDir(profile), ".desktop-user-id");
|
||||
}
|
||||
|
||||
async function readProfileUserId(profile: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await readFile(profileUserIdPath(profile), "utf-8");
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeProfileUserId(
|
||||
profile: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await mkdir(profileDir(profile), { recursive: true });
|
||||
await writeFile(profileUserIdPath(profile), userId, "utf-8");
|
||||
}
|
||||
|
||||
async function removeProfileUserId(profile: string): Promise<void> {
|
||||
try {
|
||||
await rm(profileUserIdPath(profile));
|
||||
} catch {
|
||||
// Already gone — nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(u: string): string {
|
||||
if (!u) return "";
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
|
||||
} catch {
|
||||
return u.replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function urlsMatch(a: string, b: string): boolean {
|
||||
const na = normalizeUrl(a);
|
||||
const nb = normalizeUrl(b);
|
||||
return na.length > 0 && na === nb;
|
||||
}
|
||||
|
||||
function sendStatus(status: DaemonStatus): void {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("daemon:status", status);
|
||||
}
|
||||
|
||||
interface HealthPayload {
|
||||
status?: string;
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemon_id?: string;
|
||||
device_name?: string;
|
||||
server_url?: string;
|
||||
cli_version?: string;
|
||||
active_task_count?: number;
|
||||
agents?: string[];
|
||||
workspaces?: unknown[];
|
||||
}
|
||||
|
||||
async function fetchHealthAtPort(
|
||||
port: number,
|
||||
): Promise<HealthPayload | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2_000);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as HealthPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop owns a dedicated CLI profile named after the target API host, so it
|
||||
// never reads or writes the user's hand-configured profiles. Profile dir:
|
||||
// ~/.multica/profiles/desktop-<host>/
|
||||
function deriveProfileName(targetUrl: string): string {
|
||||
try {
|
||||
const url = new URL(targetUrl);
|
||||
const host = url.host.replace(/:/g, "-").toLowerCase();
|
||||
return `desktop-${host}`;
|
||||
} catch {
|
||||
return "desktop";
|
||||
}
|
||||
}
|
||||
|
||||
async function readProfileConfig(
|
||||
profile: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const raw = await readFile(profileConfigPath(profile), "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeProfileConfig(
|
||||
profile: string,
|
||||
cfg: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const op = async () => {
|
||||
await mkdir(profileDir(profile), { recursive: true });
|
||||
await writeFile(
|
||||
profileConfigPath(profile),
|
||||
JSON.stringify(cfg, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
};
|
||||
const next = configWriteChain.catch(() => {}).then(op);
|
||||
configWriteChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Desktop-owned profile for the current target API URL. Creates
|
||||
* the profile's config.json on demand with `server_url` pinned to the target.
|
||||
*
|
||||
* This function never falls back to the default profile, and never touches a
|
||||
* profile whose name doesn't start with `desktop-`, so the user's manually
|
||||
* configured CLI profiles are untouched.
|
||||
*/
|
||||
async function resolveActiveProfile(): Promise<ActiveProfile> {
|
||||
const target = targetApiBaseUrl;
|
||||
if (!target) return { name: "", port: DEFAULT_HEALTH_PORT };
|
||||
|
||||
const name = deriveProfileName(target);
|
||||
const cfg = await readProfileConfig(name);
|
||||
|
||||
if (cfg.server_url !== target) {
|
||||
cfg.server_url = target;
|
||||
await writeProfileConfig(name, cfg);
|
||||
console.log(`[daemon] initialized profile "${name}" → ${target}`);
|
||||
}
|
||||
|
||||
return { name, port: healthPortForProfile(name) };
|
||||
}
|
||||
|
||||
async function ensureActiveProfile(): Promise<ActiveProfile> {
|
||||
if (activeProfile) return activeProfile;
|
||||
activeProfile = await resolveActiveProfile();
|
||||
return activeProfile;
|
||||
}
|
||||
|
||||
function invalidateActiveProfile(): void {
|
||||
activeProfile = null;
|
||||
}
|
||||
|
||||
async function fetchHealth(): Promise<DaemonStatus> {
|
||||
// While the CLI is being downloaded or has permanently failed, short-circuit
|
||||
// polling — there's nothing to probe yet and /health calls would just return
|
||||
// "stopped", which would overwrite the correct setup state in the UI.
|
||||
if (currentState === "installing_cli" || currentState === "cli_not_found") {
|
||||
return { state: currentState };
|
||||
}
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
const data = await fetchHealthAtPort(active.port);
|
||||
|
||||
if (!data || data.status !== "running") {
|
||||
return {
|
||||
state: currentState === "starting" ? "starting" : "stopped",
|
||||
profile: active.name,
|
||||
};
|
||||
}
|
||||
|
||||
// Safety: if we have a target URL and the daemon on our port reports a
|
||||
// different server_url, it's not "our" daemon — drop it and re-resolve.
|
||||
if (
|
||||
targetApiBaseUrl &&
|
||||
data.server_url &&
|
||||
!urlsMatch(data.server_url, targetApiBaseUrl)
|
||||
) {
|
||||
invalidateActiveProfile();
|
||||
return { state: "stopped" };
|
||||
}
|
||||
|
||||
return {
|
||||
state: "running",
|
||||
pid: data.pid,
|
||||
uptime: data.uptime,
|
||||
daemonId: data.daemon_id,
|
||||
deviceName: data.device_name,
|
||||
agents: data.agents ?? [],
|
||||
workspaceCount: Array.isArray(data.workspaces)
|
||||
? data.workspaces.length
|
||||
: 0,
|
||||
profile: active.name,
|
||||
serverUrl: data.server_url,
|
||||
};
|
||||
}
|
||||
|
||||
function findCliOnPath(): string | null {
|
||||
const candidates = process.platform === "win32" ? ["multica.exe"] : ["multica"];
|
||||
const paths = (process.env["PATH"] ?? "").split(
|
||||
process.platform === "win32" ? ";" : ":",
|
||||
);
|
||||
if (process.platform === "darwin") {
|
||||
paths.push("/opt/homebrew/bin", "/usr/local/bin");
|
||||
}
|
||||
for (const name of candidates) {
|
||||
for (const dir of paths) {
|
||||
const full = join(dir, name);
|
||||
if (existsSync(full)) return full;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the CLI binary bundled inside the Desktop app.
|
||||
*
|
||||
* - Dev (`electron-vite dev`): `app.getAppPath()` → `apps/desktop`, resolving
|
||||
* to `apps/desktop/resources/bin/multica`. `bundle-cli.mjs` populates this
|
||||
* before dev starts, so iterating on Go changes is "make build → restart".
|
||||
* - Packaged: `app.getAppPath()` → `<Multica.app>/Contents/Resources/app.asar`.
|
||||
* electron-builder's `asarUnpack: resources/**` extracts the binary to
|
||||
* `app.asar.unpacked/`, so we swap the path segment to execute it.
|
||||
*/
|
||||
function bundledCliPath(): string {
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
return join(app.getAppPath(), "resources", "bin", binName).replace(
|
||||
"app.asar",
|
||||
"app.asar.unpacked",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable `multica` binary path. Priority:
|
||||
* 1. Cached result from a previous successful resolve.
|
||||
* 2. Bundled binary shipped with the Desktop app (`bundle-cli.mjs`).
|
||||
* 3. Managed binary already installed in userData (`managedCliPath`).
|
||||
* 4. Download + install latest release into userData.
|
||||
* 5. `multica` on PATH (dev convenience / user-installed via brew).
|
||||
* Returns `null` only when all of the above fail.
|
||||
*
|
||||
* Bundled is preferred so Desktop iterates in lockstep with Go changes in
|
||||
* the same repo — avoids the 404 / stale-API problem when the Desktop's
|
||||
* TS side is ahead of the last published CLI release.
|
||||
*
|
||||
* This function is idempotent and safe to call concurrently — in-flight
|
||||
* installs are de-duplicated via `cliResolvePromise`.
|
||||
*/
|
||||
async function resolveCliBinary(): Promise<string | null> {
|
||||
if (cachedCliBinary !== undefined) return cachedCliBinary;
|
||||
if (cliResolvePromise) return cliResolvePromise;
|
||||
|
||||
cliResolvePromise = (async () => {
|
||||
const bundled = bundledCliPath();
|
||||
if (existsSync(bundled)) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
return bundled;
|
||||
}
|
||||
|
||||
const managed = managedCliPath();
|
||||
if (existsSync(managed)) {
|
||||
cachedCliBinary = managed;
|
||||
return managed;
|
||||
}
|
||||
|
||||
try {
|
||||
const installed = await ensureManagedCli();
|
||||
cachedCliBinary = installed;
|
||||
return installed;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
|
||||
const onPath = findCliOnPath();
|
||||
cachedCliBinary = onPath;
|
||||
return onPath;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await cliResolvePromise;
|
||||
} finally {
|
||||
cliResolvePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the version of the currently resolved CLI binary by invoking
|
||||
* `multica version --output json`. Cached for the process lifetime — the
|
||||
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
|
||||
* Returns null on any failure (unknown `go` at bundle time, broken binary,
|
||||
* etc.) so callers can fail open.
|
||||
*/
|
||||
async function getCliBinaryVersion(): Promise<string | null> {
|
||||
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) {
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
cachedCliBinaryVersion = parsed.version ?? null;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] failed to read CLI binary version:", err);
|
||||
cachedCliBinaryVersion = null;
|
||||
}
|
||||
return cachedCliBinaryVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the running daemon's `cli_version` against the CLI binary we
|
||||
* would use to spawn a new one, and restarts only when safe. The decision
|
||||
* logic itself is in `version-decision.ts` (pure, unit-tested); this
|
||||
* wrapper handles the async plumbing and side effects.
|
||||
*
|
||||
* Restart is only fired when ALL of:
|
||||
* - a daemon is actually running on the active profile's port
|
||||
* - both sides report a version and the strings differ
|
||||
* - `active_task_count` is 0 (no in-flight agent work would be killed)
|
||||
*
|
||||
* On a confirmed mismatch while the daemon is busy, `pendingVersionRestart`
|
||||
* is set; the poll loop retries this function on each 5s tick and will fire
|
||||
* the restart as soon as the daemon drains.
|
||||
*/
|
||||
async function ensureRunningDaemonVersionMatches(): Promise<
|
||||
"restarted" | "deferred" | "ok" | "not_running"
|
||||
> {
|
||||
const active = await ensureActiveProfile();
|
||||
const running = await fetchHealthAtPort(active.port);
|
||||
const bundled = await getCliBinaryVersion();
|
||||
const action = decideVersionAction(bundled, running);
|
||||
|
||||
switch (action) {
|
||||
case "not_running":
|
||||
pendingVersionRestart = false;
|
||||
return "not_running";
|
||||
case "ok":
|
||||
pendingVersionRestart = false;
|
||||
return "ok";
|
||||
case "defer": {
|
||||
if (!pendingVersionRestart) {
|
||||
const activeTasks = running?.active_task_count ?? 0;
|
||||
console.log(
|
||||
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}); deferring restart until ${activeTasks} active task(s) finish`,
|
||||
);
|
||||
}
|
||||
pendingVersionRestart = true;
|
||||
return "deferred";
|
||||
}
|
||||
case "restart":
|
||||
console.log(
|
||||
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}) — restarting daemon`,
|
||||
);
|
||||
pendingVersionRestart = false;
|
||||
await restartDaemon();
|
||||
return "restarted";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the user's JWT for a long-lived PAT via POST /api/tokens. The
|
||||
* daemon needs a PAT (or `mul_` / `mdt_` token) because JWTs expire in 30
|
||||
* days and signatures are tied to a specific backend instance.
|
||||
*/
|
||||
async function mintPat(jwt: string): Promise<string> {
|
||||
if (!targetApiBaseUrl) {
|
||||
throw new Error("mint PAT: target API URL not set");
|
||||
}
|
||||
const url = `${targetApiBaseUrl.replace(/\/+$/, "")}/api/tokens`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
// Omit expires_in_days → server treats as null → non-expiring PAT.
|
||||
body: JSON.stringify({ name: "Multica Desktop" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { token?: unknown };
|
||||
if (typeof data.token !== "string" || !data.token.startsWith("mul_")) {
|
||||
throw new Error("mint PAT: response missing token");
|
||||
}
|
||||
return data.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the active profile's config.json has a usable token for the daemon.
|
||||
*
|
||||
* - Input from the renderer is the user's JWT (from localStorage) plus the
|
||||
* current user's id, so we can detect session changes.
|
||||
* - If the profile already has a cached PAT (`mul_...`) AND the sidecar user
|
||||
* id matches the caller, reuse it — minting fresh on every launch would
|
||||
* accumulate garbage in the user's tokens page.
|
||||
* - On user mismatch (or first run) call POST /api/tokens with the JWT to
|
||||
* mint a fresh PAT, overwriting any stale cached PAT. This is the critical
|
||||
* path: without it, a previous user's PAT would be used by a new session.
|
||||
* - If the caller happens to pass a PAT directly, write it through.
|
||||
* - When we mint fresh and a daemon is already running, restart it so the
|
||||
* new credentials take effect (the Go daemon reads config at startup).
|
||||
*/
|
||||
async function syncToken(
|
||||
tokenFromRenderer: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const active = await ensureActiveProfile();
|
||||
const config = await readProfileConfig(active.name);
|
||||
const previousUserId = await readProfileUserId(active.name);
|
||||
const userChanged = Boolean(previousUserId) && previousUserId !== userId;
|
||||
const sameUserWithCachedPat =
|
||||
!userChanged &&
|
||||
previousUserId === userId &&
|
||||
typeof config.token === "string" &&
|
||||
config.token.startsWith("mul_");
|
||||
|
||||
let finalToken: string;
|
||||
if (tokenFromRenderer.startsWith("mul_")) {
|
||||
finalToken = tokenFromRenderer;
|
||||
} else if (sameUserWithCachedPat) {
|
||||
finalToken = config.token as string;
|
||||
} else {
|
||||
try {
|
||||
finalToken = await mintPat(tokenFromRenderer);
|
||||
console.log(
|
||||
`[daemon] minted PAT for profile "${active.name}" (user_changed=${userChanged})`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[daemon] failed to mint PAT:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
config.token = finalToken;
|
||||
if (targetApiBaseUrl) config.server_url = targetApiBaseUrl;
|
||||
await writeProfileConfig(active.name, config);
|
||||
await writeProfileUserId(active.name, userId);
|
||||
|
||||
// If we just rotated credentials onto a running daemon, restart it so the
|
||||
// in-memory token in the Go process matches the new config.
|
||||
if (userChanged) {
|
||||
try {
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (existing?.status === "running") {
|
||||
console.log(
|
||||
"[daemon] user switched — restarting daemon with new credentials",
|
||||
);
|
||||
void restartDaemon();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[daemon] restart-on-user-switch failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrefs(): Promise<DaemonPrefs> {
|
||||
try {
|
||||
const raw = await readFile(PREFS_PATH, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...DEFAULT_PREFS, ...parsed };
|
||||
} catch {
|
||||
return { ...DEFAULT_PREFS };
|
||||
}
|
||||
}
|
||||
|
||||
async function savePrefs(prefs: DaemonPrefs): Promise<void> {
|
||||
const dir = join(homedir(), ".multica");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(PREFS_PATH, JSON.stringify(prefs, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
async function clearToken(): Promise<void> {
|
||||
const active = await ensureActiveProfile();
|
||||
const config = await readProfileConfig(active.name);
|
||||
if ("token" in config) {
|
||||
delete config.token;
|
||||
await writeProfileConfig(active.name, config);
|
||||
}
|
||||
// Always drop the sidecar so a subsequent syncToken from any user is
|
||||
// treated as a fresh mint, not a reuse of a stale cached PAT.
|
||||
await removeProfileUserId(active.name);
|
||||
}
|
||||
|
||||
async function withGuard<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
|
||||
if (operationInProgress) {
|
||||
return { success: false, error: "Another daemon operation is in progress" };
|
||||
}
|
||||
operationInProgress = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
operationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
function profileArgs(active: ActiveProfile): string[] {
|
||||
return active.name ? ["--profile", active.name] : [];
|
||||
}
|
||||
|
||||
// Env passed to every CLI child so the daemon process knows it was spawned
|
||||
// by the Desktop app. The server uses this to mark runtimes as managed and
|
||||
// hide CLI self-update UI. Computed lazily so it picks up the PATH fix
|
||||
// applied by fix-path in main/index.ts — as a top-level const it would
|
||||
// snapshot process.env at import time, before that block runs.
|
||||
function desktopSpawnEnv(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, MULTICA_LAUNCHED_BY: "desktop" };
|
||||
}
|
||||
|
||||
async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return { success: false, error: "multica CLI is not installed" };
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (existing?.status === "running") {
|
||||
pollOnce();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
currentState = "starting";
|
||||
sendStatus({ state: "starting" });
|
||||
|
||||
const args = ["daemon", "start", ...profileArgs(active)];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
bin,
|
||||
args,
|
||||
{ timeout: 20_000, env: desktopSpawnEnv() },
|
||||
(err) => {
|
||||
if (err) {
|
||||
currentState = "stopped";
|
||||
sendStatus({ state: "stopped" });
|
||||
resolve({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
// Stay in "starting" until pollOnce confirms /health — the CLI
|
||||
// returning 0 only means the supervisor was spawned, not that the
|
||||
// daemon process is already listening.
|
||||
pollOnce();
|
||||
resolve({ success: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return { success: false, error: "multica CLI is not installed" };
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
currentState = "stopping";
|
||||
sendStatus({ state: "stopping" });
|
||||
|
||||
const args = ["daemon", "stop", ...profileArgs(active)];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(bin, args, { timeout: 15_000 }, (err) => {
|
||||
if (err) {
|
||||
resolve({ success: false, error: err.message });
|
||||
} else {
|
||||
resolve({ success: true });
|
||||
}
|
||||
currentState = "stopped";
|
||||
sendStatus({ state: "stopped" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const stopResult = await stopDaemon();
|
||||
if (!stopResult.success) return stopResult;
|
||||
return startDaemon();
|
||||
}
|
||||
|
||||
async function pollOnce(): Promise<void> {
|
||||
const status = await fetchHealth();
|
||||
currentState = status.state;
|
||||
sendStatus(status);
|
||||
// Retry a deferred version-mismatch restart once the daemon drains.
|
||||
if (pendingVersionRestart && status.state === "running") {
|
||||
void ensureRunningDaemonVersionMatches();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
if (statusPollTimer) return;
|
||||
pollOnce();
|
||||
statusPollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the CLI binary is available, then transitions into the normal
|
||||
* stopped/running state machine. Called once at startup and again on
|
||||
* user-triggered `daemon:retry-install`.
|
||||
*/
|
||||
async function bootstrapCli(): Promise<void> {
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) {
|
||||
currentState = "cli_not_found";
|
||||
sendStatus({ state: "cli_not_found" });
|
||||
return;
|
||||
}
|
||||
currentState = "stopped";
|
||||
sendStatus({ state: "stopped" });
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (statusPollTimer) {
|
||||
clearInterval(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const LOG_TAIL_INITIAL_WINDOW_BYTES = 32 * 1024;
|
||||
const LOG_TAIL_INITIAL_LINES = 200;
|
||||
const LOG_TAIL_POLL_MS = 500;
|
||||
|
||||
async function readLogRange(
|
||||
path: string,
|
||||
startAt: number,
|
||||
length: number,
|
||||
): Promise<string> {
|
||||
const handle = await open(path, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, startAt);
|
||||
return buffer.subarray(0, bytesRead).toString("utf-8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function sendLines(win: BrowserWindow, text: string): void {
|
||||
const lines = text.split("\n").filter((line) => line.length > 0);
|
||||
for (const line of lines) {
|
||||
win.webContents.send("daemon:log-line", line);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-platform tail -f replacement: read the tail of the file once, then
|
||||
// poll its stat with fs.watchFile and forward any new bytes since the last
|
||||
// known offset. watchFile works on macOS, Linux, and Windows; spawn("tail")
|
||||
// would silently fail on Windows.
|
||||
function startLogTail(win: BrowserWindow, retryCount = 0): void {
|
||||
stopLogTail();
|
||||
|
||||
void ensureActiveProfile().then(async (active) => {
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
if (retryCount < LOG_TAIL_MAX_RETRIES) {
|
||||
setTimeout(() => startLogTail(win, retryCount + 1), LOG_TAIL_RETRY_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let position = 0;
|
||||
try {
|
||||
const initialStats = await stat(logPath);
|
||||
const windowBytes = Math.min(
|
||||
initialStats.size,
|
||||
LOG_TAIL_INITIAL_WINDOW_BYTES,
|
||||
);
|
||||
const startAt = initialStats.size - windowBytes;
|
||||
if (windowBytes > 0) {
|
||||
const text = await readLogRange(logPath, startAt, windowBytes);
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-LOG_TAIL_INITIAL_LINES);
|
||||
for (const line of lines) {
|
||||
win.webContents.send("daemon:log-line", line);
|
||||
}
|
||||
}
|
||||
position = initialStats.size;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] log tail initial read failed:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const listener: StatsListener = (curr) => {
|
||||
const target = getMainWindow();
|
||||
if (!target) return;
|
||||
// File rotated/truncated — restart from the new beginning.
|
||||
if (curr.size < position) position = 0;
|
||||
if (curr.size === position) return;
|
||||
const from = position;
|
||||
const length = curr.size - from;
|
||||
position = curr.size;
|
||||
readLogRange(logPath, from, length)
|
||||
.then((text) => sendLines(target, text))
|
||||
.catch((err) => {
|
||||
console.warn("[daemon] log tail read failed:", err);
|
||||
});
|
||||
};
|
||||
|
||||
watchFile(logPath, { interval: LOG_TAIL_POLL_MS }, listener);
|
||||
logTailWatcher = { path: logPath, listener };
|
||||
});
|
||||
}
|
||||
|
||||
function stopLogTail(): void {
|
||||
if (logTailWatcher) {
|
||||
unwatchFile(logTailWatcher.path, logTailWatcher.listener);
|
||||
logTailWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupDaemonManager(
|
||||
windowGetter: () => BrowserWindow | null,
|
||||
): void {
|
||||
getMainWindow = windowGetter;
|
||||
|
||||
ipcMain.handle("daemon:set-target-api-url", async (_e, url: string) => {
|
||||
const normalized = url || null;
|
||||
if (targetApiBaseUrl !== normalized) {
|
||||
console.log(`[daemon] target API URL set to ${normalized ?? "(none)"}`);
|
||||
targetApiBaseUrl = normalized;
|
||||
invalidateActiveProfile();
|
||||
await pollOnce();
|
||||
}
|
||||
});
|
||||
ipcMain.handle("daemon:start", () => withGuard(() => startDaemon()));
|
||||
ipcMain.handle("daemon:stop", () => withGuard(() => stopDaemon()));
|
||||
ipcMain.handle("daemon:restart", () => withGuard(() => restartDaemon()));
|
||||
ipcMain.handle("daemon:get-status", () => fetchHealth());
|
||||
ipcMain.handle(
|
||||
"daemon:sync-token",
|
||||
(_event, token: string, userId: string) => syncToken(token, userId),
|
||||
);
|
||||
ipcMain.handle("daemon:clear-token", () => clearToken());
|
||||
ipcMain.handle("daemon:is-cli-installed", async () => {
|
||||
const bin = await resolveCliBinary();
|
||||
return bin !== null;
|
||||
});
|
||||
ipcMain.handle("daemon:retry-install", async () => {
|
||||
cachedCliBinary = undefined;
|
||||
cliResolvePromise = null;
|
||||
// A retry-install may land a new CLI at a different version; drop the
|
||||
// cached version string so the next check re-reads the binary.
|
||||
cachedCliBinaryVersion = undefined;
|
||||
await bootstrapCli();
|
||||
});
|
||||
ipcMain.handle("daemon:get-prefs", () => loadPrefs());
|
||||
ipcMain.handle(
|
||||
"daemon:set-prefs",
|
||||
(_event, prefs: Partial<DaemonPrefs>) =>
|
||||
loadPrefs().then((cur) => {
|
||||
const merged = { ...cur, ...prefs };
|
||||
return savePrefs(merged).then(() => merged);
|
||||
}),
|
||||
);
|
||||
ipcMain.handle("daemon:auto-start", async () => {
|
||||
const prefs = await loadPrefs();
|
||||
if (!prefs.autoStart) return;
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return;
|
||||
const health = await fetchHealth();
|
||||
if (health.state === "running") {
|
||||
// Daemon is up but may be running an older CLI than the one we just
|
||||
// bundled. Restart it so the new binary actually takes effect.
|
||||
await ensureRunningDaemonVersionMatches();
|
||||
return;
|
||||
}
|
||||
await startDaemon();
|
||||
});
|
||||
|
||||
ipcMain.on("daemon:start-log-stream", () => {
|
||||
const win = getMainWindow();
|
||||
if (win) startLogTail(win);
|
||||
});
|
||||
|
||||
ipcMain.on("daemon:stop-log-stream", () => {
|
||||
stopLogTail();
|
||||
});
|
||||
|
||||
// First-run CLI install kicks off here. Status bar shows "Setting up…"
|
||||
// until the managed binary is on disk (instant on subsequent launches).
|
||||
currentState = "installing_cli";
|
||||
sendStatus({ state: "installing_cli" });
|
||||
void bootstrapCli();
|
||||
|
||||
let isQuitting = false;
|
||||
app.on("before-quit", (event) => {
|
||||
if (isQuitting) return;
|
||||
stopPolling();
|
||||
stopLogTail();
|
||||
|
||||
loadPrefs().then(async (prefs) => {
|
||||
if (prefs.autoStop) {
|
||||
isQuitting = true;
|
||||
event.preventDefault();
|
||||
try {
|
||||
await stopDaemon();
|
||||
} catch {
|
||||
// Best-effort stop on quit
|
||||
}
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
216
apps/desktop/src/main/index.ts
Normal file
216
apps/desktop/src/main/index.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, nativeImage } from "electron";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
// by the `is.dev` branch below.
|
||||
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
|
||||
|
||||
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
|
||||
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
|
||||
// Run the user's login shell once to recover the real PATH so the bundled
|
||||
// multica CLI can find agent binaries like claude/codex/opencode. Must run
|
||||
// before any child_process.spawn / execFile call in the main process —
|
||||
// ES module imports are hoisted, so this block executes before createWindow
|
||||
// or any daemon-manager spawn.
|
||||
if (process.platform !== "win32") {
|
||||
fixPath();
|
||||
// Fallback: prepend common install locations in case fix-path came up
|
||||
// short (broken shell rc, non-interactive $SHELL, missing entries). Safe
|
||||
// to duplicate — PATH lookups short-circuit on first match.
|
||||
const fallbackPaths = [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
join(homedir(), ".local/bin"),
|
||||
];
|
||||
process.env.PATH = `${fallbackPaths.join(":")}:${process.env.PATH ?? ""}`;
|
||||
}
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
// --- Deep link helpers ---------------------------------------------------
|
||||
|
||||
function handleDeepLink(url: string): void {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== `${PROTOCOL}:`) return;
|
||||
|
||||
// multica://auth/callback?token=<jwt>
|
||||
if (parsed.hostname === "auth" && parsed.pathname === "/callback") {
|
||||
const token = parsed.searchParams.get("token");
|
||||
if (token && mainWindow) {
|
||||
mainWindow.webContents.send("auth:token", token);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs
|
||||
}
|
||||
}
|
||||
|
||||
// --- Window creation -----------------------------------------------------
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 16, y: 13 },
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// Windows/Linux pick up the window/taskbar icon from this option in
|
||||
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
|
||||
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Strip Origin header from WebSocket upgrade requests so the server's
|
||||
// origin whitelist doesn't reject connections from localhost dev origins.
|
||||
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ["wss://*/*", "ws://*/*"] },
|
||||
(details, callback) => {
|
||||
delete details.requestHeaders["Origin"];
|
||||
callback({ requestHeaders: details.requestHeaders });
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.on("ready-to-show", () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dev / production isolation -------------------------------------------
|
||||
// Give dev mode a separate app name and userData path so it gets its own
|
||||
// single-instance lock file and doesn't conflict with the packaged production
|
||||
// app. Must run BEFORE requestSingleInstanceLock() because the lock location
|
||||
// is derived from the userData path. (Same approach VS Code uses for
|
||||
// Stable / Insiders coexistence.)
|
||||
|
||||
// DESKTOP_APP_SUFFIX lets parallel worktrees run dev Electron side-by-side
|
||||
// without fighting for the shared single-instance lock. The suffix is
|
||||
// appended to the app name + userData path, so each worktree gets its own
|
||||
// lock file. Default (no env var) keeps behavior unchanged — the common
|
||||
// single-worktree case still lands at "Multica Canary".
|
||||
const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
|
||||
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
|
||||
: "Multica Canary";
|
||||
|
||||
if (is.dev) {
|
||||
app.setName(DEV_APP_NAME);
|
||||
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
|
||||
}
|
||||
|
||||
// --- Protocol registration -----------------------------------------------
|
||||
|
||||
if (process.defaultApp) {
|
||||
// In dev, register with the path to the electron binary + app path
|
||||
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
|
||||
app.getAppPath(),
|
||||
]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(PROTOCOL);
|
||||
}
|
||||
|
||||
// --- Single instance lock ------------------------------------------------
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
// Windows/Linux: second instance passes deep link via argv
|
||||
app.on("second-instance", (_event, argv) => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
// On Windows the deep link URL is the last argv entry
|
||||
const deepLinkUrl = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`));
|
||||
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId(
|
||||
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
|
||||
);
|
||||
|
||||
// macOS: replace the default Electron dock icon with the bundled logo
|
||||
// so the Canary dev build is visually distinct from a stock Electron
|
||||
// run. `app.dock` is macOS-only — guard the call.
|
||||
if (is.dev && process.platform === "darwin" && app.dock) {
|
||||
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
|
||||
if (!icon.isEmpty()) app.dock.setIcon(icon);
|
||||
}
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// IPC: open URL in default browser (used by renderer for Google login)
|
||||
ipcMain.handle("shell:openExternal", (_event, url: string) => {
|
||||
return shell.openExternal(url);
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
|
||||
if (process.platform !== "darwin") return;
|
||||
mainWindow?.setWindowButtonVisibility(!immersive);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
setupDaemonManager(() => mainWindow);
|
||||
|
||||
// macOS: deep link arrives via open-url event
|
||||
app.on("open-url", (_event, url) => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
}
|
||||
handleDeepLink(url);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
// Check argv for deep link on cold start (Windows/Linux)
|
||||
const deepLinkArg = process.argv.find((arg) =>
|
||||
arg.startsWith(`${PROTOCOL}://`),
|
||||
);
|
||||
if (deepLinkArg) {
|
||||
app.whenReady().then(() => handleDeepLink(deepLinkArg));
|
||||
}
|
||||
}
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
46
apps/desktop/src/main/updater.ts
Normal file
46
apps/desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:download-progress", {
|
||||
percent: progress.percent,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded");
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("Auto-updater error:", err);
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:download", () => {
|
||||
return autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:install", () => {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
// Check for updates after a short delay to avoid blocking startup
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
88
apps/desktop/src/main/version-decision.test.ts
Normal file
88
apps/desktop/src/main/version-decision.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideVersionAction } from "./version-decision";
|
||||
|
||||
describe("decideVersionAction", () => {
|
||||
it("returns not_running when health payload is null", () => {
|
||||
expect(decideVersionAction("v1.0.0", null)).toBe("not_running");
|
||||
});
|
||||
|
||||
it("returns not_running when status is not 'running'", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.0.0", { status: "stopped", cli_version: "v1.0.0" }),
|
||||
).toBe("not_running");
|
||||
});
|
||||
|
||||
it("returns ok when bundled version is unknown (fail safe)", () => {
|
||||
expect(
|
||||
decideVersionAction(null, {
|
||||
status: "running",
|
||||
cli_version: "v1.0.0",
|
||||
active_task_count: 0,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns ok when running daemon does not report cli_version (older daemon)", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.0.0", {
|
||||
status: "running",
|
||||
active_task_count: 0,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns ok when versions match exactly", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.3",
|
||||
active_task_count: 5,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns restart when versions differ and daemon is idle", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.2",
|
||||
active_task_count: 0,
|
||||
}),
|
||||
).toBe("restart");
|
||||
});
|
||||
|
||||
it("treats missing active_task_count as 0 (old daemon that still reports cli_version)", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.2",
|
||||
}),
|
||||
).toBe("restart");
|
||||
});
|
||||
|
||||
it("returns defer when versions differ but daemon is busy", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.2",
|
||||
active_task_count: 2,
|
||||
}),
|
||||
).toBe("defer");
|
||||
});
|
||||
|
||||
it("transitions defer → restart as tasks drain", () => {
|
||||
// Same bundled version across three observations while the daemon ages.
|
||||
const bundled = "v2.0.0";
|
||||
const base = { status: "running", cli_version: "v1.9.0" } as const;
|
||||
|
||||
expect(
|
||||
decideVersionAction(bundled, { ...base, active_task_count: 3 }),
|
||||
).toBe("defer");
|
||||
expect(
|
||||
decideVersionAction(bundled, { ...base, active_task_count: 1 }),
|
||||
).toBe("defer");
|
||||
expect(
|
||||
decideVersionAction(bundled, { ...base, active_task_count: 0 }),
|
||||
).toBe("restart");
|
||||
});
|
||||
});
|
||||
37
apps/desktop/src/main/version-decision.ts
Normal file
37
apps/desktop/src/main/version-decision.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Pure decision logic for the daemon version-check flow. Kept in its own
|
||||
// module so it can be unit-tested without mocking Electron, execFile, or
|
||||
// the HTTP health probe.
|
||||
|
||||
export interface VersionCheckHealth {
|
||||
status?: string;
|
||||
cli_version?: string;
|
||||
active_task_count?: number;
|
||||
}
|
||||
|
||||
export type VersionAction = "restart" | "defer" | "ok" | "not_running";
|
||||
|
||||
/**
|
||||
* Decides what the daemon-manager should do given the currently-resolved
|
||||
* bundled CLI version and the latest /health payload.
|
||||
*
|
||||
* not_running: no daemon is up, nothing to do
|
||||
* ok: versions match, OR either side is unknown (fail safe)
|
||||
* defer: versions differ but the daemon is busy — wait for drain
|
||||
* restart: versions differ and the daemon is idle — safe to restart
|
||||
*
|
||||
* Pure function: no I/O, no side effects, no module state.
|
||||
*/
|
||||
export function decideVersionAction(
|
||||
bundled: string | null,
|
||||
running: VersionCheckHealth | null,
|
||||
): VersionAction {
|
||||
if (!running || running.status !== "running") return "not_running";
|
||||
|
||||
const runningVersion = running.cli_version;
|
||||
if (!bundled || !runningVersion) return "ok";
|
||||
if (runningVersion === bundled) return "ok";
|
||||
|
||||
const activeTasks = running.active_task_count ?? 0;
|
||||
if (activeTasks > 0) return "defer";
|
||||
return "restart";
|
||||
}
|
||||
65
apps/desktop/src/preload/index.d.ts
vendored
Normal file
65
apps/desktop/src/preload/index.d.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Open a URL in the default browser. */
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
setImmersiveMode: (immersive: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
agents?: string[];
|
||||
workspaceCount?: number;
|
||||
profile?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
interface DaemonPrefs {
|
||||
autoStart: boolean;
|
||||
autoStop: boolean;
|
||||
}
|
||||
|
||||
interface DaemonAPI {
|
||||
start: () => Promise<{ success: boolean; error?: string }>;
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
restart: () => Promise<{ success: boolean; error?: string }>;
|
||||
getStatus: () => Promise<DaemonStatus>;
|
||||
onStatusChange: (callback: (status: DaemonStatus) => void) => () => void;
|
||||
setTargetApiUrl: (url: string) => Promise<void>;
|
||||
syncToken: (token: string, userId: string) => Promise<void>;
|
||||
clearToken: () => Promise<void>;
|
||||
isCliInstalled: () => Promise<boolean>;
|
||||
getPrefs: () => Promise<DaemonPrefs>;
|
||||
setPrefs: (prefs: Partial<DaemonPrefs>) => Promise<DaemonPrefs>;
|
||||
autoStart: () => Promise<void>;
|
||||
retryInstall: () => Promise<void>;
|
||||
startLogStream: () => void;
|
||||
stopLogStream: () => void;
|
||||
onLogLine: (callback: (line: string) => void) => () => void;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
desktopAPI: DesktopAPI;
|
||||
daemonAPI: DaemonAPI;
|
||||
updater: UpdaterAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
106
apps/desktop/src/preload/index.ts
Normal file
106
apps/desktop/src/preload/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
const desktopAPI = {
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
callback(token);
|
||||
ipcRenderer.on("auth:token", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("auth:token", handler);
|
||||
};
|
||||
},
|
||||
/** Open a URL in the default browser */
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
setImmersiveMode: (immersive: boolean) =>
|
||||
ipcRenderer.invoke("window:setImmersive", immersive),
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
agents?: string[];
|
||||
workspaceCount?: number;
|
||||
profile?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
const daemonAPI = {
|
||||
start: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:start"),
|
||||
stop: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:stop"),
|
||||
restart: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:restart"),
|
||||
getStatus: (): Promise<DaemonStatus> =>
|
||||
ipcRenderer.invoke("daemon:get-status"),
|
||||
onStatusChange: (callback: (status: DaemonStatus) => void) => {
|
||||
const handler = (_: unknown, status: DaemonStatus) => callback(status);
|
||||
ipcRenderer.on("daemon:status", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:status", handler);
|
||||
},
|
||||
setTargetApiUrl: (url: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:set-target-api-url", url),
|
||||
syncToken: (token: string, userId: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:sync-token", token, userId),
|
||||
clearToken: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:clear-token"),
|
||||
isCliInstalled: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("daemon:is-cli-installed"),
|
||||
getPrefs: (): Promise<{ autoStart: boolean; autoStop: boolean }> =>
|
||||
ipcRenderer.invoke("daemon:get-prefs"),
|
||||
setPrefs: (prefs: Partial<{ autoStart: boolean; autoStop: boolean }>): Promise<{ autoStart: boolean; autoStop: boolean }> =>
|
||||
ipcRenderer.invoke("daemon:set-prefs", prefs),
|
||||
autoStart: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:auto-start"),
|
||||
retryInstall: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:retry-install"),
|
||||
startLogStream: () => ipcRenderer.send("daemon:start-log-stream"),
|
||||
stopLogStream: () => ipcRenderer.send("daemon:stop-log-stream"),
|
||||
onLogLine: (callback: (line: string) => void) => {
|
||||
const handler = (_: unknown, line: string) => callback(line);
|
||||
ipcRenderer.on("daemon:log-line", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:log-line", handler);
|
||||
},
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => {
|
||||
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) => callback(info);
|
||||
ipcRenderer.on("updater:update-available", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-available", handler);
|
||||
},
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => {
|
||||
const handler = (_: unknown, progress: { percent: number }) => callback(progress);
|
||||
ipcRenderer.on("updater:download-progress", handler);
|
||||
return () => ipcRenderer.removeListener("updater:download-progress", handler);
|
||||
},
|
||||
onUpdateDownloaded: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("updater:update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
|
||||
},
|
||||
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("updater:install"),
|
||||
};
|
||||
|
||||
if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld("electron", electronAPI);
|
||||
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
|
||||
contextBridge.exposeInMainWorld("daemonAPI", daemonAPI);
|
||||
contextBridge.exposeInMainWorld("updater", updaterAPI);
|
||||
} else {
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.electron = electronAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.desktopAPI = desktopAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.daemonAPI = daemonAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.updater = updaterAPI;
|
||||
}
|
||||
12
apps/desktop/src/renderer/index.html
Normal file
12
apps/desktop/src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multica</title>
|
||||
</head>
|
||||
<body class="h-full overflow-hidden antialiased font-sans">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
167
apps/desktop/src/renderer/src/App.tsx
Normal file
167
apps/desktop/src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const qc = useQueryClient();
|
||||
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
|
||||
// setQueryData sequentially. loginWithToken sets user+isLoading=false
|
||||
// as soon as getMe resolves, which would cause DesktopShell to mount
|
||||
// before the workspace list is hydrated and briefly see `!workspace`.
|
||||
// This local flag keeps the loading screen up until the whole chain
|
||||
// finishes, so IndexRedirect gets a definitive workspace state on
|
||||
// first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
useEffect(() => {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
|
||||
// daemonAPI.syncToken is handled separately by the [user] effect below, which
|
||||
// fires whenever a user logs in (deep link, session restore, account switch).
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onAuthToken(async (token) => {
|
||||
setBootstrapping(true);
|
||||
try {
|
||||
await useAuthStore.getState().loginWithToken(token);
|
||||
// Seed React Query cache with the workspace list so the index-route
|
||||
// redirect (routes.tsx `IndexRedirect`) can resolve the initial
|
||||
// destination without a second fetch. Workspace side-effects
|
||||
// (setCurrentWorkspace, persist namespace) are synced later by
|
||||
// WorkspaceRouteLayout when the URL resolves.
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
} catch {
|
||||
// Token invalid or expired — user stays on login page
|
||||
} finally {
|
||||
setBootstrapping(false);
|
||||
}
|
||||
});
|
||||
}, [qc]);
|
||||
|
||||
// Sync token and start the daemon whenever the user logs in.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
const userId = user.id;
|
||||
(async () => {
|
||||
try {
|
||||
await window.daemonAPI.syncToken(token, userId);
|
||||
await window.daemonAPI.autoStart();
|
||||
} catch (err) {
|
||||
console.error("Failed to sync daemon on login", err);
|
||||
}
|
||||
})();
|
||||
}, [user]);
|
||||
|
||||
// When a user who started the session with zero workspaces creates their
|
||||
// first one, restart the daemon so it picks up the new workspace
|
||||
// immediately (otherwise workspaceSyncLoop's next 30s tick would be the
|
||||
// earliest pickup point). Specifically scoped to "started empty" because
|
||||
// account switches (user A logout → user B login) should not trigger a
|
||||
// daemon restart here — daemon-manager already restarts on user change
|
||||
// via syncToken.
|
||||
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
const wsCount = workspaces?.length ?? 0;
|
||||
|
||||
// Validate persisted tab paths against the current user's workspace list.
|
||||
// Tabs survive across app restarts and account switches (persisted to
|
||||
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
|
||||
// reference a workspace the current user can't access — showing
|
||||
// NoAccessPage every time they open the app.
|
||||
//
|
||||
// Run synchronously in render phase rather than in useEffect so the first
|
||||
// render already sees validated tabs. useEffect runs AFTER commit, which
|
||||
// means the initial render would briefly show NoAccessPage before the
|
||||
// effect resets the tab. Zustand supports render-phase setState; the
|
||||
// validator is idempotent (exits early if nothing changed) so this
|
||||
// doesn't loop.
|
||||
if (workspaces) {
|
||||
const validSlugs = new Set(workspaces.map((w) => w.slug));
|
||||
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
|
||||
}
|
||||
// null = undecided (pre-login or list hasn't settled yet)
|
||||
// true = session started with zero workspaces; next transition to >=1 triggers restart
|
||||
// false = session started with >=1 workspace, OR we've already restarted; skip
|
||||
const sessionStartedEmptyRef = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
sessionStartedEmptyRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!workspaceListFetched) return;
|
||||
if (sessionStartedEmptyRef.current === null) {
|
||||
sessionStartedEmptyRef.current = wsCount === 0;
|
||||
return;
|
||||
}
|
||||
if (sessionStartedEmptyRef.current && wsCount >= 1) {
|
||||
void window.daemonAPI.restart();
|
||||
sessionStartedEmptyRef.current = false;
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount]);
|
||||
|
||||
if (isLoading || bootstrapping) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return <DesktopLoginPage />;
|
||||
return <DesktopShell />;
|
||||
}
|
||||
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, clear any cached PAT and stop the daemon so that a subsequent
|
||||
// login as a different user never inherits the previous user's credentials.
|
||||
async function handleDaemonLogout() {
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
// Best-effort — clearing is followed by stop which also hardens state.
|
||||
}
|
||||
try {
|
||||
await window.daemonAPI.stop();
|
||||
} catch {
|
||||
// Daemon may already be stopped.
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
<UpdateNotification />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
309
apps/desktop/src/renderer/src/components/daemon-panel.tsx
Normal file
309
apps/desktop/src/renderer/src/components/daemon-panel.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet";
|
||||
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
interface DaemonPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
status: DaemonStatus;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_COLORS: Record<string, string> = {
|
||||
INFO: "text-info",
|
||||
WARN: "text-warning",
|
||||
ERROR: "text-destructive",
|
||||
DEBUG: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function colorizeLogLine(line: string): { level: string; className: string } {
|
||||
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
|
||||
if (line.includes(level)) return { level, className };
|
||||
}
|
||||
return { level: "", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-right text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ state }: { state: DaemonState }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
line: string;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
let logIdCounter = 0;
|
||||
|
||||
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
window.daemonAPI.startLogStream();
|
||||
const unsub = window.daemonAPI.onLogLine((line) => {
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, { id: ++logIdCounter, line }];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
window.daemonAPI.stopLogStream();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleLogScroll = useCallback(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
setAutoScroll(atBottom);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
Local Daemon
|
||||
</SheetTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label="Close"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
|
||||
<div className="shrink-0 space-y-4">
|
||||
{/* Status info */}
|
||||
<div className="rounded-lg border p-3 space-y-0.5">
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot state={status.state} />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
|
||||
<InfoRow label="Profile" value={status.profile || "default"} />
|
||||
{status.serverUrl && (
|
||||
<InfoRow
|
||||
label="Server"
|
||||
value={
|
||||
<span className="font-mono text-xs" title={status.serverUrl}>
|
||||
{status.serverUrl}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.agents && status.agents.length > 0 && (
|
||||
<InfoRow label="Agents" value={status.agents.join(", ")} />
|
||||
)}
|
||||
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
|
||||
{status.daemonId && (
|
||||
<InfoRow
|
||||
label="Daemon ID"
|
||||
value={<span className="font-mono text-xs">{status.daemonId}</span>}
|
||||
/>
|
||||
)}
|
||||
{typeof status.workspaceCount === "number" && (
|
||||
<InfoRow label="Workspaces" value={status.workspaceCount} />
|
||||
)}
|
||||
{status.pid && (
|
||||
<InfoRow
|
||||
label="PID"
|
||||
value={<span className="font-mono text-xs">{status.pid}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{status.state === "installing_cli" ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
Setting up the local runtime… this only happens the first time.
|
||||
</div>
|
||||
) : status.state === "cli_not_found" ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
|
||||
<p className="text-sm">
|
||||
Couldn't download the local runtime. Check your network
|
||||
connection and try again.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
{status.state === "stopped" ? (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Logs — fills remaining vertical space down to the sheet bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h3 className="text-sm font-medium">Logs</h3>
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="size-3 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleLogScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground/50 text-center py-8">
|
||||
{status.state === "running"
|
||||
? "Waiting for logs…"
|
||||
: "Start the daemon to see logs"}
|
||||
</p>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const { className } = colorizeLogLine(entry.line);
|
||||
return (
|
||||
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
|
||||
{entry.line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
155
apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx
Normal file
155
apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
|
||||
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then((s) => setStatus(s));
|
||||
const unsub = window.daemonAPI.onStatusChange((s) => {
|
||||
setStatus(s);
|
||||
setActionLoading(false);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
if (!result.success) {
|
||||
setActionLoading(false);
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const isRunning = status.state === "running";
|
||||
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setPanelOpen(true);
|
||||
}
|
||||
}}
|
||||
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Local Daemon</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
|
||||
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
|
||||
{isRunning && status.uptime && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
|
||||
</>
|
||||
)}
|
||||
{isRunning && status.agents && status.agents.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 shrink-0"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading || status.state === "cli_not_found"}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isTransitioning && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
apps/desktop/src/renderer/src/components/daemon-settings-tab.tsx
Normal file
103
apps/desktop/src/renderer/src/components/daemon-settings-tab.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import type { DaemonPrefs } from "../../../shared/daemon-types";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DaemonSettingsTab() {
|
||||
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
|
||||
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
window.daemonAPI.isCliInstalled().then(setCliInstalled);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
async (key: keyof DaemonPrefs, value: boolean) => {
|
||||
setSaving(true);
|
||||
const updated = await window.daemonAPI.setPrefs({ [key]: value });
|
||||
setPrefs(updated);
|
||||
setSaving(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Daemon</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure how the local agent daemon behaves with the desktop app.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<SettingRow
|
||||
label="Auto-start on launch"
|
||||
description="Automatically start the daemon when the app opens and you are logged in."
|
||||
>
|
||||
<Switch
|
||||
checked={prefs.autoStart}
|
||||
onCheckedChange={(checked) => updatePref("autoStart", checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Auto-stop on quit"
|
||||
description="Stop the daemon when the desktop app is closed. Disable this to keep the daemon running in the background."
|
||||
>
|
||||
<Switch
|
||||
checked={prefs.autoStop}
|
||||
onCheckedChange={(checked) => updatePref("autoStop", checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm font-medium">CLI Status</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{cliInstalled === null
|
||||
? "Checking…"
|
||||
: cliInstalled
|
||||
? "multica CLI is installed and available in PATH."
|
||||
: "multica CLI not found. Install it to enable daemon management."}
|
||||
</p>
|
||||
{cliInstalled === false && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
window.desktopAPI.openExternal(
|
||||
"https://github.com/multica-ai/multica#cli-installation",
|
||||
)
|
||||
}
|
||||
>
|
||||
Installation Guide
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
apps/desktop/src/renderer/src/components/desktop-layout.tsx
Normal file
138
apps/desktop/src/renderer/src/components/desktop-layout.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabHistory } from "@/hooks/use-tab-history";
|
||||
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
|
||||
function SidebarTopBar() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-12 shrink-0 flex items-center justify-end px-2"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Go back"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Go forward"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The main area's top bar doubles as a window drag region. When the sidebar
|
||||
// is not occupying main-flow width — either user-collapsed (offcanvas) or
|
||||
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
|
||||
// left side so tabs don't land under the macOS traffic lights (which live at
|
||||
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
|
||||
// the sidebar can be brought back without keyboard shortcut.
|
||||
function MainTopBar() {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const sidebarHidden = state === "collapsed" || isMobile;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"h-12 shrink-0 flex items-center gap-2",
|
||||
sidebarHidden && "pl-20",
|
||||
)}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
{sidebarHidden && (
|
||||
<SidebarTrigger
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
/>
|
||||
)}
|
||||
<TabBar />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function useInternalLinkHandler() {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const path = (e as CustomEvent).detail?.path;
|
||||
if (!path) return;
|
||||
const icon = resolveRouteIcon(path);
|
||||
const store = useTabStore.getState();
|
||||
const tabId = store.openTab(path, path, icon);
|
||||
store.setActiveTab(tabId);
|
||||
};
|
||||
window.addEventListener("multica:navigate", handler);
|
||||
return () => window.removeEventListener("multica:navigate", handler);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
|
||||
// Reactive read of current workspace slug from the platform singleton.
|
||||
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab
|
||||
// router) sets it. Once set, the sidebar and other shell-level components
|
||||
// can resolve workspace-scoped paths via useWorkspacePaths().
|
||||
const slug = useSyncExternalStore(subscribeToCurrentSlug, getCurrentSlug, () => null);
|
||||
|
||||
return (
|
||||
<DesktopNavigationProvider>
|
||||
{/* WorkspaceSlugProvider accepts null — components that need slug
|
||||
use useWorkspaceSlug() (nullable) or useRequiredWorkspaceSlug()
|
||||
(throws). TabContent MUST always render so the tab router can
|
||||
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
|
||||
to populate the slug. The sidebar gates on slug being present
|
||||
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
|
||||
users are routed to /workspaces/new by IndexRedirect. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
201
apps/desktop/src/renderer/src/components/tab-bar.tsx
Normal file
201
apps/desktop/src/renderer/src/components/tab-bar.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
ListTodo,
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
restrictToHorizontalAxis,
|
||||
restrictToParentElement,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import { isGlobalPath, paths } from "@multica/core/paths";
|
||||
|
||||
const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
ListTodo,
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
Settings,
|
||||
};
|
||||
|
||||
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: tab.id });
|
||||
|
||||
const Icon = TAB_ICONS[tab.icon];
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
WebkitAppRegion: "no-drag",
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isActive) return;
|
||||
setActiveTab(tab.id);
|
||||
// No navigate() — Activity handles visibility
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
// No navigate() — store handles activeTabId switch
|
||||
};
|
||||
|
||||
// Stop pointer down on close so it doesn't start a drag on the parent button.
|
||||
const stopDragOnClose = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
|
||||
"select-none cursor-default",
|
||||
isActive
|
||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
: "bg-sidebar-accent/50 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
<span
|
||||
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
|
||||
style={{
|
||||
maskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
|
||||
WebkitMaskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
{!isOnly && (
|
||||
<span
|
||||
onClick={handleClose}
|
||||
onPointerDown={stopDragOnClose}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTabButton() {
|
||||
const addTab = useTabStore((s) => s.addTab);
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
|
||||
const handleClick = () => {
|
||||
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
|
||||
// opens in the same context as the active one. Read the slug from the
|
||||
// active tab's path directly rather than from getCurrentSlug(), because
|
||||
// that singleton is "last tab to render" (non-deterministic with N tabs
|
||||
// mounted under <Activity>), while activeTabId is the unambiguous truth.
|
||||
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
|
||||
// tab is on a global route (e.g. /workspaces/new, /login).
|
||||
const { tabs, activeTabId } = useTabStore.getState();
|
||||
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
|
||||
let slug: string | null = null;
|
||||
if (activePath !== "/" && !isGlobalPath(activePath)) {
|
||||
slug = activePath.split("/").filter(Boolean)[0] ?? null;
|
||||
}
|
||||
const path = slug ? paths.workspace(slug).issues() : "/";
|
||||
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabBar() {
|
||||
const tabs = useTabStore((s) => s.tabs);
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
const moveTab = useTabStore((s) => s.moveTab);
|
||||
|
||||
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = tabs.findIndex((t) => t.id === active.id);
|
||||
const to = tabs.findIndex((t) => t.id === over.id);
|
||||
if (from !== -1 && to !== -1) moveTab(from, to);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center gap-0.5 px-2 justify-start">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis, restrictToParentElement]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<NewTabButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/desktop/src/renderer/src/components/tab-content.tsx
Normal file
43
apps/desktop/src/renderer/src/components/tab-content.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Activity, useEffect } from "react";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { TabNavigationProvider } from "@/platform/navigation";
|
||||
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
|
||||
|
||||
/** Inner wrapper rendered inside each tab's RouterProvider. */
|
||||
function TabRouterInner({ tabId }: { tabId: string }) {
|
||||
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
|
||||
useTabRouterSync(tabId, tab!.router);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all tabs using Activity for state preservation.
|
||||
* Only the active tab is visible; hidden tabs keep their DOM and React state.
|
||||
*/
|
||||
export function TabContent() {
|
||||
const tabs = useTabStore((s) => s.tabs);
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
|
||||
// Sync document.title when switching tabs
|
||||
useEffect(() => {
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (tab) document.title = tab.title;
|
||||
}, [activeTabId, tabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tabs.map((tab) => (
|
||||
<Activity
|
||||
key={tab.id}
|
||||
mode={tab.id === activeTabId ? "visible" : "hidden"}
|
||||
>
|
||||
<TabNavigationProvider router={tab.router}>
|
||||
<RouterProvider router={tab.router} />
|
||||
<TabRouterInner tabId={tab.id} />
|
||||
</TabNavigationProvider>
|
||||
</Activity>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal file
124
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
|
||||
|
||||
type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "available"; version: string }
|
||||
| { status: "downloading"; percent: number }
|
||||
| { status: "ready" };
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [state, setState] = useState<UpdateState>({ status: "idle" });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateAvailable((info) => {
|
||||
setState({ status: "available", version: info.version });
|
||||
setDismissed(false);
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onDownloadProgress((progress) => {
|
||||
setState({ status: "downloading", percent: progress.percent });
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateDownloaded(() => {
|
||||
setState({ status: "ready" });
|
||||
}),
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
// Prevent double-click: immediately transition to downloading state
|
||||
if (state.status !== "available") return;
|
||||
setState({ status: "downloading", percent: 0 });
|
||||
window.updater.downloadUpdate();
|
||||
}, [state.status]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
window.updater.installUpdate();
|
||||
}, []);
|
||||
|
||||
// Only allow dismiss when update is available (not during download or ready)
|
||||
if (state.status === "idle") return null;
|
||||
if (dismissed && state.status === "available") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{state.status === "available" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">New version available</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} is ready to download
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Download update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "downloading" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Downloading update...</p>
|
||||
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.round(state.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{Math.round(state.percent)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "ready" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
*
|
||||
* Resolves the URL slug → workspace UUID via the React Query list cache
|
||||
* (seeded by AuthInitializer). Children do not render until the workspace
|
||||
* is fully resolved — useWorkspaceId() inside child pages is therefore
|
||||
* guaranteed non-null when called. Two industry-standard identities are
|
||||
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
|
||||
*
|
||||
* If the slug doesn't resolve to any workspace the user has access to,
|
||||
* we render NoAccessPage instead of silently redirecting — users get
|
||||
* explicit feedback for stale bookmarks or revoked access.
|
||||
*/
|
||||
export function WorkspaceRouteLayout() {
|
||||
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Workspace routes require auth. If user is unauthenticated (token
|
||||
// expired, logged out from another tab, etc.), bounce to /login.
|
||||
// Without this, the layout renders null and the user sees a blank page
|
||||
// stuck on /{slug}/...
|
||||
useEffect(() => {
|
||||
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
|
||||
}, [isAuthLoading, user, navigate]);
|
||||
|
||||
const { data: workspace, isFetched: listFetched } = useQuery({
|
||||
...workspaceBySlugOptions(workspaceSlug ?? ""),
|
||||
enabled: !!user && !!workspaceSlug,
|
||||
});
|
||||
|
||||
// Feed the URL slug into the platform singleton so the API client's
|
||||
// X-Workspace-Slug header and persist namespace follow the active tab.
|
||||
// setCurrentWorkspace self-dedupes on slug equality — safe to call on
|
||||
// every render (matters on desktop, where N tabs each mount their own
|
||||
// layout). Rehydrate is the singleton's internal side effect.
|
||||
if (workspace && workspaceSlug) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
}
|
||||
|
||||
// Remember whether this slug has resolved before (see hook docs). Gates
|
||||
// the NoAccessPage render below so active workspace removal doesn't
|
||||
// flash "Workspace not available" before the navigate lands.
|
||||
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
|
||||
|
||||
if (isAuthLoading) return null;
|
||||
if (!workspaceSlug) return null;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
// throws when the workspace list hasn't populated or the slug is
|
||||
// unknown — gating here is the single point where that invariant is
|
||||
// enforced, so every descendant can call useWorkspaceId() safely.
|
||||
if (!listFetched) return null;
|
||||
if (!workspace) {
|
||||
// Active workspace just removed (delete/leave/realtime eviction) —
|
||||
// navigate is in flight; hold null briefly instead of flashing
|
||||
// NoAccessPage.
|
||||
if (hasBeenSeen) return null;
|
||||
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
|
||||
// link from a former teammate's workspace) → explicit feedback.
|
||||
return <NoAccessPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<Outlet />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
1
apps/desktop/src/renderer/src/env.d.ts
vendored
Normal file
1
apps/desktop/src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
40
apps/desktop/src/renderer/src/globals.css
Normal file
40
apps/desktop/src/renderer/src/globals.css
Normal file
@@ -0,0 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@multica/ui/styles/tokens.css";
|
||||
@import "@multica/ui/styles/base.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
|
||||
Web app uses the same stack via next/font/google in apps/web/app/layout.tsx —
|
||||
keep the CJK fallback tail in sync across both files. The Inter primary family
|
||||
differs by design: next/font produces `__Inter_xxx` (with a synthetic size-adjusted
|
||||
fallback face to prevent FOUT layout shift); desktop uses fontsource's "Inter Variable".
|
||||
Both resolve to Inter glyphs, so rendering is identical in practice.
|
||||
Currently covers English + Simplified Chinese. When ja/ko i18n lands, extend
|
||||
the tail with Hiragino Kaku Gothic ProN / Yu Gothic / Apple SD Gothic Neo / Malgun Gothic.
|
||||
Per-character fallback: Latin chars render with Inter, Chinese chars with
|
||||
PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans CJK SC (Linux).
|
||||
|
||||
Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
|
||||
non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
|
||||
would falsely signal alignment guarantees. Browser default fallback handles
|
||||
the rare mixed case correctly. */
|
||||
:root {
|
||||
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
|
||||
sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
@source "../../../../../packages/ui/**/*.tsx";
|
||||
@source "../../../../../packages/core/**/*.{ts,tsx}";
|
||||
@source "../../../../../packages/views/**/*.{ts,tsx}";
|
||||
@source "./**/*.tsx";
|
||||
|
||||
/* Desktop-specific: override sidebar container padding for traffic light layout */
|
||||
[data-slot="sidebar-container"] {
|
||||
padding: 0 !important;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/** Sets document.title. The tab system observes this automatically. */
|
||||
export function useDocumentTitle(title: string) {
|
||||
useEffect(() => {
|
||||
if (title) document.title = title;
|
||||
}, [title]);
|
||||
}
|
||||
40
apps/desktop/src/renderer/src/hooks/use-tab-history.ts
Normal file
40
apps/desktop/src/renderer/src/hooks/use-tab-history.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from "react";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
|
||||
* Set before calling router.navigate(-1 | 1), read in the synchronous subscription.
|
||||
*/
|
||||
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
|
||||
|
||||
/**
|
||||
* Per-tab back/forward navigation derived from the active tab's history state.
|
||||
* Replaces the old global useNavigationHistory() hook.
|
||||
*/
|
||||
export function useTabHistory() {
|
||||
// Return the actual tab object from the store — stable reference.
|
||||
// Do NOT create a new object in the selector (causes infinite re-renders).
|
||||
const activeTab = useTabStore((s) =>
|
||||
s.tabs.find((t) => t.id === s.activeTabId),
|
||||
);
|
||||
|
||||
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
|
||||
const canGoForward =
|
||||
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (!activeTab || activeTab.historyIndex <= 0) return;
|
||||
popDirectionHints.set(activeTab.router, "back");
|
||||
activeTab.router.navigate(-1);
|
||||
}, [activeTab]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
|
||||
return;
|
||||
popDirectionHints.set(activeTab.router, "forward");
|
||||
activeTab.router.navigate(1);
|
||||
}, [activeTab]);
|
||||
|
||||
return { canGoBack, canGoForward, goBack, goForward };
|
||||
}
|
||||
49
apps/desktop/src/renderer/src/hooks/use-tab-router-sync.ts
Normal file
49
apps/desktop/src/renderer/src/hooks/use-tab-router-sync.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import { popDirectionHints } from "./use-tab-history";
|
||||
|
||||
/**
|
||||
* Subscribe to a tab's memory router and sync path + history tracking
|
||||
* back into the tab store.
|
||||
*
|
||||
* Called once per tab inside its RouterProvider subtree.
|
||||
*/
|
||||
export function useTabRouterSync(tabId: string, router: DataRouter) {
|
||||
const indexRef = useRef(0);
|
||||
const lengthRef = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync initial state
|
||||
const initialPath = router.state.location.pathname;
|
||||
const store = useTabStore.getState();
|
||||
store.updateTab(tabId, { path: initialPath, icon: resolveRouteIcon(initialPath) });
|
||||
|
||||
const unsubscribe = router.subscribe((state) => {
|
||||
const { pathname } = state.location;
|
||||
const action = state.historyAction;
|
||||
|
||||
if (action === "PUSH") {
|
||||
indexRef.current += 1;
|
||||
lengthRef.current = indexRef.current + 1;
|
||||
} else if (action === "POP") {
|
||||
// Determine direction from the hint set by goBack/goForward
|
||||
const hint = popDirectionHints.get(router);
|
||||
popDirectionHints.delete(router);
|
||||
if (hint === "forward") {
|
||||
indexRef.current = Math.min(indexRef.current + 1, lengthRef.current - 1);
|
||||
} else {
|
||||
// Default to back
|
||||
indexRef.current = Math.max(0, indexRef.current - 1);
|
||||
}
|
||||
}
|
||||
// REPLACE: index and length stay the same
|
||||
|
||||
const store = useTabStore.getState();
|
||||
store.updateTab(tabId, { path: pathname, icon: resolveRouteIcon(pathname) });
|
||||
store.updateTabHistory(tabId, indexRef.current, lengthRef.current);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [tabId, router]);
|
||||
}
|
||||
29
apps/desktop/src/renderer/src/hooks/use-tab-sync.ts
Normal file
29
apps/desktop/src/renderer/src/hooks/use-tab-sync.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Watches document.title via MutationObserver and updates the active tab's title.
|
||||
*
|
||||
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
|
||||
* This observer picks up the change and syncs it to the tab store.
|
||||
*/
|
||||
export function useActiveTitleSync() {
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const title = document.title;
|
||||
if (!title) return;
|
||||
const { tabs, activeTabId } = useTabStore.getState();
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (activeTab && activeTab.title !== title) {
|
||||
useTabStore.getState().updateTab(activeTabId, { title });
|
||||
}
|
||||
});
|
||||
|
||||
const titleEl = document.querySelector("title");
|
||||
if (titleEl) {
|
||||
observer.observe(titleEl, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}
|
||||
11
apps/desktop/src/renderer/src/main.tsx
Normal file
11
apps/desktop/src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
// Inter variable font covers all weights (100-900) in a single file.
|
||||
// Geist Mono kept as-is for code blocks; CJK is handled by system font fallback
|
||||
// (see globals.css --font-sans chain). Keep font stack in sync with apps/web/app/layout.tsx.
|
||||
import "@fontsource-variable/inter";
|
||||
import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "./globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AutopilotDetailPage as AutopilotDetail } from "@multica/views/autopilots/components";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { autopilotDetailOptions } from "@multica/core/autopilots/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function AutopilotDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data } = useQuery(autopilotDetailOptions(wsId, id!));
|
||||
|
||||
useDocumentTitle(data ? `⚡ ${data.autopilot.title}` : "Autopilot");
|
||||
|
||||
if (!id) return null;
|
||||
return <AutopilotDetail autopilotId={id} />;
|
||||
}
|
||||
17
apps/desktop/src/renderer/src/pages/issue-detail-page.tsx
Normal file
17
apps/desktop/src/renderer/src/pages/issue-detail-page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function IssueDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issue } = useQuery(issueDetailOptions(wsId, id!));
|
||||
|
||||
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
|
||||
|
||||
if (!id) return null;
|
||||
return <IssueDetail issueId={id} />;
|
||||
}
|
||||
32
apps/desktop/src/renderer/src/pages/login.tsx
Normal file
32
apps/desktop/src/renderer/src/pages/login.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
window.desktopAPI.openExternal(
|
||||
`${WEB_URL}/login?platform=desktop`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Traffic light inset */}
|
||||
<div
|
||||
className="h-[38px] shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => {
|
||||
// Auth store update triggers AppContent re-render → shows DesktopShell.
|
||||
// Initial workspace navigation happens in routes.tsx via IndexRedirect.
|
||||
}}
|
||||
onGoogleLogin={handleGoogleLogin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/desktop/src/renderer/src/pages/project-detail-page.tsx
Normal file
17
apps/desktop/src/renderer/src/pages/project-detail-page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ProjectDetail } from "@multica/views/projects/components";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: project } = useQuery(projectDetailOptions(wsId, id!));
|
||||
|
||||
useDocumentTitle(project ? `${project.icon || "📁"} ${project.title}` : "Project");
|
||||
|
||||
if (!id) return null;
|
||||
return <ProjectDetail projectId={id} />;
|
||||
}
|
||||
121
apps/desktop/src/renderer/src/platform/navigation.tsx
Normal file
121
apps/desktop/src/renderer/src/platform/navigation.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import {
|
||||
NavigationProvider,
|
||||
type NavigationAdapter,
|
||||
} from "@multica/views/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. Falls
|
||||
// back to the production host for dev builds so "Copy link" yields a URL
|
||||
// that actually points somewhere a teammate can open.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab RouterProviders
|
||||
* (sidebar, search dialog, modals, etc.).
|
||||
*
|
||||
* Reads from the active tab's memory router via router.subscribe().
|
||||
* Does NOT use any react-router hooks — it's above all RouterProviders.
|
||||
*/
|
||||
export function DesktopNavigationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
|
||||
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
|
||||
|
||||
// Subscribe to the active tab's router for pathname updates
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
setPathname(activeTab.router.state.location.pathname);
|
||||
return activeTab.router.subscribe((state) => {
|
||||
setPathname(state.location.pathname);
|
||||
});
|
||||
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => {
|
||||
if (path === "/login") {
|
||||
// DashboardGuard token expired — force back to login screen
|
||||
useAuthStore.getState().logout();
|
||||
return;
|
||||
}
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(-1);
|
||||
},
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const icon = resolveRouteIcon(path);
|
||||
const store = useTabStore.getState();
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[pathname],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab navigation provider rendered inside each tab's Activity wrapper.
|
||||
* Subscribes to the tab's own router for up-to-date pathname.
|
||||
*
|
||||
* This is what @multica/views page components read via useNavigation().
|
||||
*/
|
||||
export function TabNavigationProvider({
|
||||
router,
|
||||
children,
|
||||
}: {
|
||||
router: DataRouter;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [location, setLocation] = useState(router.state.location);
|
||||
|
||||
useEffect(() => {
|
||||
setLocation(router.state.location);
|
||||
return router.subscribe((state) => {
|
||||
setLocation(state.location);
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => router.navigate(path),
|
||||
replace: (path: string) => router.navigate(path, { replace: true }),
|
||||
back: () => router.navigate(-1),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const icon = resolveRouteIcon(path);
|
||||
const store = useTabStore.getState();
|
||||
const newTabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(newTabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[router, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
}
|
||||
204
apps/desktop/src/renderer/src/routes.tsx
Normal file
204
apps/desktop/src/renderer/src/routes.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
createMemoryRouter,
|
||||
Navigate,
|
||||
Outlet,
|
||||
useMatches,
|
||||
} from "react-router-dom";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
* The tab system observes document.title via MutationObserver.
|
||||
* Pages with dynamic titles (e.g. issue detail) override by setting
|
||||
* document.title directly via useDocumentTitle().
|
||||
*/
|
||||
function TitleSync() {
|
||||
const matches = useMatches();
|
||||
const title = [...matches]
|
||||
.reverse()
|
||||
.find((m) => (m.handle as { title?: string })?.title)
|
||||
?.handle as { title?: string } | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (title?.title) document.title = title.title;
|
||||
}, [title?.title]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Wrapper that renders route children + TitleSync */
|
||||
function PageShell() {
|
||||
return (
|
||||
<>
|
||||
<TitleSync />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NewWorkspaceRoute() {
|
||||
const nav = useNavigation();
|
||||
return (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root index route: resolves the URL-less `/` path to a concrete destination.
|
||||
*
|
||||
* Runs both on first login (App.tsx seeded the cache) and on app reopen
|
||||
* (AuthInitializer seeded the cache). Reading from React Query avoids
|
||||
* duplicate fetches across tabs — each tab's memory router hits this
|
||||
* component independently but the query is deduped.
|
||||
*
|
||||
* Sends first-time users without any workspace to /workspaces/new,
|
||||
* everyone else to their first workspace's issues page. Persisted tab
|
||||
* paths that already carry a workspace slug bypass this component
|
||||
* entirely.
|
||||
*/
|
||||
function IndexRedirect() {
|
||||
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
|
||||
|
||||
// Wait for the query to settle so we don't redirect to /workspaces/new
|
||||
// on the initial render before the seeded/fetched data arrives.
|
||||
if (!isFetched) return null;
|
||||
|
||||
const firstWorkspace = wsList?.[0];
|
||||
if (firstWorkspace) {
|
||||
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
|
||||
}
|
||||
return <Navigate to={paths.newWorkspace()} replace />;
|
||||
}
|
||||
|
||||
function InviteRoute() {
|
||||
const matches = useMatches();
|
||||
const match = matches.find((m) => (m.params as { id?: string }).id);
|
||||
const id = (match?.params as { id?: string })?.id ?? "";
|
||||
return <InvitePage invitationId={id} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route definitions shared by all tabs.
|
||||
*
|
||||
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
|
||||
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
|
||||
* slug to a workspace and syncing side-effects (api client, persist namespace,
|
||||
* Zustand mirror). Global (pre-workspace) routes — workspaces/new and invite —
|
||||
* sit at the top level alongside the workspace wrapper.
|
||||
*/
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <PageShell />,
|
||||
children: [
|
||||
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
|
||||
// list from React Query cache (seeded by AuthInitializer on reopen
|
||||
// or App.tsx on deep-link login) and bounces to the first
|
||||
// workspace's issues page — or /workspaces/new if the user has none.
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{
|
||||
path: "workspaces/new",
|
||||
element: <NewWorkspaceRoute />,
|
||||
handle: { title: "Create Workspace" },
|
||||
},
|
||||
{
|
||||
path: "invite/:id",
|
||||
element: <InviteRoute />,
|
||||
handle: { title: "Accept Invite" },
|
||||
},
|
||||
{
|
||||
path: ":workspaceSlug",
|
||||
element: <WorkspaceRouteLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="issues" replace /> },
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
handle: { title: "Issue" },
|
||||
},
|
||||
{
|
||||
path: "projects",
|
||||
element: <ProjectsPage />,
|
||||
handle: { title: "Projects" },
|
||||
},
|
||||
{
|
||||
path: "projects/:id",
|
||||
element: <ProjectDetailPage />,
|
||||
handle: { title: "Project" },
|
||||
},
|
||||
{
|
||||
path: "autopilots",
|
||||
element: <AutopilotsPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "autopilots/:id",
|
||||
element: <AutopilotDetailPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "my-issues",
|
||||
element: <MyIssuesPage />,
|
||||
handle: { title: "My Issues" },
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
handle: { title: "Settings" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Create an independent memory router for a tab. */
|
||||
export function createTabRouter(initialPath: string) {
|
||||
return createMemoryRouter(appRoutes, {
|
||||
initialEntries: [initialPath],
|
||||
});
|
||||
}
|
||||
45
apps/desktop/src/renderer/src/stores/tab-store.test.ts
Normal file
45
apps/desktop/src/renderer/src/stores/tab-store.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// createTabRouter transitively pulls in route modules that expect a browser
|
||||
// router context. For pure-function tests we stub it out.
|
||||
vi.mock("../routes", () => ({
|
||||
createTabRouter: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { sanitizeTabPath } from "./tab-store";
|
||||
|
||||
describe("sanitizeTabPath", () => {
|
||||
it("passes through root sentinel", () => {
|
||||
expect(sanitizeTabPath("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("passes through global paths", () => {
|
||||
expect(sanitizeTabPath("/login")).toBe("/login");
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
|
||||
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
|
||||
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
|
||||
});
|
||||
|
||||
it("passes through valid workspace-scoped paths", () => {
|
||||
expect(sanitizeTabPath("/acme/issues")).toBe("/acme/issues");
|
||||
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
|
||||
});
|
||||
|
||||
it("rejects paths whose first segment is a reserved slug", () => {
|
||||
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
|
||||
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(sanitizeTabPath("/issues")).toBe("/");
|
||||
expect(sanitizeTabPath("/issues/abc-123")).toBe("/");
|
||||
expect(sanitizeTabPath("/settings")).toBe("/");
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
|
||||
// A workspace owner could legitimately pick "acme-issues" or
|
||||
// "project-x" as their slug — sanitize must not touch these.
|
||||
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
|
||||
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
|
||||
});
|
||||
});
|
||||
298
apps/desktop/src/renderer/src/stores/tab-store.ts
Normal file
298
apps/desktop/src/renderer/src/stores/tab-store.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
router: DataRouter;
|
||||
historyIndex: number;
|
||||
historyLength: number;
|
||||
}
|
||||
|
||||
interface TabStore {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
|
||||
/** Open a background tab. Deduplicates by path. Returns the tab id. */
|
||||
openTab: (path: string, title: string, icon: string) => string;
|
||||
/** Always create a new tab (no dedup). Returns the tab id. */
|
||||
addTab: (path: string, title: string, icon: string) => string;
|
||||
/** Close a tab. Disposes router. */
|
||||
closeTab: (tabId: string) => void;
|
||||
/** Switch to a tab by id. */
|
||||
setActiveTab: (tabId: string) => void;
|
||||
/** Update a tab's metadata (path, title, icon — partial). */
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Update a tab's history tracking. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
|
||||
moveTab: (fromIndex: number, toIndex: number) => void;
|
||||
/**
|
||||
* Reset any tab whose first path segment references a workspace slug the
|
||||
* current user doesn't have access to. Called after login + workspace list
|
||||
* is populated (and on every subsequent list change, e.g. realtime
|
||||
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
|
||||
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
|
||||
* are untouched.
|
||||
*/
|
||||
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route → icon mapping (title comes from document.title, not from here)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
inbox: "Inbox",
|
||||
"my-issues": "CircleUser",
|
||||
issues: "ListTodo",
|
||||
projects: "FolderKanban",
|
||||
autopilots: "ListTodo",
|
||||
agents: "Bot",
|
||||
runtimes: "Monitor",
|
||||
skills: "BookOpenText",
|
||||
settings: "Settings",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a route icon from a pathname. Title is NOT determined here — it
|
||||
* comes from document.title.
|
||||
*
|
||||
* Path shape after the workspace URL refactor:
|
||||
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
|
||||
* - global (workspaces/new, invite, auth, login): `/{route}/...` → use segment index 0
|
||||
*
|
||||
* `isGlobalPath` is the single source of truth for which prefixes are global.
|
||||
*/
|
||||
export function resolveRouteIcon(pathname: string): string {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const routeSegment = isGlobalPath(pathname)
|
||||
? (segments[0] ?? "")
|
||||
: (segments[1] ?? "");
|
||||
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sentinel path for new tabs with no explicit destination. The tab store is
|
||||
* workspace-implicit — it doesn't know which workspace is active, so it can't
|
||||
* build a `/:slug/issues` path itself. Instead we hand off to the router: `/`
|
||||
* matches the top-level index route, which redirects to the workspace default
|
||||
* (slug-aware redirect lives in routes.tsx / App.tsx).
|
||||
*
|
||||
* `title` and `icon` on the placeholder tab get overwritten by
|
||||
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
|
||||
*/
|
||||
const DEFAULT_PATH = "/";
|
||||
|
||||
function createId(): string {
|
||||
return createSafeId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive: catch tab paths that were constructed without a workspace slug
|
||||
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
|
||||
* paths would get matched as `workspaceSlug="issues"` by the router and
|
||||
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
|
||||
* a valid workspace).
|
||||
*
|
||||
* Passes through:
|
||||
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
|
||||
* - workspace-scoped paths whose first segment is not a reserved word
|
||||
*
|
||||
* Rejects (and rewrites to "/"):
|
||||
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
|
||||
* means the caller forgot to prefix the workspace. Logs a warning so the
|
||||
* buggy call site is easy to find.
|
||||
*/
|
||||
export function sanitizeTabPath(path: string): string {
|
||||
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
|
||||
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (isReservedSlug(firstSegment)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Falling back to "/".`,
|
||||
);
|
||||
return DEFAULT_PATH;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function makeTab(path: string, title: string, icon: string): Tab {
|
||||
const safePath = sanitizeTabPath(path);
|
||||
return {
|
||||
id: createId(),
|
||||
path: safePath,
|
||||
title,
|
||||
icon,
|
||||
router: createTabRouter(safePath),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const initialTab = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
|
||||
export const useTabStore = create<TabStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [initialTab],
|
||||
activeTabId: initialTab.id,
|
||||
|
||||
openTab(path, title, icon) {
|
||||
const { tabs } = get();
|
||||
const existing = tabs.find((t) => t.path === path);
|
||||
if (existing) return existing.id;
|
||||
|
||||
const tab = makeTab(path, title, icon);
|
||||
set({ tabs: [...tabs, tab] });
|
||||
return tab.id;
|
||||
},
|
||||
|
||||
addTab(path, title, icon) {
|
||||
const tab = makeTab(path, title, icon);
|
||||
set((s) => ({ tabs: [...s.tabs, tab] }));
|
||||
return tab.id;
|
||||
},
|
||||
|
||||
closeTab(tabId) {
|
||||
const { tabs, activeTabId } = get();
|
||||
|
||||
const closingTab = tabs.find((t) => t.id === tabId);
|
||||
|
||||
// Never close the last tab — replace with default
|
||||
if (tabs.length === 1) {
|
||||
closingTab?.router.dispose();
|
||||
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
set({ tabs: [fresh], activeTabId: fresh.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
|
||||
closingTab?.router.dispose();
|
||||
const next = tabs.filter((t) => t.id !== tabId);
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
const newActive = next[Math.min(idx, next.length - 1)];
|
||||
set({ tabs: next, activeTabId: newActive.id });
|
||||
} else {
|
||||
set({ tabs: next });
|
||||
}
|
||||
},
|
||||
|
||||
setActiveTab(tabId) {
|
||||
set({ activeTabId: tabId });
|
||||
},
|
||||
|
||||
updateTab(tabId, patch) {
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, ...patch } : t,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
updateTabHistory(tabId, historyIndex, historyLength) {
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, historyIndex, historyLength } : t,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
moveTab(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex) return;
|
||||
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
|
||||
},
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { tabs } = get();
|
||||
let changed = false;
|
||||
const nextTabs = tabs.map((t) => {
|
||||
// Skip tabs on non-workspace-scoped paths — nothing to validate.
|
||||
if (t.path === "/" || isGlobalPath(t.path)) return t;
|
||||
|
||||
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (validSlugs.has(firstSegment)) return t;
|
||||
|
||||
// Stale slug: dispose the old router and replace with a fresh one
|
||||
// pointing at `/`. IndexRedirect will send the tab to a valid
|
||||
// workspace (or /workspaces/new if the user now has none).
|
||||
changed = true;
|
||||
t.router.dispose();
|
||||
return {
|
||||
...t,
|
||||
path: DEFAULT_PATH,
|
||||
title: "Issues",
|
||||
icon: resolveRouteIcon(DEFAULT_PATH),
|
||||
router: createTabRouter(DEFAULT_PATH),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
});
|
||||
|
||||
if (!changed) return;
|
||||
set({ tabs: nextTabs });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 1,
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
partialize: (state) => ({
|
||||
tabs: state.tabs.map(
|
||||
({ router, historyIndex, historyLength, ...rest }) => rest,
|
||||
),
|
||||
activeTabId: state.activeTabId,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as
|
||||
| Pick<TabStore, "tabs" | "activeTabId">
|
||||
| undefined;
|
||||
if (!persisted?.tabs?.length) return currentState;
|
||||
|
||||
const tabs: Tab[] = persisted.tabs.map((tab) => {
|
||||
// Sanitize persisted paths against reserved-slug rules. Catches
|
||||
// both pre-refactor paths like "/issues/abc" (missing workspace
|
||||
// slug) and any other malformed paths that slipped past the
|
||||
// write-time guard. The defense across makeTab + merge + runtime
|
||||
// validate ensures stale or malformed paths never reach the
|
||||
// router.
|
||||
const path = sanitizeTabPath(tab.path);
|
||||
return {
|
||||
...tab,
|
||||
path,
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
});
|
||||
|
||||
// Validate activeTabId — fall back to first tab if stale
|
||||
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
|
||||
? persisted.activeTabId
|
||||
: tabs[0].id;
|
||||
|
||||
return { ...currentState, tabs, activeTabId };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
53
apps/desktop/src/shared/daemon-types.ts
Normal file
53
apps/desktop/src/shared/daemon-types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type DaemonState =
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "stopping"
|
||||
| "installing_cli"
|
||||
| "cli_not_found";
|
||||
|
||||
export interface DaemonStatus {
|
||||
state: DaemonState;
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
agents?: string[];
|
||||
workspaceCount?: number;
|
||||
/** CLI profile this daemon belongs to. Empty string means the default profile. */
|
||||
profile?: string;
|
||||
/** Backend URL the daemon connects to. */
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
export interface DaemonPrefs {
|
||||
autoStart: boolean;
|
||||
autoStop: boolean;
|
||||
}
|
||||
|
||||
export const DAEMON_STATE_COLORS: Record<DaemonState, string> = {
|
||||
running: "bg-emerald-500",
|
||||
stopped: "bg-muted-foreground/40",
|
||||
starting: "bg-amber-500 animate-pulse",
|
||||
stopping: "bg-amber-500 animate-pulse",
|
||||
installing_cli: "bg-sky-500 animate-pulse",
|
||||
cli_not_found: "bg-red-500",
|
||||
};
|
||||
|
||||
export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
starting: "Starting…",
|
||||
stopping: "Stopping…",
|
||||
installing_cli: "Setting up…",
|
||||
cli_not_found: "Setup Failed",
|
||||
};
|
||||
|
||||
export function formatUptime(uptime?: string): string {
|
||||
if (!uptime) return "";
|
||||
const match = uptime.match(/(?:(\d+)h)?(\d+)m/);
|
||||
if (!match) return uptime;
|
||||
const h = match[1] ? `${match[1]}h ` : "";
|
||||
const m = match[2] ? `${match[2]}m` : "";
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
1
apps/desktop/test/setup.ts
Normal file
1
apps/desktop/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
4
apps/desktop/tsconfig.json
Normal file
4
apps/desktop/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
8
apps/desktop/tsconfig.node.json
Normal file
8
apps/desktop/tsconfig.node.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
}
|
||||
}
|
||||
21
apps/desktop/tsconfig.web.json
Normal file
21
apps/desktop/tsconfig.web.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts",
|
||||
"test/setup.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noImplicitAny": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/renderer/src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
apps/desktop/vitest.config.ts
Normal file
13
apps/desktop/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./test/setup.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
3
apps/docs/.gitignore
vendored
Normal file
3
apps/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.next/
|
||||
.source/
|
||||
node_modules/
|
||||
7
apps/docs/app/(home)/layout.tsx
Normal file
7
apps/docs/app/(home)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
|
||||
}
|
||||
29
apps/docs/app/(home)/page.tsx
Normal file
29
apps/docs/app/(home)/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 text-center px-4">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Multica Documentation
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg text-fd-muted-foreground">
|
||||
The open-source managed agents platform. Turn coding agents into real
|
||||
teammates — assign tasks, track progress, compound skills.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/docs"
|
||||
className="inline-flex items-center rounded-md bg-fd-primary px-6 py-3 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/multica-ai/multica"
|
||||
className="inline-flex items-center rounded-md border border-fd-border px-6 py-3 text-sm font-medium transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
4
apps/docs/app/api/search/route.ts
Normal file
4
apps/docs/app/api/search/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { source } from "@/lib/source";
|
||||
import { createFromSource } from "fumadocs-core/search/server";
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
47
apps/docs/app/docs/[[...slug]]/page.tsx
Normal file
47
apps/docs/app/docs/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { source } from "@/lib/source";
|
||||
import {
|
||||
DocsPage,
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsTitle,
|
||||
} from "fumadocs-ui/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
12
apps/docs/app/docs/layout.tsx
Normal file
12
apps/docs/app/docs/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type { ReactNode } from "react";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
3
apps/docs/app/global.css
Normal file
3
apps/docs/app/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/neutral.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
25
apps/docs/app/layout.config.tsx
Normal file
25
apps/docs/app/layout.config.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
import { BookOpen, Terminal, Rocket, Code } from "lucide-react";
|
||||
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: (
|
||||
<span className="font-semibold text-base">Multica Docs</span>
|
||||
),
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: "Documentation",
|
||||
url: "/docs",
|
||||
active: "nested-url",
|
||||
},
|
||||
{
|
||||
text: "GitHub",
|
||||
url: "https://github.com/multica-ai/multica",
|
||||
},
|
||||
{
|
||||
text: "Cloud",
|
||||
url: "https://multica.ai",
|
||||
},
|
||||
],
|
||||
};
|
||||
23
apps/docs/app/layout.tsx
Normal file
23
apps/docs/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import "./global.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | Multica Docs",
|
||||
default: "Multica Docs",
|
||||
},
|
||||
description:
|
||||
"Documentation for Multica — the open-source managed agents platform.",
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<RootProvider>{children}</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
96
apps/docs/content/docs/cli/installation.mdx
Normal file
96
apps/docs/content/docs/cli/installation.mdx
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: CLI Installation
|
||||
description: Install the Multica CLI and start the agent daemon.
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make build
|
||||
cp server/bin/multica /usr/local/bin/multica
|
||||
```
|
||||
|
||||
### Download from GitHub Releases
|
||||
|
||||
If Homebrew is not available, download the binary directly:
|
||||
|
||||
```bash
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # "darwin" or "linux"
|
||||
ARCH=$(uname -m) # "x86_64" or "arm64"
|
||||
|
||||
# Normalize architecture name
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
ARCH="amd64"
|
||||
fi
|
||||
|
||||
# Get the latest release tag from GitHub
|
||||
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest \
|
||||
| grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
|
||||
|
||||
# Download and extract
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" \
|
||||
-o /tmp/multica.tar.gz
|
||||
tar -xzf /tmp/multica.tar.gz -C /tmp multica
|
||||
sudo mv /tmp/multica /usr/local/bin/multica
|
||||
rm /tmp/multica.tar.gz
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
brew upgrade multica-ai/tap/multica
|
||||
```
|
||||
|
||||
For install script or manual installs, use:
|
||||
|
||||
```bash
|
||||
multica update
|
||||
```
|
||||
|
||||
`multica update` auto-detects your installation method and upgrades accordingly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# One command: configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
```
|
||||
|
||||
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
|
||||
|
||||
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, install at least one supported AI agent CLI:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
|
||||
- [Codex](https://github.com/openai/codex) (`codex`)
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini`)
|
||||
- OpenCode (`opencode`)
|
||||
- OpenClaw (`openclaw`)
|
||||
- Hermes (`hermes`)
|
||||
|
||||
Then restart the daemon:
|
||||
|
||||
```bash
|
||||
multica daemon stop && multica daemon start
|
||||
```
|
||||
4
apps/docs/content/docs/cli/meta.json
Normal file
4
apps/docs/content/docs/cli/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "CLI & Daemon",
|
||||
"pages": ["installation", "reference"]
|
||||
}
|
||||
320
apps/docs/content/docs/cli/reference.mdx
Normal file
320
apps/docs/content/docs/cli/reference.mdx
Normal file
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: CLI Reference
|
||||
description: Complete command reference for the Multica CLI and agent daemon.
|
||||
---
|
||||
|
||||
The `multica` CLI connects your local machine to Multica. It handles authentication, workspace management, issue tracking, and runs the agent daemon that executes AI tasks locally.
|
||||
|
||||
## Authentication
|
||||
|
||||
### Browser Login
|
||||
|
||||
```bash
|
||||
multica login
|
||||
```
|
||||
|
||||
Opens your browser for OAuth authentication, creates a 90-day personal access token, and auto-configures your workspaces.
|
||||
|
||||
### Token Login
|
||||
|
||||
```bash
|
||||
multica login --token
|
||||
```
|
||||
|
||||
Authenticate by pasting a personal access token directly. Useful for headless environments.
|
||||
|
||||
### Check Status
|
||||
|
||||
```bash
|
||||
multica auth status
|
||||
```
|
||||
|
||||
Shows your current server, user, and token validity.
|
||||
|
||||
### Logout
|
||||
|
||||
```bash
|
||||
multica auth logout
|
||||
```
|
||||
|
||||
Removes the stored authentication token.
|
||||
|
||||
## Agent Daemon
|
||||
|
||||
The daemon is the local agent runtime. It detects available AI CLIs on your machine, registers them with the Multica server, and executes tasks when agents are assigned work.
|
||||
|
||||
### Start
|
||||
|
||||
```bash
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
By default, the daemon runs in the background and logs to `~/.multica/daemon.log`.
|
||||
|
||||
To run in the foreground (useful for debugging):
|
||||
|
||||
```bash
|
||||
multica daemon start --foreground
|
||||
```
|
||||
|
||||
### Stop
|
||||
|
||||
```bash
|
||||
multica daemon stop
|
||||
```
|
||||
|
||||
### Status
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
multica daemon status --output json
|
||||
```
|
||||
|
||||
Shows PID, uptime, detected agents, and watched workspaces.
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
multica daemon logs # Last 50 lines
|
||||
multica daemon logs -f # Follow (tail -f)
|
||||
multica daemon logs -n 100 # Last 100 lines
|
||||
```
|
||||
|
||||
### Supported Agents
|
||||
|
||||
The daemon auto-detects these AI CLIs on your PATH:
|
||||
|
||||
| CLI | Command | Description |
|
||||
|-----|---------|-------------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
|
||||
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | Google's coding agent |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. On start, the daemon detects installed agent CLIs and registers a runtime for each agent in each watched workspace
|
||||
2. It polls the server at a configurable interval (default: 3s) for claimed tasks
|
||||
3. When a task arrives, it creates an isolated workspace directory, spawns the agent CLI, and streams results back
|
||||
4. Heartbeats are sent periodically (default: 15s) so the server knows the daemon is alive
|
||||
5. On shutdown, all runtimes are deregistered
|
||||
|
||||
### Configuration
|
||||
|
||||
Daemon behavior is configured via flags or environment variables:
|
||||
|
||||
| Setting | Flag | Env Variable | Default |
|
||||
|---------|------|--------------|---------|
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
|
||||
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
When connecting to a self-hosted Multica instance, point the CLI to your server before logging in:
|
||||
|
||||
```bash
|
||||
export MULTICA_APP_URL=https://app.example.com
|
||||
export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
Or set them persistently:
|
||||
|
||||
```bash
|
||||
multica config set app_url https://app.example.com
|
||||
multica config set server_url wss://api.example.com/ws
|
||||
```
|
||||
|
||||
### Profiles
|
||||
|
||||
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
|
||||
|
||||
```bash
|
||||
# Set up a staging profile
|
||||
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
|
||||
|
||||
# Start its daemon
|
||||
multica daemon start --profile staging
|
||||
|
||||
# Default profile runs separately
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daemon state, health port, and workspace root.
|
||||
|
||||
## Workspaces
|
||||
|
||||
### List Workspaces
|
||||
|
||||
```bash
|
||||
multica workspace list
|
||||
```
|
||||
|
||||
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
|
||||
|
||||
### Watch / Unwatch
|
||||
|
||||
```bash
|
||||
multica workspace watch <workspace-id>
|
||||
multica workspace unwatch <workspace-id>
|
||||
```
|
||||
|
||||
### Get Details
|
||||
|
||||
```bash
|
||||
multica workspace get <workspace-id>
|
||||
multica workspace get <workspace-id> --output json
|
||||
```
|
||||
|
||||
### List Members
|
||||
|
||||
```bash
|
||||
multica workspace members <workspace-id>
|
||||
```
|
||||
|
||||
## Issues
|
||||
|
||||
### List Issues
|
||||
|
||||
```bash
|
||||
multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
```bash
|
||||
multica issue get <id>
|
||||
multica issue get <id> --output json
|
||||
```
|
||||
|
||||
### Create Issue
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
```bash
|
||||
multica issue update <id> --title "New title" --priority urgent
|
||||
```
|
||||
|
||||
### Assign Issue
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
multica issue status <id> in_progress
|
||||
```
|
||||
|
||||
Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`.
|
||||
|
||||
### Comments
|
||||
|
||||
```bash
|
||||
# List comments
|
||||
multica issue comment list <issue-id>
|
||||
|
||||
# Add a comment
|
||||
multica issue comment add <issue-id> --content "Looks good, merging now"
|
||||
|
||||
# Reply to a specific comment
|
||||
multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
|
||||
# Delete a comment
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
### Execution History
|
||||
|
||||
```bash
|
||||
# List all execution runs for an issue
|
||||
multica issue runs <issue-id>
|
||||
multica issue runs <issue-id> --output json
|
||||
|
||||
# View messages for a specific execution run
|
||||
multica issue run-messages <task-id>
|
||||
multica issue run-messages <task-id> --output json
|
||||
|
||||
# Incremental fetch (only messages after a given sequence number)
|
||||
multica issue run-messages <task-id> --since 42 --output json
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### View Config
|
||||
|
||||
```bash
|
||||
multica config show
|
||||
```
|
||||
|
||||
Shows config file path, server URL, app URL, and default workspace.
|
||||
|
||||
### Set Values
|
||||
|
||||
```bash
|
||||
multica config set server_url wss://api.example.com/ws
|
||||
multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
## Other Commands
|
||||
|
||||
```bash
|
||||
multica version # Show CLI version and commit hash
|
||||
multica update # Update to latest version
|
||||
multica agent list # List agents in the current workspace
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
Most commands support `--output` with two formats:
|
||||
|
||||
- `table` — human-readable table (default for list commands)
|
||||
- `json` — structured JSON (useful for scripting and automation)
|
||||
|
||||
```bash
|
||||
multica issue list --output json
|
||||
multica daemon status --output json
|
||||
```
|
||||
75
apps/docs/content/docs/developers/architecture.mdx
Normal file
75
apps/docs/content/docs/developers/architecture.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Architecture
|
||||
description: Technical architecture of the Multica platform.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Multica is a Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
│ Next.js │────>│ Go Backend │────>│ PostgreSQL │
|
||||
│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │
|
||||
└──────────────┘ └──────┬───────┘ └──────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (runs on your machine)
|
||||
│Claude/Codex/ │
|
||||
│OpenClaw/Code │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
| Directory | Purpose | Technology |
|
||||
|-----------|---------|------------|
|
||||
| `server/` | Go backend | Chi router, sqlc for DB, gorilla/websocket |
|
||||
| `apps/web/` | Next.js frontend | App Router |
|
||||
| `apps/desktop/` | Electron desktop app | electron-vite |
|
||||
| `apps/docs/` | Documentation site | Fumadocs |
|
||||
| `packages/core/` | Headless business logic | Zero react-dom, all-platform reuse |
|
||||
| `packages/ui/` | Atomic UI components | Zero business logic, shadcn-based |
|
||||
| `packages/views/` | Shared business pages | Zero next/\*, zero react-router imports |
|
||||
| `packages/tsconfig/` | Shared TypeScript config | — |
|
||||
| `packages/eslint-config/` | Shared ESLint config | — |
|
||||
|
||||
## Backend Structure
|
||||
|
||||
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI + daemon), `migrate`
|
||||
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon)
|
||||
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients, server broadcasts events
|
||||
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256), middleware sets `X-User-ID` and `X-User-Email` headers
|
||||
- **Task lifecycle** (`internal/service/task.go`): enqueue → claim → start → complete/fail
|
||||
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex
|
||||
- **Daemon** (`internal/daemon/`): Auto-detects CLIs, registers runtimes, polls for tasks
|
||||
- **Database**: PostgreSQL 17 with pgvector, sqlc generates code from SQL in `pkg/db/queries/`
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Internal Packages Pattern
|
||||
|
||||
All shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
|
||||
|
||||
### Package Boundaries
|
||||
|
||||
- `packages/core/` — zero react-dom, zero localStorage, zero UI libs. All Zustand stores live here.
|
||||
- `packages/ui/` — pure UI components, zero business logic.
|
||||
- `packages/views/` — zero `next/*`, zero `react-router-dom`. Uses `NavigationAdapter` for routing.
|
||||
|
||||
### State Management
|
||||
|
||||
- **TanStack Query** owns all server state (issues, users, workspaces)
|
||||
- **Zustand** owns all client state (UI selections, filters, drafts)
|
||||
- **React Context** reserved for cross-cutting plumbing (`WorkspaceIdProvider`, `NavigationProvider`)
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
```
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
178
apps/docs/content/docs/developers/contributing.mdx
Normal file
178
apps/docs/content/docs/developers/contributing.mdx
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
title: Contributing
|
||||
description: Local development workflow for contributors working on the Multica codebase.
|
||||
---
|
||||
|
||||
## Development Model
|
||||
|
||||
Local development uses one shared PostgreSQL container and one database per checkout.
|
||||
|
||||
- The main checkout usually uses `.env` and `POSTGRES_DB=multica`
|
||||
- Each Git worktree uses its own `.env.worktree`
|
||||
- Every checkout connects to the same PostgreSQL host: `localhost:5432`
|
||||
- Isolation happens at the database level, not by starting a separate Docker Compose project
|
||||
- Backend and frontend ports are still unique per worktree
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js `v20+`
|
||||
- `pnpm` `v10.28+`
|
||||
- Go `v1.26+`
|
||||
- Docker
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
### Main Checkout
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make setup-main
|
||||
```
|
||||
|
||||
What `make setup-main` does:
|
||||
|
||||
- Installs JavaScript dependencies with `pnpm install`
|
||||
- Ensures the shared PostgreSQL container is running
|
||||
- Creates the application database if it does not exist
|
||||
- Runs all migrations against that database
|
||||
|
||||
Start the app:
|
||||
|
||||
```bash
|
||||
make start-main
|
||||
```
|
||||
|
||||
### Worktree
|
||||
|
||||
From the worktree directory:
|
||||
|
||||
```bash
|
||||
make worktree-env
|
||||
make setup-worktree
|
||||
```
|
||||
|
||||
Start the worktree app:
|
||||
|
||||
```bash
|
||||
make start-worktree
|
||||
```
|
||||
|
||||
## Daily Workflow
|
||||
|
||||
### Main Checkout
|
||||
|
||||
```bash
|
||||
make start-main
|
||||
make stop-main
|
||||
make check-main
|
||||
```
|
||||
|
||||
### Feature Worktree
|
||||
|
||||
```bash
|
||||
git worktree add ../multica-feature -b feat/my-change main
|
||||
cd ../multica-feature
|
||||
make worktree-env
|
||||
make setup-worktree
|
||||
make start-worktree
|
||||
```
|
||||
|
||||
Day-to-day:
|
||||
|
||||
```bash
|
||||
make start-worktree
|
||||
make stop-worktree
|
||||
make check-worktree
|
||||
```
|
||||
|
||||
## Running Main and Worktree Simultaneously
|
||||
|
||||
This is a first-class workflow. Both checkouts use the same PostgreSQL container but different databases and ports:
|
||||
|
||||
| | Main | Worktree |
|
||||
|---|---|---|
|
||||
| Database | `multica` | `multica_my_feature_702` |
|
||||
| Backend port | `8080` | generated (e.g. `18782`) |
|
||||
| Frontend port | `3000` | generated (e.g. `13702`) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Frontend (all commands go through Turborepo)
|
||||
pnpm install
|
||||
pnpm dev:web # Next.js dev server (port 3000)
|
||||
pnpm dev:desktop # Electron dev (electron-vite, HMR)
|
||||
pnpm build # Build all frontend apps
|
||||
pnpm typecheck # TypeScript check
|
||||
pnpm lint # ESLint
|
||||
pnpm test # TS tests (Vitest)
|
||||
|
||||
# Backend (Go)
|
||||
make dev # Run Go server (port 8080)
|
||||
make daemon # Run local daemon
|
||||
make build # Build server + CLI binaries
|
||||
make test # Go tests
|
||||
make sqlc # Regenerate sqlc code
|
||||
make migrate-up # Run database migrations
|
||||
make migrate-down # Rollback migrations
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run all local checks:
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
This runs:
|
||||
|
||||
1. TypeScript typecheck
|
||||
2. TypeScript unit tests
|
||||
3. Go tests
|
||||
4. Playwright E2E tests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Env File
|
||||
|
||||
Create the expected env file:
|
||||
|
||||
```bash
|
||||
# Main checkout
|
||||
cp .env.example .env
|
||||
|
||||
# Worktree
|
||||
make worktree-env
|
||||
```
|
||||
|
||||
### Check Which Database a Checkout Uses
|
||||
|
||||
```bash
|
||||
cat .env # or .env.worktree
|
||||
```
|
||||
|
||||
Look for `POSTGRES_DB`, `DATABASE_URL`, `PORT`, `FRONTEND_PORT`.
|
||||
|
||||
### List All Local Databases
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres psql -U multica -d postgres \
|
||||
-At -c "select datname from pg_database order by datname;"
|
||||
```
|
||||
|
||||
### Destructive Reset
|
||||
|
||||
Stop PostgreSQL and keep local databases:
|
||||
|
||||
```bash
|
||||
make db-down
|
||||
```
|
||||
|
||||
Wipe all local PostgreSQL data:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
> **Warning:** This deletes the shared Docker volume and all databases. After that you must run `make setup-main` or `make setup-worktree` again.
|
||||
4
apps/docs/content/docs/developers/meta.json
Normal file
4
apps/docs/content/docs/developers/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Developers",
|
||||
"pages": ["contributing", "architecture"]
|
||||
}
|
||||
64
apps/docs/content/docs/getting-started/cloud-quickstart.mdx
Normal file
64
apps/docs/content/docs/getting-started/cloud-quickstart.mdx
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Cloud Quickstart
|
||||
description: Get started with Multica Cloud — no setup required.
|
||||
---
|
||||
|
||||
The fastest way to get started with Multica — no setup required.
|
||||
|
||||
## 1. Sign up
|
||||
|
||||
Go to [multica.ai](https://multica.ai) and create an account.
|
||||
|
||||
## 2. Install the CLI and start the daemon
|
||||
|
||||
Give this instruction to your AI agent (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, etc.):
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
```
|
||||
|
||||
Or install manually:
|
||||
|
||||
### macOS / Linux (Homebrew - recommended)
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### macOS / Linux (install script)
|
||||
|
||||
```bash
|
||||
# Install the CLI
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Then configure, authenticate, and start the daemon:
|
||||
|
||||
```bash
|
||||
# Configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
|
||||
## 3. Verify your runtime
|
||||
|
||||
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
|
||||
|
||||
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
|
||||
|
||||
## 4. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
## 5. Assign your first task
|
||||
|
||||
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
|
||||
|
||||
That's it! Your agent is now part of the team.
|
||||
4
apps/docs/content/docs/getting-started/meta.json
Normal file
4
apps/docs/content/docs/getting-started/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Getting Started",
|
||||
"pages": ["cloud-quickstart", "self-hosting"]
|
||||
}
|
||||
396
apps/docs/content/docs/getting-started/self-hosting.mdx
Normal file
396
apps/docs/content/docs/getting-started/self-hosting.mdx
Normal file
@@ -0,0 +1,396 @@
|
||||
---
|
||||
title: Self-Hosting Guide
|
||||
description: Deploy Multica on your own infrastructure.
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Multica has three components:
|
||||
|
||||
| Component | Description | Technology |
|
||||
|-----------|-------------|------------|
|
||||
| **Backend** | REST API + WebSocket server | Go (single binary) |
|
||||
| **Frontend** | Web application | Next.js 16 |
|
||||
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
|
||||
|
||||
Each user who wants to run AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
|
||||
## Quick Install
|
||||
|
||||
Two commands to set up everything:
|
||||
|
||||
```bash
|
||||
# Install CLI + provision self-host server
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
|
||||
# Configure CLI, authenticate, and start the daemon
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
|
||||
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
</Callout>
|
||||
|
||||
<Callout>
|
||||
For a step-by-step setup, see below.
|
||||
</Callout>
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### Step 1 — Start the Server
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make selfhost
|
||||
```
|
||||
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
<Callout>
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
</Callout>
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
|
||||
|
||||
<Callout>
|
||||
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
|
||||
</Callout>
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks.
|
||||
|
||||
### a) Install the CLI and an AI agent
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
You also need at least one AI agent CLI:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini` on PATH)
|
||||
- OpenCode (`opencode` on PATH)
|
||||
- OpenClaw (`openclaw` on PATH)
|
||||
- Hermes (`hermes` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. Configures the CLI to connect to `localhost`
|
||||
2. Opens your browser for authentication
|
||||
3. Discovers your workspaces
|
||||
4. Starts the daemon in the background
|
||||
|
||||
For on-premise deployments with custom domains:
|
||||
|
||||
```bash
|
||||
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
|
||||
```
|
||||
|
||||
Verify the daemon is running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
<Callout>
|
||||
Alternatively, configure step by step: `multica config set server_url http://localhost:8080 && multica config set app_url http://localhost:3000 && multica login && multica daemon start`
|
||||
</Callout>
|
||||
|
||||
### Step 4 — Verify & Start Using
|
||||
|
||||
1. Open your workspace at http://localhost:3000
|
||||
2. Navigate to **Settings → Runtimes** — you should see your machine listed
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent
|
||||
|
||||
## Stopping Services
|
||||
|
||||
```bash
|
||||
# Stop Docker Compose services
|
||||
make selfhost-stop
|
||||
|
||||
# Stop the local daemon
|
||||
multica daemon stop
|
||||
```
|
||||
|
||||
## Switching to Multica Cloud
|
||||
|
||||
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
|
||||
|
||||
```bash
|
||||
multica setup
|
||||
```
|
||||
|
||||
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
|
||||
|
||||
<Callout>
|
||||
Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
</Callout>
|
||||
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is done via environment variables. Copy `.env.example` as a starting point.
|
||||
|
||||
### Required Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
|
||||
### CLI / Daemon
|
||||
|
||||
These are configured on each user's machine, not on the server:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
|
||||
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
|
||||
## Database Setup
|
||||
|
||||
Multica requires PostgreSQL 17 with the pgvector extension.
|
||||
|
||||
### Using the Included Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
This starts a `pgvector/pgvector:pg17` container on port 5432 with default credentials (`multica`/`multica`).
|
||||
|
||||
### Using Your Own PostgreSQL
|
||||
|
||||
Ensure the pgvector extension is available:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
### Running Migrations
|
||||
|
||||
Migrations must be run before starting the server:
|
||||
|
||||
```bash
|
||||
# Using the built binary
|
||||
./server/bin/migrate up
|
||||
|
||||
# Or from source
|
||||
cd server && go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
## Manual Setup (Without Docker Compose)
|
||||
|
||||
If you prefer to build and run services manually:
|
||||
|
||||
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
|
||||
|
||||
```bash
|
||||
# Start your PostgreSQL (or use: docker compose up -d postgres)
|
||||
|
||||
# Build the backend
|
||||
make build
|
||||
|
||||
# Run database migrations
|
||||
DATABASE_URL="your-database-url" ./server/bin/migrate up
|
||||
|
||||
# Start the backend server
|
||||
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
|
||||
```
|
||||
|
||||
For the frontend:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Frontend
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket support
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When using separate domains for frontend and backend, set these environment variables accordingly:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
FRONTEND_ORIGIN=https://app.example.com
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
|
||||
# Frontend
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Pull the latest code or image
|
||||
2. Run migrations: `./server/bin/migrate up`
|
||||
3. Restart the backend and frontend
|
||||
|
||||
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.
|
||||
57
apps/docs/content/docs/guides/agents.mdx
Normal file
57
apps/docs/content/docs/guides/agents.mdx
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Agents
|
||||
description: How AI agents work in Multica — execution model, skills, and runtime guidelines.
|
||||
---
|
||||
|
||||
## Agents as Teammates
|
||||
|
||||
In Multica, agents are first-class citizens. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
|
||||
Assignees are polymorphic — an issue can be assigned to a member or an agent. The `assignee_type` + `assignee_id` fields on issues distinguish between the two. Agents render with distinct styling (purple background, robot icon).
|
||||
|
||||
## Agent Execution Model
|
||||
|
||||
When an agent is assigned a task in Multica:
|
||||
|
||||
1. The daemon detects the task assignment
|
||||
2. It creates an isolated workspace directory
|
||||
3. It spawns the appropriate agent CLI (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes)
|
||||
4. The agent executes autonomously, streaming progress back to Multica
|
||||
5. Results are reported — success, failure, or blockers
|
||||
|
||||
The full task lifecycle is: **enqueue → claim → start → complete/fail**.
|
||||
|
||||
Real-time progress is streamed via WebSocket so you can follow along in the Multica UI.
|
||||
|
||||
## Supported Agent Providers
|
||||
|
||||
| Provider | CLI Command | Description |
|
||||
|----------|-------------|-------------|
|
||||
| Claude Code | `claude` | Anthropic's coding agent |
|
||||
| Codex | `codex` | OpenAI's coding agent |
|
||||
| Gemini CLI | `gemini` | Google's coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
|
||||
The daemon auto-detects which CLIs are available on your PATH and registers them as available runtimes.
|
||||
|
||||
## Reusable Skills
|
||||
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
- Deployments
|
||||
- Migrations
|
||||
- Code reviews
|
||||
- Common patterns
|
||||
|
||||
Your skill library compounds over time. Local skills give individual agents their capabilities; workspace skills align the entire team.
|
||||
|
||||
## Multi-Workspace Support
|
||||
|
||||
Each workspace has its own set of agents, issues, and settings. The daemon can watch multiple workspaces simultaneously, routing tasks to the appropriate agent based on workspace configuration.
|
||||
4
apps/docs/content/docs/guides/meta.json
Normal file
4
apps/docs/content/docs/guides/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Guides",
|
||||
"pages": ["quickstart", "agents"]
|
||||
}
|
||||
30
apps/docs/content/docs/guides/quickstart.mdx
Normal file
30
apps/docs/content/docs/guides/quickstart.mdx
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: Quickstart
|
||||
description: Assign your first task to an agent in under 5 minutes.
|
||||
---
|
||||
|
||||
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent.
|
||||
|
||||
## 1. Set up and start the daemon
|
||||
|
||||
```bash
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) available on your PATH.
|
||||
|
||||
## 2. Verify your runtime
|
||||
|
||||
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
|
||||
|
||||
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
|
||||
|
||||
## 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
## 4. Assign your first task
|
||||
|
||||
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
|
||||
|
||||
That's it! Your agent is now part of the team.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user