mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-11 09:26:42 +02:00
Compare commits
715 Commits
pr/pap-817
...
pap-1469-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ec06d3300 | ||
|
|
6e6f538630 | ||
|
|
12e5049e2f | ||
|
|
54210b428d | ||
|
|
27c5cde7ad | ||
|
|
ab8682d2aa | ||
|
|
1dce216cb3 | ||
|
|
2083888738 | ||
|
|
93df2d7e69 | ||
|
|
4694a15217 | ||
|
|
84f0ffb9dd | ||
|
|
3a02a7de49 | ||
|
|
d2c68b4893 | ||
|
|
8fdfd47af3 | ||
|
|
360a1198c2 | ||
|
|
99ed100981 | ||
|
|
e25a58d1a0 | ||
|
|
335a8786de | ||
|
|
f270f30799 | ||
|
|
dca6823dee | ||
|
|
46af1e5623 | ||
|
|
f1c504bc48 | ||
|
|
2ce37a4f94 | ||
|
|
3638036230 | ||
|
|
2a932a6db0 | ||
|
|
5d1ed71779 | ||
|
|
76fe736e8e | ||
|
|
d6b06788f6 | ||
|
|
6844226572 | ||
|
|
0cb42f49ea | ||
|
|
e59047187b | ||
|
|
1729e41179 | ||
|
|
11de5ae9c9 | ||
|
|
8e82ac7e38 | ||
|
|
be82a912b2 | ||
|
|
ab5eeca94e | ||
|
|
2172476e84 | ||
|
|
c1bb938519 | ||
|
|
b649bd454f | ||
|
|
a692e37f3e | ||
|
|
96637a1e09 | ||
|
|
a5aed931ab | ||
|
|
a63e847525 | ||
|
|
a7dc88941b | ||
|
|
b6115424b1 | ||
|
|
1f78e55072 | ||
|
|
fcab770518 | ||
|
|
2d8f97feb0 | ||
|
|
03a2cf5c8a | ||
|
|
a77206812e | ||
|
|
6208899d0a | ||
|
|
2a84e53c1b | ||
|
|
e1bf9d66a7 | ||
|
|
b48be80d5d | ||
|
|
45ebecab5a | ||
|
|
dae888cc5d | ||
|
|
aaf42f3a7e | ||
|
|
62d05a7ae2 | ||
|
|
1cd0281b4d | ||
|
|
65480ffab1 | ||
|
|
dc94e3d1df | ||
|
|
0162bb332c | ||
|
|
7ec8716159 | ||
|
|
8cb70d897d | ||
|
|
8bdf4081ee | ||
|
|
958c11699e | ||
|
|
c566a9236c | ||
|
|
dab95740be | ||
|
|
548721248e | ||
|
|
f4a05dc35c | ||
|
|
b00d52c5b6 | ||
|
|
ac664df8e4 | ||
|
|
4477ca2a7e | ||
|
|
724893ad5b | ||
|
|
7c42345177 | ||
|
|
0e87fdbe35 | ||
|
|
4077ccd343 | ||
|
|
44d94d0add | ||
|
|
6d63a4df45 | ||
|
|
3cee1f12da | ||
|
|
03dff1a29a | ||
|
|
5d021583be | ||
|
|
b4a58ba8a6 | ||
|
|
da251e5eab | ||
|
|
264eb34f24 | ||
|
|
0191fabdc6 | ||
|
|
b578bf1f51 | ||
|
|
781d9dcf74 | ||
|
|
c7bf2661c9 | ||
|
|
d607ca0089 | ||
|
|
61ed4ef90c | ||
|
|
ce3bc329fc | ||
|
|
11c3eee66b | ||
|
|
0ed3f56935 | ||
|
|
1ac1dbcb3e | ||
|
|
fe21ab324b | ||
|
|
327eadb45c | ||
|
|
996c7eb727 | ||
|
|
9e8cd28f81 | ||
|
|
a4b05d8831 | ||
|
|
de1cd5858d | ||
|
|
efc1e336b0 | ||
|
|
d82468d6e5 | ||
|
|
2ebbad6561 | ||
|
|
f44c951a22 | ||
|
|
e15b5412ec | ||
|
|
ba5cb34bed | ||
|
|
cbc237311f | ||
|
|
1079f21ac4 | ||
|
|
15b0f11275 | ||
|
|
59d913d04b | ||
|
|
e21e442033 | ||
|
|
296033620f | ||
|
|
fad5634b29 | ||
|
|
69ff793c6a | ||
|
|
ede3206423 | ||
|
|
2cf2a44d68 | ||
|
|
58ae23aa2c | ||
|
|
d3e66c789e | ||
|
|
097f30b138 | ||
|
|
c5ccafbb80 | ||
|
|
28a28d1cb6 | ||
|
|
7edd2f7946 | ||
|
|
1a82646e9d | ||
|
|
f75c0c317c | ||
|
|
d0920da459 | ||
|
|
c830c64727 | ||
|
|
2ec2b1f1eb | ||
|
|
9159b44fcc | ||
|
|
f5a87ab14e | ||
|
|
185195201a | ||
|
|
1e4ccb2b1f | ||
|
|
038dd2bb82 | ||
|
|
bac5afa647 | ||
|
|
db1279dc12 | ||
|
|
30dd2b78e5 | ||
|
|
ee82a4f243 | ||
|
|
1cbb0a5e34 | ||
|
|
93355bae6b | ||
|
|
c6779b570f | ||
|
|
27ec1e0c8b | ||
|
|
26d4cabb2e | ||
|
|
3baebee2df | ||
|
|
8894520ed0 | ||
|
|
ec75cabcd8 | ||
|
|
9eaf72ab31 | ||
|
|
844b061267 | ||
|
|
5640d29ab0 | ||
|
|
1de5fb9316 | ||
|
|
372421ef0b | ||
|
|
8e88577371 | ||
|
|
b1e9215375 | ||
|
|
0d270655ab | ||
|
|
5758aba91e | ||
|
|
482dac7097 | ||
|
|
0937f07c79 | ||
|
|
d00860b12a | ||
|
|
4e20279305 | ||
|
|
4bd62471f7 | ||
|
|
56ee63bfd0 | ||
|
|
87db949d3f | ||
|
|
735c591bad | ||
|
|
46892ded18 | ||
|
|
9f9a8cfa25 | ||
|
|
3264f9c1f6 | ||
|
|
642188f900 | ||
|
|
b7a7dacfa3 | ||
|
|
b1e457365b | ||
|
|
fcbae62baf | ||
|
|
cc44d309c0 | ||
|
|
316790ea0a | ||
|
|
8367c5f406 | ||
|
|
0ff262ca0f | ||
|
|
fa3cbc7fdb | ||
|
|
e3804f792d | ||
|
|
3cfbc350a0 | ||
|
|
667d5a7384 | ||
|
|
950ea065ae | ||
|
|
b5e177df7e | ||
|
|
81b96c6021 | ||
|
|
fe96a2f976 | ||
|
|
92f142f7f8 | ||
|
|
34589ad457 | ||
|
|
7dd3661467 | ||
|
|
e09dfb1a2c | ||
|
|
a57f6f48b4 | ||
|
|
29514606bb | ||
|
|
627fbc80ac | ||
|
|
2a372fbe8a | ||
|
|
d8a7342686 | ||
|
|
3574a3bf49 | ||
|
|
f94fe57d10 | ||
|
|
94652c6079 | ||
|
|
9131cc0355 | ||
|
|
f7410673fe | ||
|
|
4a75d05969 | ||
|
|
8ada49f31b | ||
|
|
f593e116c1 | ||
|
|
3fea60c04c | ||
|
|
73abe4c76e | ||
|
|
9cfa37fce3 | ||
|
|
943b851a5e | ||
|
|
f2a2049d17 | ||
|
|
54f93c1f27 | ||
|
|
f55a5e557d | ||
|
|
50a36beec5 | ||
|
|
f559455d92 | ||
|
|
5ae335c42f | ||
|
|
a13ac0d56f | ||
|
|
b0b85e6ba3 | ||
|
|
cb705c9856 | ||
|
|
bce58d353d | ||
|
|
a0333f3e9d | ||
|
|
25d308186d | ||
|
|
0e80e60665 | ||
|
|
0a5ac9affd | ||
|
|
ff333d6828 | ||
|
|
e7fe02c02f | ||
|
|
71d93c79a5 | ||
|
|
cb6e615186 | ||
|
|
be518529b7 | ||
|
|
2e31fb7c91 | ||
|
|
b3e0c31239 | ||
|
|
4b39b0cc14 | ||
|
|
e10baee84c | ||
|
|
3cd9a54d94 | ||
|
|
6e894f27a2 | ||
|
|
93c7493054 | ||
|
|
391afa627f | ||
|
|
47b025c146 | ||
|
|
8b7dafd218 | ||
|
|
700b41f7e1 | ||
|
|
7e78ce0d7e | ||
|
|
aa18aeb1e9 | ||
|
|
b6fe9ebcbc | ||
|
|
53ffa50638 | ||
|
|
ebd45b62cd | ||
|
|
26ebe3b002 | ||
|
|
60744d8a91 | ||
|
|
3a0e71b080 | ||
|
|
1c1d006c5e | ||
|
|
07987d75ad | ||
|
|
aec88f10dd | ||
|
|
45f18d1bee | ||
|
|
2329a33f32 | ||
|
|
74481b1d1e | ||
|
|
cae7cda463 | ||
|
|
2c2e13eac2 | ||
|
|
502d60b2a8 | ||
|
|
f3e5c55f45 | ||
|
|
448e9f2be3 | ||
|
|
48704c6586 | ||
|
|
e2962e6528 | ||
|
|
3e0ab97b12 | ||
|
|
bb980bfb33 | ||
|
|
1e4d252661 | ||
|
|
ac473820a3 | ||
|
|
2c8cb7f519 | ||
|
|
51414be269 | ||
|
|
1de1393413 | ||
|
|
669e5c87cc | ||
|
|
9a150eee65 | ||
|
|
a3ecc086d9 | ||
|
|
85ca675311 | ||
|
|
622a8e44bf | ||
|
|
d71ff903e4 | ||
|
|
492e49e1c0 | ||
|
|
f1bb175584 | ||
|
|
4b654fc81e | ||
|
|
5136381d8f | ||
|
|
0edac73a68 | ||
|
|
b3b9d99519 | ||
|
|
c414790404 | ||
|
|
1b55474a9b | ||
|
|
bf3fba36f2 | ||
|
|
dc842ff7ea | ||
|
|
758219d53f | ||
|
|
2775a5652b | ||
|
|
bd0f56e523 | ||
|
|
977e9f3e9a | ||
|
|
365b6d9bd8 | ||
|
|
6b4f3b56e4 | ||
|
|
c1d0c52985 | ||
|
|
5d6217b70b | ||
|
|
eda127a2b2 | ||
|
|
93e8e6447d | ||
|
|
13ada98e78 | ||
|
|
54ac2c6fe9 | ||
|
|
962a882799 | ||
|
|
2ac1c62ab1 | ||
|
|
2278d96d5a | ||
|
|
aff56c2297 | ||
|
|
612bab1eb6 | ||
|
|
68499eb2f4 | ||
|
|
e9c8bd4805 | ||
|
|
517fe5093e | ||
|
|
bdc8e27bf4 | ||
|
|
8cdba3ce18 | ||
|
|
1a3aee9ee1 | ||
|
|
9a8a169e95 | ||
|
|
bfa60338cc | ||
|
|
1e76bbe38c | ||
|
|
42b326bcc6 | ||
|
|
8f23270f35 | ||
|
|
97d4ce41b3 | ||
|
|
0a9a8b5a44 | ||
|
|
37d2d5ef02 | ||
|
|
55d756f9a3 | ||
|
|
7e34d6c66b | ||
|
|
8be6fe987b | ||
|
|
15bd2ef349 | ||
|
|
08fea10ce1 | ||
|
|
b74d94ba1e | ||
|
|
8f722c5751 | ||
|
|
b6e40fec54 | ||
|
|
a8d1c4b596 | ||
|
|
cd19834fab | ||
|
|
9e2ccc24bb | ||
|
|
fc8e1d1153 | ||
|
|
f3ad1fc301 | ||
|
|
eefe9f39f1 | ||
|
|
8d20510b9a | ||
|
|
5a252020d5 | ||
|
|
4c01a45d2a | ||
|
|
467f3a749a | ||
|
|
9499d0df97 | ||
|
|
dde4cc070e | ||
|
|
a8638619e5 | ||
|
|
2f73346a64 | ||
|
|
785ce54e5e | ||
|
|
73e7007e7c | ||
|
|
c5f3b8e40a | ||
|
|
47299c511e | ||
|
|
ed97432fae | ||
|
|
0593b9b0c5 | ||
|
|
855d895a12 | ||
|
|
39d001c9b5 | ||
|
|
89ad6767c7 | ||
|
|
c171ff901c | ||
|
|
2d129bfede | ||
|
|
2e09570ce0 | ||
|
|
866032eaaa | ||
|
|
81ff9fb311 | ||
|
|
23eea392c8 | ||
|
|
3513b60dbc | ||
|
|
42989115a7 | ||
|
|
7623f679cf | ||
|
|
9be1b3f8a9 | ||
|
|
b380d6000f | ||
|
|
e23d148be1 | ||
|
|
58a1a20f5b | ||
|
|
12011fa9de | ||
|
|
11643941e6 | ||
|
|
8cdb65febb | ||
|
|
2082bb61fe | ||
|
|
21a1e97a81 | ||
|
|
6c8569156c | ||
|
|
c19208010a | ||
|
|
8ae4c0e765 | ||
|
|
22af797ca3 | ||
|
|
27accb1bdb | ||
|
|
b9b2bf3b5b | ||
|
|
4dea302791 | ||
|
|
b825a121cb | ||
|
|
91e040a696 | ||
|
|
e75960f284 | ||
|
|
94d4a01b76 | ||
|
|
fe61e650c2 | ||
|
|
c89349687f | ||
|
|
f515f2aa12 | ||
|
|
5a9a2a9112 | ||
|
|
65818c3447 | ||
|
|
4993b5338c | ||
|
|
d3401c0518 | ||
|
|
dbb5f0c4a9 | ||
|
|
3d685335eb | ||
|
|
2615450afc | ||
|
|
35f2fc7230 | ||
|
|
d9476abecb | ||
|
|
d12650e5ac | ||
|
|
d202631016 | ||
|
|
cd2be692e9 | ||
|
|
c6d2dc8b56 | ||
|
|
80b81459a7 | ||
|
|
a07237779b | ||
|
|
21dd6acb81 | ||
|
|
b81d765d2e | ||
|
|
4efe018a8f | ||
|
|
0651f48f6c | ||
|
|
01c05b5f1b | ||
|
|
c36ea1de6f | ||
|
|
3c4b8711ec | ||
|
|
ef2cbb838f | ||
|
|
fb3aabb743 | ||
|
|
2a2fa31a03 | ||
|
|
8adae848e4 | ||
|
|
00898e8194 | ||
|
|
199a2178cf | ||
|
|
ed95fc1dda | ||
|
|
c757a07708 | ||
|
|
acfd7c260a | ||
|
|
388650afc7 | ||
|
|
d7a7bda209 | ||
|
|
47f3cdc1bb | ||
|
|
69a1593ff8 | ||
|
|
f884cbab78 | ||
|
|
14d59da316 | ||
|
|
e13c3f7c6c | ||
|
|
f8452a4520 | ||
|
|
68b2fe20bb | ||
|
|
1ce800c158 | ||
|
|
aa256fee03 | ||
|
|
112eeafd62 | ||
|
|
258c7ccd21 | ||
|
|
728fbdd199 | ||
|
|
8e42c6cdac | ||
|
|
2af64b6068 | ||
|
|
9b3ad6e616 | ||
|
|
f749efd412 | ||
|
|
f2925ae0df | ||
|
|
37b6ad42ea | ||
|
|
6d73a8a1cb | ||
|
|
acb2bc6b3b | ||
|
|
21ee44e29c | ||
|
|
58db67c318 | ||
|
|
87d46bba57 | ||
|
|
045a3d54b9 | ||
|
|
f467f3d826 | ||
|
|
2ac40aba56 | ||
|
|
72408642b1 | ||
|
|
8db0c7fd2f | ||
|
|
993a3262f6 | ||
|
|
a13a67de54 | ||
|
|
422dd51a87 | ||
|
|
a80edfd6d9 | ||
|
|
65e0d3d672 | ||
|
|
931678db83 | ||
|
|
dda63a4324 | ||
|
|
43fa9c3a9a | ||
|
|
c9ee8e7a7e | ||
|
|
f843a45a84 | ||
|
|
36049beeea | ||
|
|
c041fee6fc | ||
|
|
82290451d4 | ||
|
|
fb3b57ab1f | ||
|
|
ca8d35fd99 | ||
|
|
81a7f79dfd | ||
|
|
ad1ef6a8c6 | ||
|
|
833842b391 | ||
|
|
fd6cfc7149 | ||
|
|
620a5395d7 | ||
|
|
1350753f5f | ||
|
|
50e9f69010 | ||
|
|
38a0cd275e | ||
|
|
bd6d07d0b4 | ||
|
|
3ab7d52f00 | ||
|
|
909e8cd4c8 | ||
|
|
36376968af | ||
|
|
29d0e82dce | ||
|
|
1c1040e219 | ||
|
|
0ec8257563 | ||
|
|
38833304d4 | ||
|
|
85e6371cb6 | ||
|
|
daea94a2ed | ||
|
|
c18b3cb414 | ||
|
|
af844b778e | ||
|
|
53dbcd185e | ||
|
|
f16de6026d | ||
|
|
34044cdfce | ||
|
|
ca5659f734 | ||
|
|
77faf8c668 | ||
|
|
d12e3e3d1a | ||
|
|
2fca400dd9 | ||
|
|
c0d0d03bce | ||
|
|
3db6bdfc3c | ||
|
|
6524dbe08f | ||
|
|
2c1883fc77 | ||
|
|
4abd53c089 | ||
|
|
3c99ab8d01 | ||
|
|
9d6d159209 | ||
|
|
26069682ee | ||
|
|
1e24e6e84c | ||
|
|
9d89d74d70 | ||
|
|
056a5ee32a | ||
|
|
dedd972e3d | ||
|
|
6a7830b07e | ||
|
|
f9cebe9b73 | ||
|
|
9e1ee925cd | ||
|
|
c424f06263 | ||
|
|
77f854c081 | ||
|
|
6c2c63e0f1 | ||
|
|
461779a960 | ||
|
|
6aa3ead238 | ||
|
|
e0f64c04e7 | ||
|
|
9b238d9644 | ||
|
|
e5b2e8b29b | ||
|
|
62d8b39474 | ||
|
|
420cd4fd8d | ||
|
|
b642d3e06b | ||
|
|
5b479652f2 | ||
|
|
99296f95db | ||
|
|
92e03ac4e3 | ||
|
|
ce8d9eb323 | ||
|
|
06cf00129f | ||
|
|
ebc6888e7d | ||
|
|
9f1bb350fe | ||
|
|
46ce546174 | ||
|
|
90889c12d8 | ||
|
|
761dce559d | ||
|
|
41f261eaf5 | ||
|
|
8427043431 | ||
|
|
19aaa54ae4 | ||
|
|
d134d5f3a1 | ||
|
|
98337f5b03 | ||
|
|
477ef78fed | ||
|
|
b0e0f8cd91 | ||
|
|
ccb5cce4ac | ||
|
|
5575399af1 | ||
|
|
2c75c8a1ec | ||
|
|
d8814e938c | ||
|
|
a7cfbc98f3 | ||
|
|
5e65bb2b92 | ||
|
|
d7d01e9819 | ||
|
|
88e742a129 | ||
|
|
db4e146551 | ||
|
|
9684e7bf30 | ||
|
|
a3e125f796 | ||
|
|
2b18fc4007 | ||
|
|
ec1210caaa | ||
|
|
3c66683169 | ||
|
|
c610192c53 | ||
|
|
4d61dbfd34 | ||
|
|
26a974da17 | ||
|
|
8a368e8721 | ||
|
|
c8ab70f2ce | ||
|
|
29da357c5b | ||
|
|
4120016d30 | ||
|
|
fceefe7f09 | ||
|
|
2d31c71fbe | ||
|
|
b5efd8b435 | ||
|
|
fc2be204e2 | ||
|
|
92ebad3d42 | ||
|
|
5310bbd4d8 | ||
|
|
c54b985d9f | ||
|
|
32fe1056e7 | ||
|
|
8e2148e99d | ||
|
|
70702ce74f | ||
|
|
b1b3408efa | ||
|
|
57357991e4 | ||
|
|
50577b8c63 | ||
|
|
1871a602df | ||
|
|
facf994694 | ||
|
|
403aeff7f6 | ||
|
|
7d81e4cb2a | ||
|
|
44f052f4c5 | ||
|
|
c33dcbd202 | ||
|
|
bc61eb84df | ||
|
|
74687553f3 | ||
|
|
4226e15128 | ||
|
|
cfb7dd4818 | ||
|
|
52bb4ea37a | ||
|
|
3986eb615c | ||
|
|
0f9faa297b | ||
|
|
d917375e35 | ||
|
|
ce4536d1fa | ||
|
|
4fd62a3d91 | ||
|
|
25066c967b | ||
|
|
1534b39ee3 | ||
|
|
826da2973d | ||
|
|
4426d96610 | ||
|
|
c8956094ad | ||
|
|
2ec4ba629e | ||
|
|
182b459235 | ||
|
|
94d6ae4049 | ||
|
|
b3d61a7561 | ||
|
|
d9005405b9 | ||
|
|
e3f07aad55 | ||
|
|
2fea39b814 | ||
|
|
0356040a29 | ||
|
|
caa7550e9f | ||
|
|
84d4c328f5 | ||
|
|
11f08ea5d5 | ||
|
|
1f1fe9c989 | ||
|
|
f1ad07616c | ||
|
|
868cfa8c50 | ||
|
|
6793dde597 | ||
|
|
cadfcd1bc6 | ||
|
|
c114ff4dc6 | ||
|
|
84e35b801c | ||
|
|
cbeefbfa5a | ||
|
|
2de691f023 | ||
|
|
41f2a80aa8 | ||
|
|
bb1732dd11 | ||
|
|
15e0e2ece9 | ||
|
|
b7b5d8dae3 | ||
|
|
0ff778ec29 | ||
|
|
b69f0b7dc4 | ||
|
|
b75ac76b13 | ||
|
|
19b6adc415 | ||
|
|
54b05d6d68 | ||
|
|
f83a77f41f | ||
|
|
a3537a86e3 | ||
|
|
5d538d4792 | ||
|
|
dc3aa8f31f | ||
|
|
c98af52590 | ||
|
|
01fb97e8da | ||
|
|
6a72faf83b | ||
|
|
1fd40920db | ||
|
|
caef115b95 | ||
|
|
17e5322e28 | ||
|
|
582f4ceaf4 | ||
|
|
1583a2d65a | ||
|
|
9a70a4edaa | ||
|
|
0ac01a04e5 | ||
|
|
11ff24cd22 | ||
|
|
a5d47166e2 | ||
|
|
eb8c5d93e7 | ||
|
|
af5b980362 | ||
|
|
2e563ccd50 | ||
|
|
2c406d3b8c | ||
|
|
49c7fb7fbd | ||
|
|
995f5b0b66 | ||
|
|
b34fa3b273 | ||
|
|
9ddf960312 | ||
|
|
a8894799e4 | ||
|
|
76a692c260 | ||
|
|
5913706329 | ||
|
|
b944293eda | ||
|
|
3c1ebed539 | ||
|
|
ab0d04ff7a | ||
|
|
6073ac3145 | ||
|
|
3b329467eb | ||
|
|
aa5b2be907 | ||
|
|
dcb66eeae7 | ||
|
|
874fe5ec7d | ||
|
|
c916626cef | ||
|
|
555f026c24 | ||
|
|
e91da556ee | ||
|
|
ab82e3f022 | ||
|
|
c74cda1851 | ||
|
|
fcf3ba6974 | ||
|
|
ed62d58cb2 | ||
|
|
dd8c1ca3b2 | ||
|
|
5ee4cd98e8 | ||
|
|
a6ca3a9418 | ||
|
|
0fd75aa579 | ||
|
|
eaa765118f | ||
|
|
ed73547fb6 | ||
|
|
692105e202 | ||
|
|
01b550d61a | ||
|
|
c6364149b1 | ||
|
|
b0b9809732 | ||
|
|
844b6dfd70 | ||
|
|
0a32e3838a | ||
|
|
e186449f94 | ||
|
|
4bb42005ea | ||
|
|
66aa65f8f7 | ||
|
|
15f6079c6b | ||
|
|
9e9eec9af6 | ||
|
|
1a4ed8c953 | ||
|
|
bd60ea4909 | ||
|
|
6ebfc0ff3d | ||
|
|
083d7c9ac4 | ||
|
|
80766e589c | ||
|
|
c5c6c62bd7 | ||
|
|
1549799c1e | ||
|
|
af1b08fdf4 | ||
|
|
72bc4ab403 | ||
|
|
4c6b9c190b | ||
|
|
f6ac6e47c4 | ||
|
|
623ab1c3ea | ||
|
|
eeec52ad74 | ||
|
|
db3883d2e7 | ||
|
|
9637351880 | ||
|
|
d0e01d2863 | ||
|
|
cbca599625 | ||
|
|
b1d12d2f37 | ||
|
|
0a952dc93d | ||
|
|
ff8b839f42 | ||
|
|
fea892c8b3 | ||
|
|
1696ff0c3f | ||
|
|
4eecd23ea3 | ||
|
|
4da83296a9 | ||
|
|
0ce4134ce1 | ||
|
|
03f44d0089 | ||
|
|
d38d5e1a7b | ||
|
|
add6ca5648 | ||
|
|
04a07080af | ||
|
|
8bebc9599a | ||
|
|
6250d536a0 | ||
|
|
de5985bb75 | ||
|
|
331e1f0d06 | ||
|
|
58c511af9a | ||
|
|
4b668379bc | ||
|
|
f352f3f514 | ||
|
|
4ff460de38 | ||
|
|
06b85d62b2 | ||
|
|
3447e2087a | ||
|
|
44fbf83106 | ||
|
|
eb73fc747a | ||
|
|
5602576ae1 | ||
|
|
c4838cca6e | ||
|
|
67841a0c6d | ||
|
|
a9dcea023b | ||
|
|
14ffbe30a0 | ||
|
|
98a5e287ef | ||
|
|
2735ef1f4a | ||
|
|
53f0988006 | ||
|
|
730a67bb20 | ||
|
|
dd1d9bed80 | ||
|
|
47449152ac | ||
|
|
59b1d1551a | ||
|
|
432d7e72fa | ||
|
|
666ab53648 | ||
|
|
314288ff82 |
230
.agents/skills/deal-with-security-advisory/SKILL.md
Normal file
230
.agents/skills/deal-with-security-advisory/SKILL.md
Normal file
@@ -0,0 +1,230 @@
|
||||
---
|
||||
name: deal-with-security-advisory
|
||||
description: >
|
||||
Handle a GitHub Security Advisory response for Paperclip, including
|
||||
confidential fix development in a temporary private fork, human coordination
|
||||
on advisory-thread comments, CVE request, synchronized advisory publication,
|
||||
and immediate security release steps.
|
||||
---
|
||||
|
||||
# Security Vulnerability Response Instructions
|
||||
|
||||
## ⚠️ CRITICAL: This is a security vulnerability. Everything about this process is confidential until the advisory is published. Do not mention the vulnerability details in any public commit message, PR title, branch name, or comment. Do not push anything to a public branch. Do not discuss specifics in any public channel. Assume anything on the public repo is visible to attackers who will exploit the window between disclosure and user upgrades.
|
||||
|
||||
***
|
||||
|
||||
## Context
|
||||
|
||||
A security vulnerability has been reported via GitHub Security Advisory:
|
||||
|
||||
* **Advisory:** {{ghsaId}} (e.g. GHSA-x8hx-rhr2-9rf7)
|
||||
* **Reporter:** {{reporterHandle}}
|
||||
* **Severity:** {{severity}}
|
||||
* **Notes:** {{notes}}
|
||||
|
||||
***
|
||||
|
||||
## Step 0: Fetch the Advisory Details
|
||||
|
||||
Pull the full advisory so you understand the vulnerability before doing anything else:
|
||||
|
||||
```
|
||||
gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}}
|
||||
|
||||
```
|
||||
|
||||
Read the `description`, `severity`, `cvss`, and `vulnerabilities` fields. Understand the attack vector before writing code.
|
||||
|
||||
## Step 1: Acknowledge the Report
|
||||
|
||||
⚠️ **This step requires a human.** The advisory thread does not have a comment API. Ask the human operator to post a comment on the private advisory thread acknowledging the report. Provide them this template:
|
||||
|
||||
> Thanks for the report, @{{reporterHandle}}. We've confirmed the issue and are working on a fix. We're targeting a patch release within {{timeframe}}. We'll keep you updated here.
|
||||
|
||||
Give your human this template, but still continue
|
||||
|
||||
Below we use `gh` tools - you do have access and credentials outside of your sandbox, so use them.
|
||||
|
||||
## Step 2: Create the Temporary Private Fork
|
||||
|
||||
This is where all fix development happens. Never push to the public repo.
|
||||
|
||||
```
|
||||
gh api --method POST \
|
||||
repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/forks
|
||||
|
||||
```
|
||||
|
||||
This returns a repository object for the private fork. Save the `full_name` and `clone_url`.
|
||||
|
||||
Clone it and set up your workspace:
|
||||
|
||||
```
|
||||
# Clone the private fork somewhere outside ~/paperclip
|
||||
git clone <clone_url_from_response> ~/security-patch-{{ghsaId}}
|
||||
cd ~/security-patch-{{ghsaId}}
|
||||
git checkout -b security-fix
|
||||
|
||||
```
|
||||
|
||||
**Do not edit `~/paperclip`** — the dev server is running off the `~/paperclip` master branch and we don't want to touch it. All work happens in the private fork clone.
|
||||
|
||||
**TIPS:**
|
||||
|
||||
* Do not commit `pnpm-lock.yaml` — the repo has actions to manage this
|
||||
* Do not use descriptive branch names that leak the vulnerability (e.g., no `fix-dns-rebinding-rce`). Use something generic like `security-fix`
|
||||
* All work stays in the private fork until publication
|
||||
* CI/GitHub Actions will NOT run on the temporary private fork — this is a GitHub limitation by design. You must run tests locally
|
||||
|
||||
## Step 3: Develop and Validate the Fix
|
||||
|
||||
Write the patch. Same content standards as any PR:
|
||||
|
||||
* It must functionally work — **run tests locally** since CI won't run on the private fork
|
||||
* Consider the whole codebase, not just the narrow vulnerability path. A patch that fixes one vector but opens another is worse than no patch
|
||||
* Ensure backwards compatibility for the database, or be explicit about what breaks
|
||||
* Make sure any UI components still look correct if the fix touches them
|
||||
* The fix should be minimal and focused — don't bundle unrelated changes into a security patch. Reviewers (and the reporter) should be able to read the diff and understand exactly what changed and why
|
||||
|
||||
**Specific to security fixes:**
|
||||
|
||||
* Verify the fix actually closes the attack vector described in the advisory. Reproduce the vulnerability first (using the reporter's description), then confirm the patch prevents it
|
||||
* Consider adjacent attack vectors — if DNS rebinding is the issue, are there other endpoints or modes with the same class of problem?
|
||||
* Do not introduce new dependencies unless absolutely necessary — new deps in a security patch raise eyebrows
|
||||
|
||||
Push your fix to the private fork:
|
||||
|
||||
```
|
||||
git add -A
|
||||
git commit -m "Fix security vulnerability"
|
||||
git push origin security-fix
|
||||
|
||||
```
|
||||
|
||||
## Step 4: Coordinate with the Reporter
|
||||
|
||||
⚠️ **This step requires a human.** Ask the human operator to post on the advisory thread letting the reporter know the fix is ready and giving them a chance to review. Provide them this template:
|
||||
|
||||
> @{{reporterHandle}} — fix is ready in the private fork if you'd like to review before we publish. Planning to release within {{timeframe}}.
|
||||
|
||||
Proceed
|
||||
|
||||
## Step 5: Request a CVE
|
||||
|
||||
This makes vulnerability scanners (npm audit, Snyk, Dependabot) warn users to upgrade. Without it, nobody gets automated notification.
|
||||
|
||||
```
|
||||
gh api --method POST \
|
||||
repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/cve
|
||||
|
||||
```
|
||||
|
||||
GitHub is a CVE Numbering Authority and will assign one automatically. The CVE may take a few hours to propagate after the advisory is published.
|
||||
|
||||
## Step 6: Publish Everything Simultaneously
|
||||
|
||||
This all happens at once — do not stagger these steps. The goal is **zero window** between the vulnerability becoming public knowledge and the fix being available.
|
||||
|
||||
### 6a. Verify reporter credit before publishing
|
||||
|
||||
```
|
||||
gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} --jq '.credits'
|
||||
|
||||
```
|
||||
|
||||
If the reporter is not credited, add them:
|
||||
|
||||
```
|
||||
gh api --method PATCH \
|
||||
repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \
|
||||
--input - << 'EOF'
|
||||
{
|
||||
"credits": [
|
||||
{
|
||||
"login": "{{reporterHandle}}",
|
||||
"type": "reporter"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
```
|
||||
|
||||
### 6b. Update the advisory with the patched version and publish
|
||||
|
||||
```
|
||||
gh api --method PATCH \
|
||||
repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \
|
||||
--input - << 'EOF'
|
||||
{
|
||||
"state": "published",
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"package": {
|
||||
"ecosystem": "npm",
|
||||
"name": "paperclip"
|
||||
},
|
||||
"vulnerable_version_range": "< {{patchedVersion}}",
|
||||
"patched_versions": "{{patchedVersion}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
```
|
||||
|
||||
Publishing the advisory simultaneously:
|
||||
|
||||
* Makes the GHSA public
|
||||
* Merges the temporary private fork into your repo
|
||||
* Triggers the CVE assignment (if requested in step 5)
|
||||
|
||||
### 6c. Cut a release immediately after merge
|
||||
|
||||
```
|
||||
cd ~/paperclip
|
||||
git pull origin master
|
||||
|
||||
gh release create v{{patchedVersion}} \
|
||||
--repo paperclipai/paperclip \
|
||||
--title "v{{patchedVersion}} — Security Release" \
|
||||
--notes "## Security Release
|
||||
|
||||
This release fixes a critical security vulnerability.
|
||||
|
||||
### What was fixed
|
||||
{{briefDescription}} (e.g., Remote code execution via DNS rebinding in \`local_trusted\` mode)
|
||||
|
||||
### Advisory
|
||||
https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}
|
||||
|
||||
### Credit
|
||||
Thanks to @{{reporterHandle}} for responsibly disclosing this vulnerability.
|
||||
|
||||
### Action required
|
||||
All users running versions prior to {{patchedVersion}} should upgrade immediately."
|
||||
|
||||
```
|
||||
|
||||
## Step 7: Post-Publication Verification
|
||||
|
||||
```
|
||||
# Verify the advisory is published and CVE is assigned
|
||||
gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \
|
||||
--jq '{state: .state, cve_id: .cve_id, published_at: .published_at}'
|
||||
|
||||
# Verify the release exists
|
||||
gh release view v{{patchedVersion}} --repo paperclipai/paperclip
|
||||
|
||||
```
|
||||
|
||||
If the CVE hasn't been assigned yet, that's normal — it can take a few hours.
|
||||
|
||||
⚠️ **Human step:** Ask the human operator to post a final comment on the advisory thread confirming publication and thanking the reporter.
|
||||
|
||||
Tell the human operator what you did by posting a comment to this task, including:
|
||||
|
||||
* The published advisory URL: `https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}`
|
||||
* The release URL
|
||||
* Whether the CVE has been assigned yet
|
||||
* All URLs to any pull requests or branches
|
||||
209
.agents/skills/prcheckloop/SKILL.md
Normal file
209
.agents/skills/prcheckloop/SKILL.md
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: prcheckloop
|
||||
description: >
|
||||
Iteratively gets a GitHub pull request's checks green. Detects the PR for the
|
||||
current branch or uses a provided PR number, waits for every check on the
|
||||
latest head SHA to appear and finish, investigates failing checks, fixes
|
||||
actionable code or test issues, pushes, and repeats. Escalates with a precise
|
||||
blocker when failures are external, flaky, or not safely fixable. Use when a
|
||||
PR still has unsuccessful checks after review fixes, including after greploop.
|
||||
---
|
||||
|
||||
# PRCheckloop
|
||||
|
||||
Get a GitHub PR to a fully green check state, or exit with a concrete blocker.
|
||||
|
||||
## Scope
|
||||
|
||||
- GitHub PRs only. If the repo is GitLab, stop and use `check-pr`.
|
||||
- Focus on checks for the latest PR head SHA, not old commits.
|
||||
- Focus on CI/status checks, not review comments or PR template cleanup.
|
||||
- If the user also wants review-comment cleanup, pair this with `check-pr`.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **PR number** (optional): If not provided, detect the PR for the current branch.
|
||||
- **Max iterations**: default `5`.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the PR
|
||||
|
||||
If no PR number is provided, detect it from the current branch:
|
||||
|
||||
```bash
|
||||
gh pr view --json number,headRefName,headRefOid,url,isDraft
|
||||
```
|
||||
|
||||
If needed, switch to the PR branch before making changes.
|
||||
|
||||
Stop early if:
|
||||
|
||||
- `gh` is not authenticated
|
||||
- there is no PR for the branch
|
||||
- the repo is not hosted on GitHub
|
||||
|
||||
### 2. Track the latest head SHA
|
||||
|
||||
Always work against the current PR head SHA:
|
||||
|
||||
```bash
|
||||
PR_JSON=$(gh pr view "$PR_NUMBER" --json number,headRefName,headRefOid,url)
|
||||
HEAD_SHA=$(echo "$PR_JSON" | jq -r .headRefOid)
|
||||
PR_URL=$(echo "$PR_JSON" | jq -r .url)
|
||||
```
|
||||
|
||||
Ignore failing checks from older SHAs. After every push, refresh `HEAD_SHA` and
|
||||
restart the inspection loop.
|
||||
|
||||
### 3. Inventory checks for that SHA
|
||||
|
||||
Fetch both GitHub check runs and legacy commit status contexts:
|
||||
|
||||
```bash
|
||||
gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/check-runs?per_page=100"
|
||||
gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/status"
|
||||
```
|
||||
|
||||
For a compact PR-level view, this GraphQL payload is useful:
|
||||
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
query($owner:String!, $repo:String!, $pr:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$pr) {
|
||||
headRefOid
|
||||
url
|
||||
statusCheckRollup {
|
||||
contexts(first:100) {
|
||||
nodes {
|
||||
__typename
|
||||
... on CheckRun { name status conclusion detailsUrl workflowName }
|
||||
... on StatusContext { context state targetUrl description }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner=OWNER -F repo=REPO -F pr="$PR_NUMBER"
|
||||
```
|
||||
|
||||
### 4. Wait for checks to actually run
|
||||
|
||||
After a new push, checks can take a moment to appear. Poll every 15-30 seconds
|
||||
until one of these is true:
|
||||
|
||||
- checks have appeared and every item is in a terminal state
|
||||
- checks have appeared and at least one failed
|
||||
- no checks appear after a reasonable wait, usually 2 minutes
|
||||
|
||||
Treat these as terminal success states:
|
||||
|
||||
- check runs: `SUCCESS`, `NEUTRAL`, `SKIPPED`
|
||||
- status contexts: `SUCCESS`
|
||||
|
||||
Treat these as pending:
|
||||
|
||||
- check runs: `QUEUED`, `PENDING`, `WAITING`, `REQUESTED`, `IN_PROGRESS`
|
||||
- status contexts: `PENDING`
|
||||
|
||||
Treat these as failures:
|
||||
|
||||
- check runs: `FAILURE`, `TIMED_OUT`, `CANCELLED`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`
|
||||
- status contexts: `FAILURE`, `ERROR`
|
||||
|
||||
If no checks appear for the latest SHA, inspect `.github/workflows/`, workflow
|
||||
path filters, and branch protection expectations. If the missing check cannot be
|
||||
caused or fixed from the repo, escalate.
|
||||
|
||||
### 5. Investigate failing checks
|
||||
|
||||
For GitHub Actions failures, inspect runs and failed logs for the current SHA:
|
||||
|
||||
```bash
|
||||
gh run list --commit "$HEAD_SHA" --json databaseId,workflowName,status,conclusion,url,headSha
|
||||
gh run view <RUN_ID> --json databaseId,name,workflowName,status,conclusion,jobs,url,headSha
|
||||
gh run view <RUN_ID> --log-failed
|
||||
```
|
||||
|
||||
For each failing check, classify it:
|
||||
|
||||
| Failure type | Action |
|
||||
|---|---|
|
||||
| Code/test regression | Reproduce locally, fix, and verify |
|
||||
| Lint/type/build mismatch | Run the matching local command from the workflow and fix it |
|
||||
| Flake or transient infra issue | Rerun once if evidence supports flakiness |
|
||||
| External service/status app failure | Escalate with the details URL and owner guess |
|
||||
| Missing secret/permission/branch protection issue | Escalate immediately |
|
||||
|
||||
Only rerun a failed job once without code changes. Do not loop on reruns.
|
||||
|
||||
### 6. Fix actionable failures
|
||||
|
||||
If the failure is actionable from the checked-out code:
|
||||
|
||||
1. Read the workflow or failing command to identify the real gate.
|
||||
2. Reproduce locally where reasonable.
|
||||
3. Make the smallest correct fix.
|
||||
4. Run focused verification first, then broader verification if needed.
|
||||
5. Commit in a logical commit.
|
||||
6. Push before re-checking the PR.
|
||||
|
||||
Do not stop at a local fix. The loop is only complete when the remote PR checks
|
||||
for the new head SHA are green.
|
||||
|
||||
### 7. Push and repeat
|
||||
|
||||
After each fix:
|
||||
|
||||
```bash
|
||||
git push
|
||||
sleep 5
|
||||
```
|
||||
|
||||
Then refresh the PR metadata, get the new `HEAD_SHA`, and restart from Step 3.
|
||||
|
||||
Exit the loop only when:
|
||||
|
||||
- all checks for the latest head SHA are green, or
|
||||
- a blocker remains after reasonable repair effort, or
|
||||
- the max iteration count is reached
|
||||
|
||||
### 8. Escalate blockers precisely
|
||||
|
||||
If you cannot get the PR green, report:
|
||||
|
||||
- PR URL
|
||||
- latest head SHA
|
||||
- exact failing or missing check names
|
||||
- details URLs
|
||||
- what you already tried
|
||||
- why it is blocked
|
||||
- who should likely unblock it
|
||||
- the next concrete action
|
||||
|
||||
Good blocker examples:
|
||||
|
||||
- external status app outage
|
||||
- missing GitHub secret or permission
|
||||
- required check name mismatch in branch protection
|
||||
- persistent flake after one rerun
|
||||
- failure needs credentials or infrastructure access you do not have
|
||||
|
||||
## Output
|
||||
|
||||
When the skill completes, report:
|
||||
|
||||
- PR URL and branch
|
||||
- final head SHA
|
||||
- green/pending/failing check summary
|
||||
- fixes made and verification run
|
||||
- whether changes were pushed
|
||||
- blocker summary if not fully green
|
||||
|
||||
## Notes
|
||||
|
||||
- This skill is intentionally narrower than `check-pr`: it is a repair loop for
|
||||
PR checks.
|
||||
- This skill complements `greploop`: Greptile can be perfect while CI is still
|
||||
red.
|
||||
@@ -1,3 +1,4 @@
|
||||
DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
|
||||
PORT=3100
|
||||
SERVE_UI=false
|
||||
BETTER_AUTH_SECRET=paperclip-dev-secret
|
||||
|
||||
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -8,3 +8,10 @@ scripts/rollback-latest.sh @cryppadotta @devinfoley
|
||||
doc/RELEASING.md @cryppadotta @devinfoley
|
||||
doc/PUBLISHING.md @cryppadotta @devinfoley
|
||||
doc/RELEASE-AUTOMATION-SETUP.md @cryppadotta @devinfoley
|
||||
|
||||
# Package files — dependency changes require review
|
||||
# package.json matches recursively at all depths (covers root + all workspaces)
|
||||
package.json @cryppadotta @devinfoley
|
||||
pnpm-lock.yaml @cryppadotta @devinfoley
|
||||
pnpm-workspace.yaml @cryppadotta @devinfoley
|
||||
.npmrc @cryppadotta @devinfoley
|
||||
|
||||
16
.github/PULL_REQUEST_TEMPLATE.md
vendored
16
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -38,9 +38,25 @@
|
||||
|
||||
-
|
||||
|
||||
## Model Used
|
||||
|
||||
<!--
|
||||
Required. Specify which AI model was used to produce or assist with
|
||||
this change. Be as descriptive as possible — include:
|
||||
• Provider and model name (e.g., Claude, GPT, Gemini, Codex)
|
||||
• Exact model ID or version (e.g., claude-opus-4-6, gpt-4-turbo-2024-04-09)
|
||||
• Context window size if relevant (e.g., 1M context)
|
||||
• Reasoning/thinking mode if applicable (e.g., extended thinking, chain-of-thought)
|
||||
• Any other relevant capability details (e.g., tool use, code execution)
|
||||
If no AI model was used, write "None — human-authored".
|
||||
-->
|
||||
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have included a thinking path that traces from project context to this change
|
||||
- [ ] I have specified the model used (with version and capability details)
|
||||
- [ ] 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
|
||||
|
||||
40
.github/workflows/pr.yml
vendored
40
.github/workflows/pr.yml
vendored
@@ -40,6 +40,46 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Validate Dockerfile deps stage
|
||||
run: |
|
||||
missing=0
|
||||
|
||||
# Extract only the deps stage from the Dockerfile
|
||||
deps_stage="$(awk '/^FROM .* AS deps$/{found=1; next} found && /^FROM /{exit} found{print}' Dockerfile)"
|
||||
|
||||
if [ -z "$deps_stage" ]; then
|
||||
echo "::error::Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Derive workspace search roots from pnpm-workspace.yaml (exclude dev-only packages)
|
||||
search_roots="$(grep '^ *- ' pnpm-workspace.yaml | sed 's/^ *- //' | sed 's/\*$//' | grep -v 'examples' | grep -v 'create-paperclip-plugin' | tr '\n' ' ')"
|
||||
|
||||
if [ -z "$search_roots" ]; then
|
||||
echo "::error::Could not derive workspace roots from pnpm-workspace.yaml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check all workspace package.json files are copied in the deps stage
|
||||
for pkg in $(find $search_roots -maxdepth 2 -name package.json -not -path '*/examples/*' -not -path '*/create-paperclip-plugin/*' -not -path '*/node_modules/*' 2>/dev/null | sort -u); do
|
||||
dir="$(dirname "$pkg")"
|
||||
if ! echo "$deps_stage" | grep -q "^COPY ${dir}/package.json"; then
|
||||
echo "::error::Dockerfile deps stage missing: COPY ${pkg} ${dir}/"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check patches directory is copied if it exists
|
||||
if [ -d patches ] && ! echo "$deps_stage" | grep -q '^COPY patches/'; then
|
||||
echo "::error::Dockerfile deps stage missing: COPY patches/ patches/"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if [ "$missing" -eq 1 ]; then
|
||||
echo "Dockerfile deps stage is out of sync. Update it to include the missing files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate dependency resolution when manifests change
|
||||
run: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
|
||||
33
.github/workflows/refresh-lockfile.yml
vendored
33
.github/workflows/refresh-lockfile.yml
vendored
@@ -54,10 +54,11 @@ jobs:
|
||||
id: upsert-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
if git diff --quiet -- pnpm-lock.yaml; then
|
||||
echo "Lockfile unchanged, nothing to do."
|
||||
echo "pr_created=false" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_url=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -70,28 +71,26 @@ jobs:
|
||||
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
|
||||
git push --force origin "$BRANCH"
|
||||
|
||||
# Create PR if one doesn't already exist
|
||||
existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
|
||||
if [ -z "$existing" ]; then
|
||||
gh pr create \
|
||||
# Only reuse an open PR from this repository owner, not a fork with the same branch name.
|
||||
pr_url="$(
|
||||
gh pr list --state open --head "$BRANCH" --json url,headRepositoryOwner \
|
||||
--jq ".[] | select(.headRepositoryOwner.login == \"$REPO_OWNER\") | .url" |
|
||||
head -n 1
|
||||
)"
|
||||
if [ -z "$pr_url" ]; then
|
||||
pr_url="$(gh pr create \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(lockfile): refresh pnpm-lock.yaml" \
|
||||
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml."
|
||||
echo "Created new PR."
|
||||
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml.")"
|
||||
echo "Created new PR: $pr_url"
|
||||
else
|
||||
echo "PR #$existing already exists, branch updated via force push."
|
||||
echo "PR already exists: $pr_url"
|
||||
fi
|
||||
echo "pr_created=true" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Enable auto-merge for lockfile PR
|
||||
if: steps.upsert-pr.outputs.pr_created == 'true'
|
||||
if: steps.upsert-pr.outputs.pr_url != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
pr_url="$(gh pr list --head chore/refresh-lockfile --json url --jq '.[0].url')"
|
||||
if [ -z "$pr_url" ]; then
|
||||
echo "Error: lockfile PR was not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh pr merge --auto --squash --delete-branch "$pr_url"
|
||||
gh pr merge --auto --squash --delete-branch "${{ steps.upsert-pr.outputs.pr_url }}"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ server/src/**/*.js.map
|
||||
server/src/**/*.d.ts
|
||||
server/src/**/*.d.ts.map
|
||||
tmp/
|
||||
feedback-export-*
|
||||
|
||||
# Editor / tool temp files
|
||||
*.tmp
|
||||
|
||||
4
.mailmap
4
.mailmap
@@ -1 +1,3 @@
|
||||
Dotta <bippadotta@protonmail.com> Forgotten <forgottenrunes@protonmail.com>
|
||||
Dotta <bippadotta@protonmail.com> <34892728+cryppadotta@users.noreply.github.com>
|
||||
Dotta <bippadotta@protonmail.com> <forgottenrunes@protonmail.com>
|
||||
Dotta <bippadotta@protonmail.com> <dotta@example.com>
|
||||
|
||||
62
AGENTS.md
62
AGENTS.md
@@ -26,6 +26,9 @@ Before making changes, read in this order:
|
||||
- `ui/`: React + Vite board UI
|
||||
- `packages/db/`: Drizzle schema, migrations, DB clients
|
||||
- `packages/shared/`: shared types, constants, validators, API path constants
|
||||
- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.)
|
||||
- `packages/adapter-utils/`: shared adapter utilities
|
||||
- `packages/plugins/`: plugin system packages
|
||||
- `doc/`: operational and product docs
|
||||
|
||||
## 4. Dev Setup (Auto DB)
|
||||
@@ -78,8 +81,8 @@ If you change schema/API behavior, update all impacted layers:
|
||||
4. Do not replace strategic docs wholesale unless asked.
|
||||
Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned.
|
||||
|
||||
5. Keep plan docs dated and centralized.
|
||||
New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames.
|
||||
5. Keep repo plan docs dated and centralized.
|
||||
When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file.
|
||||
|
||||
## 6. Database Change Workflow
|
||||
|
||||
@@ -135,7 +138,18 @@ When adding endpoints:
|
||||
- Use company selection context for company-scoped pages
|
||||
- Surface failures clearly; do not silently ignore API errors
|
||||
|
||||
## 10. Definition of Done
|
||||
## 10. Pull Request Requirements
|
||||
|
||||
When creating a pull request (via `gh pr create` or any other method), you **must** read and fill in every section of [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). Do not craft ad-hoc PR bodies — use the template as the structure for your PR description. Required sections:
|
||||
|
||||
- **Thinking Path** — trace reasoning from project context to this change (see `CONTRIBUTING.md` for examples)
|
||||
- **What Changed** — bullet list of concrete changes
|
||||
- **Verification** — how a reviewer can confirm it works
|
||||
- **Risks** — what could go wrong
|
||||
- **Model Used** — the AI model that produced or assisted with the change (provider, exact model ID, context window, capabilities). Write "None — human-authored" if no AI was used.
|
||||
- **Checklist** — all items checked
|
||||
|
||||
## 11. Definition of Done
|
||||
|
||||
A change is done when all are true:
|
||||
|
||||
@@ -143,3 +157,45 @@ A change is done when all are true:
|
||||
2. Typecheck, tests, and build pass
|
||||
3. Contracts are synced across db/shared/server/ui
|
||||
4. Docs updated when behavior or commands change
|
||||
5. PR description follows the [PR template](.github/PULL_REQUEST_TEMPLATE.md) with all sections filled in (including Model Used)
|
||||
|
||||
## 11. Fork-Specific: HenkDz/paperclip
|
||||
|
||||
This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)).
|
||||
|
||||
### Branch Strategy
|
||||
|
||||
- `feat/externalize-hermes-adapter` → core has **no** `hermes-paperclip-adapter` dependency and **no** built-in `hermes_local` registration. Install Hermes via the Adapter Plugin manager (`@henkey/hermes-paperclip-adapter` or a `file:` path).
|
||||
- Older fork branches may still document built-in Hermes; treat this file as authoritative for the externalize branch.
|
||||
|
||||
### Hermes (plugin only)
|
||||
|
||||
- Register through **Board → Adapter manager** (same as Droid). Type remains `hermes_local` once the package is loaded.
|
||||
- UI uses generic **config-schema** + **ui-parser.js** from the package — no Hermes imports in `server/` or `ui/` source.
|
||||
- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` for local dev of the adapter repo.
|
||||
|
||||
### Local Dev
|
||||
|
||||
- Fork runs on port 3101+ (auto-detects if 3100 is taken by upstream instance)
|
||||
- `npx vite build` hangs on NTFS — use `node node_modules/vite/bin/vite.js build` instead
|
||||
- Server startup from NTFS takes 30-60s — don't assume failure immediately
|
||||
- Kill ALL paperclip processes before starting: `pkill -f "paperclip"; pkill -f "tsx.*index.ts"`
|
||||
- Vite cache survives `rm -rf dist` — delete both: `rm -rf ui/dist ui/node_modules/.vite`
|
||||
|
||||
### Fork QoL Patches (not in upstream)
|
||||
|
||||
These are local modifications in the fork's UI. If re-copying source, these must be re-applied:
|
||||
|
||||
1. **stderr_group** — amber accordion for MCP init noise in `RunTranscriptView.tsx`
|
||||
2. **tool_group** — accordion for consecutive non-terminal tools (write, read, search, browser)
|
||||
3. **Dashboard excerpt** — `LatestRunCard` strips markdown, shows first 3 lines/280 chars
|
||||
|
||||
### Plugin System
|
||||
|
||||
PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See root `AGENTS.md` for full details.
|
||||
|
||||
- Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json`
|
||||
- The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading
|
||||
- `createServerAdapter()` must include ALL optional fields (especially `detectModel`)
|
||||
- Built-in UI adapters can shadow external plugin parsers — remove built-in when fully externalizing
|
||||
- Reference external adapters: Hermes (`@henkey/hermes-paperclip-adapter` or `file:`) and Droid (npm)
|
||||
|
||||
@@ -11,8 +11,9 @@ We really appreciate both small fixes and thoughtful larger changes.
|
||||
- Pick **one** clear thing to fix/improve
|
||||
- Touch the **smallest possible number of files**
|
||||
- Make sure the change is very targeted and easy to review
|
||||
- All automated checks pass (including Greptile comments)
|
||||
- No new lint/test failures
|
||||
- All tests pass and CI is green
|
||||
- Greptile score is 5/5 with all comments addressed
|
||||
- Use the [PR template](.github/PULL_REQUEST_TEMPLATE.md)
|
||||
|
||||
These almost always get merged quickly when they're clean.
|
||||
|
||||
@@ -26,11 +27,30 @@ These almost always get merged quickly when they're clean.
|
||||
- Before / After screenshots (or short video if UI/behavior change)
|
||||
- Clear description of what & why
|
||||
- Proof it works (manual testing notes)
|
||||
- All tests passing
|
||||
- All Greptile + other PR comments addressed
|
||||
- All tests passing and CI green
|
||||
- Greptile score 5/5 with all comments addressed
|
||||
- [PR template](.github/PULL_REQUEST_TEMPLATE.md) fully filled out
|
||||
|
||||
PRs that follow this path are **much** more likely to be accepted, even when they're large.
|
||||
|
||||
## PR Requirements (all PRs)
|
||||
|
||||
### Use the PR Template
|
||||
|
||||
Every pull request **must** follow the PR template at [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). If you create a PR via the GitHub API or other tooling that bypasses the template, copy its contents into your PR description manually. The template includes required sections: Thinking Path, What Changed, Verification, Risks, Model Used, and a Checklist.
|
||||
|
||||
### Model Used (Required)
|
||||
|
||||
Every PR must include a **Model Used** section specifying which AI model produced or assisted with the change. Include the provider, exact model ID/version, context window size, and any relevant capability details (e.g., reasoning mode, tool use). If no AI was used, write "None — human-authored". This applies to all contributors — human and AI alike.
|
||||
|
||||
### Tests Must Pass
|
||||
|
||||
All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing.
|
||||
|
||||
### Greptile Review
|
||||
|
||||
We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** with **all Greptile comments addressed** before it can be merged. If Greptile leaves comments, fix or respond to each one and request a re-review.
|
||||
|
||||
## General Rules (both paths)
|
||||
|
||||
- Write clear commit messages
|
||||
@@ -41,7 +61,7 @@ PRs that follow this path are **much** more likely to be accepted, even when the
|
||||
|
||||
## Writing a Good PR message
|
||||
|
||||
Please include a "thinking path" at the top of your PR message that explains from the top of the project down to what you fixed. E.g.:
|
||||
Your PR description must follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md). All sections are required. The "thinking path" at the top explains from the top of the project down to what you fixed. E.g.:
|
||||
|
||||
### Thinking Path Example 1:
|
||||
|
||||
|
||||
35
Dockerfile
35
Dockerfile
@@ -1,8 +1,23 @@
|
||||
FROM node:lts-trixie-slim AS base
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable
|
||||
&& apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \
|
||||
&& mkdir -p -m 755 /etc/apt/keyrings \
|
||||
&& wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
&& echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \
|
||||
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& mkdir -p -m 755 /etc/apt/sources.list.d \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gh \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable
|
||||
|
||||
# Modify the existing node user/group to have the specified UID/GID to match host user
|
||||
RUN usermod -u $USER_UID --non-unique node \
|
||||
&& groupmod -g $USER_GID --non-unique node \
|
||||
&& usermod -g $USER_GID -d /paperclip node
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
@@ -13,6 +28,7 @@ COPY ui/package.json ui/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/mcp-server/package.json packages/mcp-server/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||
@@ -21,6 +37,7 @@ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-
|
||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
||||
COPY patches/ patches/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -34,12 +51,17 @@ RUN pnpm --filter @paperclipai/server build
|
||||
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
|
||||
|
||||
FROM base AS production
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
&& mkdir -p /paperclip \
|
||||
&& chown node:node /paperclip
|
||||
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOME=/paperclip \
|
||||
HOST=0.0.0.0 \
|
||||
@@ -47,12 +69,15 @@ ENV NODE_ENV=production \
|
||||
SERVE_UI=true \
|
||||
PAPERCLIP_HOME=/paperclip \
|
||||
PAPERCLIP_INSTANCE_ID=default \
|
||||
USER_UID=${USER_UID} \
|
||||
USER_GID=${USER_GID} \
|
||||
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
|
||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=private
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=private \
|
||||
OPENCODE_ALLOW_ALL_MODELS=true
|
||||
|
||||
VOLUME ["/paperclip"]
|
||||
EXPOSE 3100
|
||||
|
||||
USER node
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
|
||||
|
||||
55
README.md
55
README.md
@@ -177,6 +177,16 @@ Open source. Self-hosted. No Paperclip account required.
|
||||
npx paperclipai onboard --yes
|
||||
```
|
||||
|
||||
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
|
||||
|
||||
```bash
|
||||
npx paperclipai onboard --yes --bind lan
|
||||
# or:
|
||||
npx paperclipai onboard --yes --bind tailnet
|
||||
```
|
||||
|
||||
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
@@ -234,16 +244,47 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ⚪ Get OpenClaw onboarding easier
|
||||
- ⚪ Get cloud agents working e.g. Cursor / e2b agents
|
||||
- ⚪ ClipMart - buy and sell entire agent companies
|
||||
- ⚪ Easy agent configurations / easier to understand
|
||||
- ⚪ Better support for harness engineering
|
||||
- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||
- ⚪ Better docs
|
||||
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
|
||||
- ✅ Get OpenClaw / claw-style agent employees
|
||||
- ✅ companies.sh - import and export entire organizations
|
||||
- ✅ Easy AGENTS.md configurations
|
||||
- ✅ Skills Manager
|
||||
- ✅ Scheduled Routines
|
||||
- ✅ Better Budgeting
|
||||
- ✅ Agent Reviews and Approvals
|
||||
- ⚪ Multiple Human Users
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||
- ⚪ Artifacts & Work Products
|
||||
- ⚪ Memory & Knowledge
|
||||
- ⚪ Enforced Outcomes
|
||||
- ⚪ MAXIMIZER MODE
|
||||
- ⚪ Deep Planning
|
||||
- ⚪ Work Queues
|
||||
- ⚪ Self-Organization
|
||||
- ⚪ Automatic Organizational Learning
|
||||
- ⚪ CEO Chat
|
||||
- ⚪ Cloud deployments
|
||||
- ⚪ Desktop App
|
||||
|
||||
<br/>
|
||||
|
||||
## Community & Plugins
|
||||
|
||||
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
|
||||
|
||||
## Telemetry
|
||||
|
||||
Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent.
|
||||
|
||||
Telemetry is **enabled by default** and can be disabled with any of the following:
|
||||
|
||||
| Method | How |
|
||||
| -------------------- | ------------------------------------------------------- |
|
||||
| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` |
|
||||
| Standard convention | `DO_NOT_TRACK=1` |
|
||||
| CI environments | Automatically disabled when `CI=true` |
|
||||
| Config file | Set `telemetry.enabled: false` in your Paperclip config |
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
||||
|
||||
8
SECURITY.md
Normal file
8
SECURITY.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security vulnerabilities through GitHub's Security Advisory feature:
|
||||
[https://github.com/paperclipai/paperclip/security/advisories/new](https://github.com/paperclipai/paperclip/security/advisories/new)
|
||||
|
||||
Do not open public issues for security vulnerabilities.
|
||||
143
adapter-plugin.md
Normal file
143
adapter-plugin.md
Normal file
@@ -0,0 +1,143 @@
|
||||
- Created branch: feat/external-adapter-phase1
|
||||
|
||||
I started phase 1 in the most merge-friendly way I could: small central changes, reusing existing registry patterns instead of inventing a whole new plugin system up front.
|
||||
|
||||
What I changed
|
||||
|
||||
1. Server adapter registry is now mutable
|
||||
Files:
|
||||
- server/src/adapters/registry.ts
|
||||
- server/src/adapters/index.ts
|
||||
|
||||
Added:
|
||||
- registerServerAdapter(adapter)
|
||||
- unregisterServerAdapter(type)
|
||||
- requireServerAdapter(type)
|
||||
|
||||
Kept the existing built-in registry shape, but changed initialization so built-ins are registered into a mutable map on startup.
|
||||
|
||||
Why this is merge-friendly:
|
||||
- existing built-in adapter definitions stay where they already are
|
||||
- existing lookup helpers still exist
|
||||
- no big architectural rewrite yet
|
||||
|
||||
1. Runtime adapter validation moved to server routes
|
||||
File:
|
||||
- server/src/routes/agents.ts
|
||||
|
||||
Added:
|
||||
- assertKnownAdapterType(...)
|
||||
|
||||
Used it in:
|
||||
- /companies/:companyId/adapters/:type/models
|
||||
- /companies/:companyId/adapters/:type/detect-model
|
||||
- /companies/:companyId/adapters/:type/test-environment
|
||||
- POST /companies/:companyId/agents
|
||||
- POST /companies/:companyId/agent-hires
|
||||
- PATCH /agents/:id when adapterType is touched
|
||||
|
||||
Why:
|
||||
- shared schemas can now allow external adapter strings
|
||||
- server becomes the real source of truth for “is this adapter actually registered?”
|
||||
|
||||
1. Shared adapterType validation is now open-ended for inputs
|
||||
Files:
|
||||
- packages/shared/src/adapter-type.ts
|
||||
- packages/shared/src/validators/agent.ts
|
||||
- packages/shared/src/validators/access.ts
|
||||
- packages/shared/src/index.ts
|
||||
|
||||
Changed input validation from hardcoded z.enum(AGENT_ADAPTER_TYPES) to:
|
||||
- agentAdapterTypeSchema
|
||||
- optionalAgentAdapterTypeSchema
|
||||
|
||||
These accept any non-empty string.
|
||||
|
||||
Important:
|
||||
- I did not remove AGENT_ADAPTER_TYPES from constants
|
||||
- I did not change the built-in type unions yet
|
||||
- this keeps the change minimal and limits blast radius
|
||||
|
||||
So:
|
||||
- input payloads can carry external adapter types
|
||||
- actual acceptance still depends on server registry validation
|
||||
|
||||
1. UI adapter registry is now mutable too
|
||||
Files:
|
||||
- ui/src/adapters/registry.ts
|
||||
- ui/src/adapters/index.ts
|
||||
|
||||
Added:
|
||||
- registerUIAdapter(adapter)
|
||||
- unregisterUIAdapter(type)
|
||||
- findUIAdapter(type)
|
||||
|
||||
Built-ins are still registered from the same file, same pattern as server.
|
||||
|
||||
1. Began switching UI adapter lists toward the registry
|
||||
Files:
|
||||
- ui/src/adapters/metadata.ts
|
||||
- ui/src/components/AgentConfigForm.tsx
|
||||
- ui/src/pages/NewAgent.tsx
|
||||
|
||||
Changed:
|
||||
- AgentConfigForm adapter list now comes from registered UI adapters via metadata helper
|
||||
- NewAgent supported adapter set now derives from listUIAdapters()
|
||||
|
||||
Why:
|
||||
- phase 1 should start reducing hardcoded UI lists
|
||||
- but without rewriting every page at once
|
||||
|
||||
1. Tests added first, then implementation
|
||||
New tests:
|
||||
- server/src/__tests__/adapter-registry.test.ts
|
||||
- server/src/__tests__/agent-adapter-validation-routes.test.ts
|
||||
- ui/src/adapters/registry.test.ts
|
||||
- packages/shared/src/adapter-types.test.ts
|
||||
|
||||
Small test mock fix:
|
||||
- server/src/__tests__/agent-skills-routes.test.ts
|
||||
|
||||
Current status
|
||||
|
||||
The branch is created and the phase-1 code is started.
|
||||
|
||||
I also followed TDD in spirit:
|
||||
- wrote failing tests first for missing register/unregister APIs
|
||||
- then implemented the registry changes
|
||||
|
||||
What is not finished yet
|
||||
|
||||
I have not finished the green/stabilization pass yet.
|
||||
|
||||
I still need to:
|
||||
- run the targeted tests again after the implementation changes
|
||||
- fix any TS / mocking fallout
|
||||
- decide how far to take phase-1 UI wiring in this branch before stopping
|
||||
|
||||
Also, I intentionally did not yet touch the bigger hardcoded surfaces like:
|
||||
- NewAgentDialog
|
||||
- OnboardingWizard
|
||||
- InviteLanding
|
||||
- duplicated adapterLabels maps
|
||||
- plugin manifest / plugin loader adapter contributions
|
||||
|
||||
That is deliberate.
|
||||
For merge safety, I started with the smallest useful seam:
|
||||
- dynamic server registry
|
||||
- dynamic UI registry
|
||||
- runtime validation based on registry
|
||||
- first couple of UI consumers
|
||||
|
||||
One note
|
||||
There was already an unrelated dirty change in:
|
||||
- server/scripts/dev-watch.ts
|
||||
|
||||
I left that alone and did not make phase-1 changes there.
|
||||
|
||||
Recommended next move
|
||||
I should keep going on this same branch and do the stabilization pass:
|
||||
1. rerun server/ui/shared targeted tests
|
||||
2. fix failures
|
||||
3. run typechecks
|
||||
4. then extend the same registry-driven approach to the next safest UI surfaces
|
||||
300
cli/README.md
Normal file
300
cli/README.md
Normal file
@@ -0,0 +1,300 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/header.png" alt="Paperclip — runs your business" width="720" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quickstart"><strong>Quickstart</strong></a> ·
|
||||
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> ·
|
||||
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> ·
|
||||
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
|
||||
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
|
||||
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<video src="https://github.com/user-attachments/assets/773bdfb2-6d1e-4e30-8c5f-3487d5b70c8f" width="600" controls></video>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
## What is Paperclip?
|
||||
|
||||
# Open-source orchestration for zero-human companies
|
||||
|
||||
**If OpenClaw is an _employee_, Paperclip is the _company_**
|
||||
|
||||
Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard.
|
||||
|
||||
It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination.
|
||||
|
||||
**Manage business goals, not pull requests.**
|
||||
|
||||
| | Step | Example |
|
||||
| ------ | --------------- | ------------------------------------------------------------------ |
|
||||
| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ |
|
||||
| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. |
|
||||
| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. |
|
||||
|
||||
<br/>
|
||||
|
||||
> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds.
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>Works<br/>with</strong></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/openclaw.svg" width="32" alt="OpenClaw" /><br/><sub>OpenClaw</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/claude.svg" width="32" alt="Claude" /><br/><sub>Claude Code</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/codex.svg" width="32" alt="Codex" /><br/><sub>Codex</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/cursor.svg" width="32" alt="Cursor" /><br/><sub>Cursor</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/bash.svg" width="32" alt="Bash" /><br/><sub>Bash</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/http.svg" width="32" alt="HTTP" /><br/><sub>HTTP</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<em>If it can receive a heartbeat, it's hired.</em>
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
## Paperclip is right for you if
|
||||
|
||||
- ✅ You want to build **autonomous AI companies**
|
||||
- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal
|
||||
- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing
|
||||
- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed
|
||||
- ✅ You want to **monitor costs** and enforce budgets
|
||||
- ✅ You want a process for managing agents that **feels like using a task manager**
|
||||
- ✅ You want to manage your autonomous businesses **from your phone**
|
||||
|
||||
<br/>
|
||||
|
||||
## Features
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%">
|
||||
<h3>🔌 Bring Your Own Agent</h3>
|
||||
Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired.
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<h3>🎯 Goal Alignment</h3>
|
||||
Every task traces back to the company mission. Agents know <em>what</em> to do and <em>why</em>.
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<h3>💓 Heartbeats</h3>
|
||||
Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>💰 Cost Control</h3>
|
||||
Monthly budgets per agent. When they hit the limit, they stop. No runaway costs.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>🏢 Multi-Company</h3>
|
||||
One deployment, many companies. Complete data isolation. One control plane for your portfolio.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>🎫 Ticket System</h3>
|
||||
Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>🛡️ Governance</h3>
|
||||
You're the board. Approve hires, override strategy, pause or terminate any agent — at any time.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>📊 Org Chart</h3>
|
||||
Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>📱 Mobile Ready</h3>
|
||||
Monitor and manage your autonomous businesses from anywhere.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br/>
|
||||
|
||||
## Problems Paperclip solves
|
||||
|
||||
| Without Paperclip | With Paperclip |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. |
|
||||
| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. |
|
||||
| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. |
|
||||
| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. |
|
||||
| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. |
|
||||
| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. |
|
||||
|
||||
<br/>
|
||||
|
||||
## Why Paperclip is special
|
||||
|
||||
Paperclip handles the hard orchestration details correctly.
|
||||
|
||||
| | |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. |
|
||||
| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. |
|
||||
| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. |
|
||||
| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. |
|
||||
| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. |
|
||||
| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. |
|
||||
| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. |
|
||||
|
||||
<br/>
|
||||
|
||||
## What Paperclip is not
|
||||
|
||||
| | |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Not a chatbot.** | Agents have jobs, not chat windows. |
|
||||
| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. |
|
||||
| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. |
|
||||
| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. |
|
||||
| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. |
|
||||
| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. |
|
||||
|
||||
<br/>
|
||||
|
||||
## Quickstart
|
||||
|
||||
Open source. Self-hosted. No Paperclip account required.
|
||||
|
||||
```bash
|
||||
npx paperclipai onboard --yes
|
||||
```
|
||||
|
||||
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
|
||||
|
||||
```bash
|
||||
npx paperclipai onboard --yes --bind lan
|
||||
# or:
|
||||
npx paperclipai onboard --yes --bind tailnet
|
||||
```
|
||||
|
||||
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/paperclipai/paperclip.git
|
||||
cd paperclip
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
|
||||
|
||||
> **Requirements:** Node.js 20+, pnpm 9.15+
|
||||
|
||||
<br/>
|
||||
|
||||
## FAQ
|
||||
|
||||
**What does a typical setup look like?**
|
||||
Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest.
|
||||
|
||||
If you're a solo-entreprenuer you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it.
|
||||
|
||||
**Can I run multiple companies?**
|
||||
Yes. A single deployment can run an unlimited number of companies with complete data isolation.
|
||||
|
||||
**How is Paperclip different from agents like OpenClaw or Claude Code?**
|
||||
Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability.
|
||||
|
||||
**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?**
|
||||
Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you.
|
||||
|
||||
(Bring-your-own-ticket-system is on the Roadmap)
|
||||
|
||||
**Do agents run continuously?**
|
||||
By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates.
|
||||
|
||||
<br/>
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm dev # Full dev (API + UI, watch mode)
|
||||
pnpm dev:once # Full dev without file watching
|
||||
pnpm dev:server # Server only
|
||||
pnpm build # Build all
|
||||
pnpm typecheck # Type checking
|
||||
pnpm test:run # Run tests
|
||||
pnpm db:generate # Generate DB migration
|
||||
pnpm db:migrate # Apply migrations
|
||||
```
|
||||
|
||||
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
<br/>
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
|
||||
- ✅ Get OpenClaw / claw-style agent employees
|
||||
- ✅ companies.sh - import and export entire organizations
|
||||
- ✅ Easy AGENTS.md configurations
|
||||
- ✅ Skills Manager
|
||||
- ✅ Scheduled Routines
|
||||
- ✅ Better Budgeting
|
||||
- ⚪ Artifacts & Deployments
|
||||
- ⚪ CEO Chat
|
||||
- ⚪ MAXIMIZER MODE
|
||||
- ⚪ Multiple Human Users
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||
- ⚪ Cloud deployments
|
||||
- ⚪ Desktop App
|
||||
|
||||
<br/>
|
||||
|
||||
## Community & Plugins
|
||||
|
||||
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details.
|
||||
|
||||
<br/>
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
|
||||
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
|
||||
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
|
||||
|
||||
<br/>
|
||||
|
||||
## License
|
||||
|
||||
MIT © 2026 Paperclip
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/footer.jpg" alt="" width="720" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>Open source under MIT. Built for people who want to run companies, not babysit agents.</sub>
|
||||
</p>
|
||||
@@ -44,6 +44,9 @@ function writeBaseConfig(configPath: string) {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: { baseDir: "/tmp/paperclip-storage" },
|
||||
|
||||
@@ -15,6 +15,10 @@ function makeCompany(overrides: Partial<Company>): Company {
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
|
||||
@@ -6,33 +6,15 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { createStoredZipArchive } from "./helpers/zip.js";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
type ServerProcess = ReturnType<typeof spawn>;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -53,30 +35,13 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db");
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
return { connectionString, dataDir, instance };
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
|
||||
@@ -265,26 +230,23 @@ async function waitForServer(
|
||||
);
|
||||
}
|
||||
|
||||
describe("paperclipai company import/export e2e", () => {
|
||||
describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
||||
let tempRoot = "";
|
||||
let configPath = "";
|
||||
let exportDir = "";
|
||||
let apiBase = "";
|
||||
let serverProcess: ServerProcess | null = null;
|
||||
let dbDataDir = "";
|
||||
let dbInstance: EmbeddedPostgresInstance | null = null;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
|
||||
configPath = path.join(tempRoot, "config", "config.json");
|
||||
exportDir = path.join(tempRoot, "exported-company");
|
||||
|
||||
const db = await startTempDatabase();
|
||||
dbDataDir = db.dataDir;
|
||||
dbInstance = db.instance;
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-");
|
||||
|
||||
const port = await getAvailablePort();
|
||||
writeTestConfig(configPath, tempRoot, port, db.connectionString);
|
||||
writeTestConfig(configPath, tempRoot, port, tempDb.connectionString);
|
||||
apiBase = `http://127.0.0.1:${port}`;
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
@@ -294,7 +256,7 @@ describe("paperclipai company import/export e2e", () => {
|
||||
["paperclipai", "run", "--config", configPath],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: createServerEnv(configPath, port, db.connectionString),
|
||||
env: createServerEnv(configPath, port, tempDb.connectionString),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
@@ -311,10 +273,7 @@ describe("paperclipai company import/export e2e", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await stopServerProcess(serverProcess);
|
||||
await dbInstance?.stop();
|
||||
if (dbDataDir) {
|
||||
rmSync(dbDataDir, { recursive: true, force: true });
|
||||
}
|
||||
await tempDb?.cleanup();
|
||||
if (tempRoot) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isGithubShorthand,
|
||||
isGithubUrl,
|
||||
looksLikeRepoUrl,
|
||||
isHttpUrl,
|
||||
normalizeGithubImportSource,
|
||||
} from "../commands/client/company.js";
|
||||
@@ -21,17 +21,17 @@ describe("isHttpUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGithubUrl", () => {
|
||||
describe("looksLikeRepoUrl", () => {
|
||||
it("matches GitHub URLs", () => {
|
||||
expect(isGithubUrl("https://github.com/org/repo")).toBe(true);
|
||||
expect(looksLikeRepoUrl("https://github.com/org/repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-GitHub HTTP URLs", () => {
|
||||
expect(isGithubUrl("https://example.com/foo")).toBe(false);
|
||||
it("rejects URLs without owner/repo path", () => {
|
||||
expect(looksLikeRepoUrl("https://example.com/foo")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects local paths", () => {
|
||||
expect(isGithubUrl("/tmp/my-company")).toBe(false);
|
||||
expect(looksLikeRepoUrl("/tmp/my-company")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -163,6 +163,10 @@ describe("renderCompanyImportPreview", () => {
|
||||
brandColor: null,
|
||||
logoPath: null,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
},
|
||||
sidebar: {
|
||||
agents: ["ceo"],
|
||||
@@ -216,6 +220,7 @@ describe("renderCompanyImportPreview", () => {
|
||||
status: null,
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
env: null,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
@@ -246,6 +251,7 @@ describe("renderCompanyImportPreview", () => {
|
||||
key: "OPENAI_API_KEY",
|
||||
description: null,
|
||||
agentSlug: "ceo",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "required",
|
||||
defaultValue: null,
|
||||
@@ -261,6 +267,7 @@ describe("renderCompanyImportPreview", () => {
|
||||
key: "OPENAI_API_KEY",
|
||||
description: null,
|
||||
agentSlug: "ceo",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "required",
|
||||
defaultValue: null,
|
||||
@@ -371,6 +378,10 @@ describe("import selection catalog", () => {
|
||||
brandColor: null,
|
||||
logoPath: "images/company-logo.png",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
},
|
||||
sidebar: {
|
||||
agents: ["ceo"],
|
||||
@@ -424,6 +435,7 @@ describe("import selection catalog", () => {
|
||||
status: null,
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
env: null,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -46,6 +46,9 @@ function createTempConfig(): string {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
|
||||
177
cli/src/__tests__/feedback.test.ts
Normal file
177
cli/src/__tests__/feedback.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FeedbackTrace } from "@paperclipai/shared";
|
||||
import { readZipArchive } from "../commands/client/zip.js";
|
||||
import {
|
||||
buildFeedbackTraceQuery,
|
||||
registerFeedbackCommands,
|
||||
renderFeedbackReport,
|
||||
summarizeFeedbackTraces,
|
||||
writeFeedbackExportBundle,
|
||||
} from "../commands/client/feedback.js";
|
||||
|
||||
function makeTrace(overrides: Partial<FeedbackTrace> = {}): FeedbackTrace {
|
||||
return {
|
||||
id: "trace-12345678",
|
||||
companyId: "company-123",
|
||||
feedbackVoteId: "vote-12345678",
|
||||
issueId: "issue-123",
|
||||
projectId: "project-123",
|
||||
issueIdentifier: "PAP-123",
|
||||
issueTitle: "Fix the feedback command",
|
||||
authorUserId: "user-123",
|
||||
targetType: "issue_comment",
|
||||
targetId: "comment-123",
|
||||
vote: "down",
|
||||
status: "pending",
|
||||
destination: "paperclip_labs_feedback_v1",
|
||||
exportId: null,
|
||||
consentVersion: "feedback-data-sharing-v1",
|
||||
schemaVersion: "1",
|
||||
bundleVersion: "1",
|
||||
payloadVersion: "1",
|
||||
payloadDigest: null,
|
||||
payloadSnapshot: {
|
||||
vote: {
|
||||
value: "down",
|
||||
reason: "Needed more detail",
|
||||
},
|
||||
},
|
||||
targetSummary: {
|
||||
label: "Comment",
|
||||
excerpt: "The first answer was too vague.",
|
||||
authorAgentId: "agent-123",
|
||||
authorUserId: null,
|
||||
createdAt: new Date("2026-03-31T12:00:00.000Z"),
|
||||
documentKey: null,
|
||||
documentTitle: null,
|
||||
revisionNumber: null,
|
||||
},
|
||||
redactionSummary: null,
|
||||
attemptCount: 0,
|
||||
lastAttemptedAt: null,
|
||||
exportedAt: null,
|
||||
failureReason: null,
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-03-31T12:02:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerFeedbackCommands", () => {
|
||||
it("registers the top-level feedback commands", () => {
|
||||
const program = new Command();
|
||||
|
||||
expect(() => registerFeedbackCommands(program)).not.toThrow();
|
||||
|
||||
const feedback = program.commands.find((command) => command.name() === "feedback");
|
||||
expect(feedback).toBeDefined();
|
||||
expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export"]);
|
||||
expect(feedback?.commands[0]?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeedbackTraceQuery", () => {
|
||||
it("encodes all supported filters", () => {
|
||||
expect(
|
||||
buildFeedbackTraceQuery({
|
||||
targetType: "issue_comment",
|
||||
vote: "down",
|
||||
status: "pending",
|
||||
projectId: "project-123",
|
||||
issueId: "issue-123",
|
||||
from: "2026-03-31T00:00:00.000Z",
|
||||
to: "2026-03-31T23:59:59.999Z",
|
||||
sharedOnly: true,
|
||||
}),
|
||||
).toBe(
|
||||
"?targetType=issue_comment&vote=down&status=pending&projectId=project-123&issueId=issue-123&from=2026-03-31T00%3A00%3A00.000Z&to=2026-03-31T23%3A59%3A59.999Z&sharedOnly=true&includePayload=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFeedbackReport", () => {
|
||||
it("includes summary counts and the optional reason", () => {
|
||||
const traces = [
|
||||
makeTrace(),
|
||||
makeTrace({
|
||||
id: "trace-87654321",
|
||||
feedbackVoteId: "vote-87654321",
|
||||
vote: "up",
|
||||
status: "local_only",
|
||||
payloadSnapshot: {
|
||||
vote: {
|
||||
value: "up",
|
||||
reason: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const report = renderFeedbackReport({
|
||||
apiBase: "http://127.0.0.1:3100",
|
||||
companyId: "company-123",
|
||||
traces,
|
||||
summary: summarizeFeedbackTraces(traces),
|
||||
includePayloads: false,
|
||||
});
|
||||
|
||||
expect(report).toContain("Paperclip Feedback Report");
|
||||
expect(report).toContain("thumbs up");
|
||||
expect(report).toContain("thumbs down");
|
||||
expect(report).toContain("Needed more detail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeFeedbackExportBundle", () => {
|
||||
it("writes votes, traces, a manifest, and a zip archive", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-feedback-export-"));
|
||||
const outputDir = path.join(tempDir, "feedback-export");
|
||||
const traces = [
|
||||
makeTrace(),
|
||||
makeTrace({
|
||||
id: "trace-abcdef12",
|
||||
feedbackVoteId: "vote-abcdef12",
|
||||
issueIdentifier: "PAP-124",
|
||||
issueId: "issue-124",
|
||||
vote: "up",
|
||||
status: "local_only",
|
||||
payloadSnapshot: {
|
||||
vote: {
|
||||
value: "up",
|
||||
reason: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const exported = await writeFeedbackExportBundle({
|
||||
apiBase: "http://127.0.0.1:3100",
|
||||
companyId: "company-123",
|
||||
traces,
|
||||
outputDir,
|
||||
});
|
||||
|
||||
expect(exported.manifest.summary.total).toBe(2);
|
||||
expect(exported.manifest.summary.withReason).toBe(1);
|
||||
|
||||
const manifest = JSON.parse(await readFile(path.join(outputDir, "index.json"), "utf8")) as {
|
||||
files: { votes: string[]; traces: string[]; zip: string };
|
||||
};
|
||||
expect(manifest.files.votes).toHaveLength(2);
|
||||
expect(manifest.files.traces).toHaveLength(2);
|
||||
|
||||
const archive = await readFile(exported.zipPath);
|
||||
const zip = await readZipArchive(archive);
|
||||
expect(Object.keys(zip.files)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"index.json",
|
||||
`votes/${manifest.files.votes[0]}`,
|
||||
`traces/${manifest.files.traces[0]}`,
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
6
cli/src/__tests__/helpers/embedded-postgres.ts
Normal file
6
cli/src/__tests__/helpers/embedded-postgres.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
type EmbeddedPostgresTestDatabase,
|
||||
type EmbeddedPostgresTestSupport,
|
||||
} from "@paperclipai/db";
|
||||
62
cli/src/__tests__/network-bind.test.ts
Normal file
62
cli/src/__tests__/network-bind.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
|
||||
import { buildPresetServerConfig } from "../config/server-bind.js";
|
||||
|
||||
describe("network bind helpers", () => {
|
||||
it("rejects non-loopback bind modes in local_trusted", () => {
|
||||
expect(
|
||||
validateConfiguredBindMode({
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bind: "lan",
|
||||
host: "0.0.0.0",
|
||||
}),
|
||||
).toContain("local_trusted requires server.bind=loopback");
|
||||
});
|
||||
|
||||
it("resolves tailnet bind using the detected tailscale address", () => {
|
||||
const resolved = resolveRuntimeBind({
|
||||
bind: "tailnet",
|
||||
host: "127.0.0.1",
|
||||
tailnetBindHost: "100.64.0.8",
|
||||
});
|
||||
|
||||
expect(resolved.errors).toEqual([]);
|
||||
expect(resolved.host).toBe("100.64.0.8");
|
||||
});
|
||||
|
||||
it("requires a custom bind host when bind=custom", () => {
|
||||
const resolved = resolveRuntimeBind({
|
||||
bind: "custom",
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
|
||||
expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom");
|
||||
});
|
||||
|
||||
it("stores the detected tailscale address for tailnet presets", () => {
|
||||
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
|
||||
|
||||
const preset = buildPresetServerConfig("tailnet", {
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
});
|
||||
|
||||
expect(preset.server.host).toBe("100.64.0.8");
|
||||
|
||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||
});
|
||||
|
||||
it("falls back to loopback when no tailscale address is available for tailnet presets", () => {
|
||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||
|
||||
const preset = buildPresetServerConfig("tailnet", {
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
});
|
||||
|
||||
expect(preset.server.host).toBe("127.0.0.1");
|
||||
});
|
||||
});
|
||||
166
cli/src/__tests__/onboard.test.ts
Normal file
166
cli/src/__tests__/onboard.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { onboard } from "../commands/onboard.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createExistingConfigFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
|
||||
const runtimeRoot = path.join(root, "runtime");
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-29T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(runtimeRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(runtimeRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(runtimeRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
||||
|
||||
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
|
||||
}
|
||||
|
||||
function createFreshConfigPath() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-fresh-"));
|
||||
return path.join(root, ".paperclip", "config.json");
|
||||
}
|
||||
|
||||
describe("onboard", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("preserves an existing config when rerun without flags", async () => {
|
||||
const fixture = createExistingConfigFixture();
|
||||
|
||||
await onboard({ config: fixture.configPath });
|
||||
|
||||
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
|
||||
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
|
||||
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an existing config when rerun with --yes", async () => {
|
||||
const fixture = createExistingConfigFixture();
|
||||
|
||||
await onboard({ config: fixture.configPath, yes: true, invokedByRun: true });
|
||||
|
||||
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
|
||||
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
|
||||
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps --yes onboarding on local trusted loopback defaults", async () => {
|
||||
const configPath = createFreshConfigPath();
|
||||
process.env.HOST = "0.0.0.0";
|
||||
process.env.PAPERCLIP_BIND = "lan";
|
||||
|
||||
await onboard({ config: configPath, yes: true, invokedByRun: true });
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||
expect(raw.server.deploymentMode).toBe("local_trusted");
|
||||
expect(raw.server.exposure).toBe("private");
|
||||
expect(raw.server.bind).toBe("loopback");
|
||||
expect(raw.server.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("supports authenticated/private quickstart bind presets", async () => {
|
||||
const configPath = createFreshConfigPath();
|
||||
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
|
||||
|
||||
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||
expect(raw.server.deploymentMode).toBe("authenticated");
|
||||
expect(raw.server.exposure).toBe("private");
|
||||
expect(raw.server.bind).toBe("tailnet");
|
||||
expect(raw.server.host).toBe("100.64.0.8");
|
||||
});
|
||||
|
||||
it("keeps tailnet quickstart on loopback until tailscale is available", async () => {
|
||||
const configPath = createFreshConfigPath();
|
||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||
|
||||
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||
expect(raw.server.deploymentMode).toBe("authenticated");
|
||||
expect(raw.server.exposure).toBe("private");
|
||||
expect(raw.server.bind).toBe("tailnet");
|
||||
expect(raw.server.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("ignores deployment env overrides during --yes quickstart", async () => {
|
||||
const configPath = createFreshConfigPath();
|
||||
process.env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
||||
|
||||
await onboard({ config: configPath, yes: true, invokedByRun: true });
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||
expect(raw.server.deploymentMode).toBe("local_trusted");
|
||||
expect(raw.server.exposure).toBe("private");
|
||||
expect(raw.server.bind).toBe("loopback");
|
||||
expect(raw.server.host).toBe("127.0.0.1");
|
||||
});
|
||||
});
|
||||
249
cli/src/__tests__/routines.test.ts
Normal file
249
cli/src/__tests__/routines.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
projects,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { disableAllRoutinesInConfig } from "../commands/routines.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres routines CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeTestConfig(configPath: string, tempRoot: string, connectionString: string) {
|
||||
const config = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "doctor" as const,
|
||||
},
|
||||
database: {
|
||||
mode: "postgres" as const,
|
||||
connectionString,
|
||||
embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: false,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(tempRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file" as const,
|
||||
logDir: path.join(tempRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted" as const,
|
||||
exposure: "private" as const,
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: false,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto" as const,
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk" as const,
|
||||
localDisk: {
|
||||
baseDir: path.join(tempRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted" as const,
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(tempRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("disableAllRoutinesInConfig", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let tempRoot = "";
|
||||
let configPath = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-cli-db-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-cli-config-"));
|
||||
configPath = path.join(tempRoot, "config.json");
|
||||
writeTestConfig(configPath, tempRoot, tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(routines);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
if (tempRoot) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("pauses only non-archived routines for the selected company", async () => {
|
||||
const companyId = randomUUID();
|
||||
const otherCompanyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const otherProjectId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const otherAgentId = randomUUID();
|
||||
const activeRoutineId = randomUUID();
|
||||
const pausedRoutineId = randomUUID();
|
||||
const archivedRoutineId = randomUUID();
|
||||
const otherCompanyRoutineId = randomUUID();
|
||||
|
||||
await db.insert(companies).values([
|
||||
{
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
{
|
||||
id: otherCompanyId,
|
||||
name: "Other company",
|
||||
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: otherAgentId,
|
||||
companyId: otherCompanyId,
|
||||
name: "Other coder",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Project",
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
id: otherProjectId,
|
||||
companyId: otherCompanyId,
|
||||
name: "Other project",
|
||||
status: "in_progress",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(routines).values([
|
||||
{
|
||||
id: activeRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Active routine",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: pausedRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Paused routine",
|
||||
status: "paused",
|
||||
},
|
||||
{
|
||||
id: archivedRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Archived routine",
|
||||
status: "archived",
|
||||
},
|
||||
{
|
||||
id: otherCompanyRoutineId,
|
||||
companyId: otherCompanyId,
|
||||
projectId: otherProjectId,
|
||||
assigneeAgentId: otherAgentId,
|
||||
title: "Other company routine",
|
||||
status: "active",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await disableAllRoutinesInConfig({
|
||||
config: configPath,
|
||||
companyId,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
companyId,
|
||||
totalRoutines: 3,
|
||||
pausedCount: 1,
|
||||
alreadyPausedCount: 1,
|
||||
archivedCount: 1,
|
||||
});
|
||||
|
||||
const companyRoutines = await db
|
||||
.select({
|
||||
id: routines.id,
|
||||
status: routines.status,
|
||||
})
|
||||
.from(routines)
|
||||
.where(eq(routines.companyId, companyId));
|
||||
const statusById = new Map(companyRoutines.map((routine) => [routine.id, routine.status]));
|
||||
|
||||
expect(statusById.get(activeRoutineId)).toBe("paused");
|
||||
expect(statusById.get(pausedRoutineId)).toBe("paused");
|
||||
expect(statusById.get(archivedRoutineId)).toBe("archived");
|
||||
|
||||
const otherCompanyRoutine = await db
|
||||
.select({
|
||||
status: routines.status,
|
||||
})
|
||||
.from(routines)
|
||||
.where(eq(routines.id, otherCompanyRoutineId));
|
||||
expect(otherCompanyRoutine[0]?.status).toBe("active");
|
||||
});
|
||||
});
|
||||
117
cli/src/__tests__/telemetry.test.ts
Normal file
117
cli/src/__tests__/telemetry.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const CI_ENV_VARS = ["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "GITHUB_ACTIONS", "GITLAB_CI"];
|
||||
|
||||
function makeConfigPath(root: string, enabled: boolean): string {
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(root, "runtime", "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(root, "runtime", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(root, "runtime", "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(root, "runtime", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(root, "runtime", "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
}, null, 2));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
describe("cli telemetry", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
for (const key of CI_ENV_VARS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true })));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("respects telemetry.enabled=false from the config file", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
|
||||
const configPath = makeConfigPath(root, false);
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "home");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
|
||||
|
||||
const { initTelemetryFromConfigFile } = await import("../telemetry.js");
|
||||
const client = initTelemetryFromConfigFile(configPath);
|
||||
|
||||
expect(client).toBeNull();
|
||||
expect(fs.existsSync(path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("creates telemetry state only after the first event is tracked", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "home");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
|
||||
|
||||
const { initTelemetry, flushTelemetry } = await import("../telemetry.js");
|
||||
const client = initTelemetry({ enabled: true });
|
||||
const statePath = path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json");
|
||||
|
||||
expect(client).not.toBeNull();
|
||||
expect(fs.existsSync(statePath)).toBe(false);
|
||||
|
||||
client!.track("install.started", { setupMode: "quickstart" });
|
||||
|
||||
expect(fs.existsSync(statePath)).toBe(true);
|
||||
|
||||
await flushTelemetry();
|
||||
});
|
||||
});
|
||||
@@ -2,17 +2,30 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
projects,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
copyGitHooksToWorktreeGitDir,
|
||||
copySeededSecretsKey,
|
||||
pauseSeededScheduledRoutines,
|
||||
readSourceAttachmentBody,
|
||||
rebindWorkspaceCwd,
|
||||
resolveSourceConfigPath,
|
||||
resolveWorktreeReseedSource,
|
||||
resolveWorktreeReseedTargetPaths,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeInitCommand,
|
||||
worktreeMakeCommand,
|
||||
worktreeReseedCommand,
|
||||
} from "../commands/worktree.js";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
@@ -25,9 +38,21 @@ import {
|
||||
sanitizeWorktreeInstanceId,
|
||||
} from "../commands/worktree-lib.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres worktree CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
@@ -75,6 +100,9 @@ function buildSourceConfig(): PaperclipConfig {
|
||||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
@@ -344,6 +372,87 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("avoids ports already claimed by sibling worktree instance configs", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree");
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
fs.mkdirSync(siblingInstanceRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(siblingInstanceRoot, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildSourceConfig(),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
||||
embeddedPostgresPort: 54330,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(siblingInstanceRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(siblingInstanceRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "authenticated",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: ["localhost"],
|
||||
serveUi: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(siblingInstanceRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
process.chdir(repoRoot);
|
||||
await worktreeInitCommand({
|
||||
seed: false,
|
||||
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
||||
home: homeDir,
|
||||
});
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
|
||||
expect(config.server.port).toBeGreaterThan(3101);
|
||||
expect(config.database.embeddedPostgresPort).not.toBe(54330);
|
||||
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
|
||||
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults the seed source config to the current repo-local Paperclip config", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
@@ -397,6 +506,234 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an explicit reseed source", () => {
|
||||
expect(() => resolveWorktreeReseedSource({})).toThrow(
|
||||
"Pass --from <worktree> or --from-config/--from-instance explicitly so the reseed source is unambiguous.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects mixed reseed source selectors", () => {
|
||||
expect(() => resolveWorktreeReseedSource({
|
||||
from: "current",
|
||||
fromInstance: "default",
|
||||
})).toThrow(
|
||||
"Use either --from <worktree> or --from-config/--from-data-dir/--from-instance, not both.",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives worktree reseed target paths from the adjacent env file", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-"));
|
||||
const worktreeRoot = path.join(tempRoot, "repo");
|
||||
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
|
||||
const envPath = path.join(worktreeRoot, ".paperclip", ".env");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
fs.writeFileSync(
|
||||
envPath,
|
||||
[
|
||||
"PAPERCLIP_HOME=/tmp/paperclip-worktrees",
|
||||
"PAPERCLIP_INSTANCE_ID=pap-1132-chat",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
expect(
|
||||
resolveWorktreeReseedTargetPaths({
|
||||
configPath,
|
||||
rootPath: worktreeRoot,
|
||||
}),
|
||||
).toMatchObject({
|
||||
cwd: worktreeRoot,
|
||||
homeDir: "/tmp/paperclip-worktrees",
|
||||
instanceId: "pap-1132-chat",
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects reseed targets without worktree env metadata", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-missing-"));
|
||||
const worktreeRoot = path.join(tempRoot, "repo");
|
||||
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
fs.writeFileSync(path.join(worktreeRoot, ".paperclip", ".env"), "", "utf8");
|
||||
|
||||
expect(() =>
|
||||
resolveWorktreeReseedTargetPaths({
|
||||
configPath,
|
||||
rootPath: worktreeRoot,
|
||||
})).toThrow("does not look like a worktree-local Paperclip instance");
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reseed preserves the current worktree ports, instance id, and branding", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const sourceRoot = path.join(tempRoot, "source");
|
||||
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const currentInstanceId = "existing-worktree";
|
||||
const currentPaths = resolveWorktreeLocalPaths({
|
||||
cwd: repoRoot,
|
||||
homeDir,
|
||||
instanceId: currentInstanceId,
|
||||
});
|
||||
const sourcePaths = resolveWorktreeLocalPaths({
|
||||
cwd: sourceRoot,
|
||||
homeDir: path.join(tempRoot, ".paperclip-source"),
|
||||
instanceId: "default",
|
||||
});
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true });
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
fs.mkdirSync(sourceRoot, { recursive: true });
|
||||
|
||||
const currentConfig = buildWorktreeConfig({
|
||||
sourceConfig: buildSourceConfig(),
|
||||
paths: currentPaths,
|
||||
serverPort: 3114,
|
||||
databasePort: 54341,
|
||||
});
|
||||
const sourceConfig = buildWorktreeConfig({
|
||||
sourceConfig: buildSourceConfig(),
|
||||
paths: sourcePaths,
|
||||
serverPort: 3200,
|
||||
databasePort: 54400,
|
||||
});
|
||||
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
|
||||
fs.writeFileSync(
|
||||
currentPaths.envPath,
|
||||
[
|
||||
`PAPERCLIP_HOME=${homeDir}`,
|
||||
`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`,
|
||||
"PAPERCLIP_WORKTREE_NAME=existing-name",
|
||||
"PAPERCLIP_WORKTREE_COLOR=\"#112233\"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeReseedCommand({
|
||||
fromConfig: sourcePaths.configPath,
|
||||
yes: true,
|
||||
});
|
||||
|
||||
const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
|
||||
const rewrittenEnv = fs.readFileSync(currentPaths.envPath, "utf8");
|
||||
|
||||
expect(rewrittenConfig.server.port).toBe(3114);
|
||||
expect(rewrittenConfig.database.embeddedPostgresPort).toBe(54341);
|
||||
expect(rewrittenConfig.database.embeddedPostgresDataDir).toBe(currentPaths.embeddedPostgresDataDir);
|
||||
expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
|
||||
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name");
|
||||
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\"");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
} else {
|
||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("restores the current worktree config and instance data if reseed fails", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const sourceRoot = path.join(tempRoot, "source");
|
||||
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const currentInstanceId = "rollback-worktree";
|
||||
const currentPaths = resolveWorktreeLocalPaths({
|
||||
cwd: repoRoot,
|
||||
homeDir,
|
||||
instanceId: currentInstanceId,
|
||||
});
|
||||
const sourcePaths = resolveWorktreeLocalPaths({
|
||||
cwd: sourceRoot,
|
||||
homeDir: path.join(tempRoot, ".paperclip-source"),
|
||||
instanceId: "default",
|
||||
});
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
|
||||
fs.mkdirSync(currentPaths.instanceRoot, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true });
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
fs.mkdirSync(sourceRoot, { recursive: true });
|
||||
|
||||
const currentConfig = buildWorktreeConfig({
|
||||
sourceConfig: buildSourceConfig(),
|
||||
paths: currentPaths,
|
||||
serverPort: 3114,
|
||||
databasePort: 54341,
|
||||
});
|
||||
const sourceConfig = {
|
||||
...buildSourceConfig(),
|
||||
database: {
|
||||
mode: "postgres",
|
||||
connectionString: "",
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: sourcePaths.secretsKeyFilePath,
|
||||
},
|
||||
},
|
||||
} as PaperclipConfig;
|
||||
|
||||
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(currentPaths.envPath, `PAPERCLIP_HOME=${homeDir}\nPAPERCLIP_INSTANCE_ID=${currentInstanceId}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "keep me", "utf8");
|
||||
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
|
||||
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await expect(worktreeReseedCommand({
|
||||
fromConfig: sourcePaths.configPath,
|
||||
yes: true,
|
||||
})).rejects.toThrow("Source instance uses postgres mode but has no connection string");
|
||||
|
||||
const restoredConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
|
||||
const restoredEnv = fs.readFileSync(currentPaths.envPath, "utf8");
|
||||
const restoredMarker = fs.readFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "utf8");
|
||||
|
||||
expect(restoredConfig.server.port).toBe(3114);
|
||||
expect(restoredConfig.database.embeddedPostgresPort).toBe(54341);
|
||||
expect(restoredEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
|
||||
expect(restoredMarker).toBe("keep me");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
} else {
|
||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
@@ -508,3 +845,138 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => {
|
||||
it("pauses only routines with enabled schedule triggers", async () => {
|
||||
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-routines-");
|
||||
const db = createDb(tempDb.connectionString);
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const activeScheduledRoutineId = randomUUID();
|
||||
const activeApiRoutineId = randomUUID();
|
||||
const pausedScheduledRoutineId = randomUUID();
|
||||
const archivedScheduledRoutineId = randomUUID();
|
||||
const disabledScheduleRoutineId = randomUUID();
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(routines).values([
|
||||
{
|
||||
id: activeScheduledRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Active scheduled",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: activeApiRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Active API",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: pausedScheduledRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Paused scheduled",
|
||||
status: "paused",
|
||||
},
|
||||
{
|
||||
id: archivedScheduledRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Archived scheduled",
|
||||
status: "archived",
|
||||
},
|
||||
{
|
||||
id: disabledScheduleRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Disabled schedule",
|
||||
status: "active",
|
||||
},
|
||||
]);
|
||||
await db.insert(routineTriggers).values([
|
||||
{
|
||||
companyId,
|
||||
routineId: activeScheduledRoutineId,
|
||||
kind: "schedule",
|
||||
enabled: true,
|
||||
cronExpression: "0 9 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
routineId: activeApiRoutineId,
|
||||
kind: "api",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
routineId: pausedScheduledRoutineId,
|
||||
kind: "schedule",
|
||||
enabled: true,
|
||||
cronExpression: "0 10 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
routineId: archivedScheduledRoutineId,
|
||||
kind: "schedule",
|
||||
enabled: true,
|
||||
cronExpression: "0 11 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
routineId: disabledScheduleRoutineId,
|
||||
kind: "schedule",
|
||||
enabled: false,
|
||||
cronExpression: "0 12 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
]);
|
||||
|
||||
const pausedCount = await pauseSeededScheduledRoutines(tempDb.connectionString);
|
||||
expect(pausedCount).toBe(1);
|
||||
|
||||
const rows = await db.select({ id: routines.id, status: routines.status }).from(routines);
|
||||
const statusById = new Map(rows.map((row) => [row.id, row.status]));
|
||||
expect(statusById.get(activeScheduledRoutineId)).toBe("paused");
|
||||
expect(statusById.get(activeApiRoutineId)).toBe("active");
|
||||
expect(statusById.get(pausedScheduledRoutineId)).toBe("paused");
|
||||
expect(statusById.get(archivedScheduledRoutineId)).toBe("archived");
|
||||
expect(statusById.get(disabledScheduleRoutineId)).toBe("active");
|
||||
} finally {
|
||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
await tempDb.cleanup();
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { inferBindModeFromHost } from "@paperclipai/shared";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
|
||||
function isLoopbackHost(host: string) {
|
||||
const normalized = host.trim().toLowerCase();
|
||||
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
||||
}
|
||||
|
||||
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
||||
const mode = config.server.deploymentMode;
|
||||
const exposure = config.server.exposure;
|
||||
const auth = config.auth;
|
||||
const bind = config.server.bind ?? inferBindModeFromHost(config.server.host);
|
||||
|
||||
if (mode === "local_trusted") {
|
||||
if (!isLoopbackHost(config.server.host)) {
|
||||
if (bind !== "loopback") {
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "fail",
|
||||
message: `local_trusted requires loopback host binding (found ${config.server.host})`,
|
||||
message: `local_trusted requires loopback binding (found ${bind})`,
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1",
|
||||
repairHint: "Run `paperclipai configure --section server` and choose Local trusted / loopback reachability",
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -86,6 +83,6 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "pass",
|
||||
message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`,
|
||||
message: `Mode ${mode}/${exposure} with bind ${bind} and auth URL mode ${auth.baseUrlMode}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
||||
import { inferBindModeFromHost } from "@paperclipai/shared";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
|
||||
@@ -40,9 +41,13 @@ function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
|
||||
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
|
||||
return config.auth.publicBaseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
const host = config?.server.host ?? "localhost";
|
||||
const bind = config?.server.bind ?? inferBindModeFromHost(config?.server.host);
|
||||
const host =
|
||||
bind === "custom"
|
||||
? config?.server.customBindHost ?? config?.server.host ?? "localhost"
|
||||
: config?.server.host ?? "localhost";
|
||||
const port = config?.server.port ?? 3100;
|
||||
const publicHost = host === "0.0.0.0" ? "localhost" : host;
|
||||
const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host;
|
||||
return `http://${publicHost}:${port}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import type {
|
||||
Company,
|
||||
FeedbackTrace,
|
||||
CompanyPortabilityFileEntry,
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilityInclude,
|
||||
CompanyPortabilityPreviewResult,
|
||||
CompanyPortabilityImportResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { getTelemetryClient, trackCompanyImported } from "../../telemetry.js";
|
||||
import { ApiRequestError } from "../../client/http.js";
|
||||
import { openUrl } from "../../client/board-auth.js";
|
||||
import { binaryContentTypeByExtension, readZipArchive } from "./zip.js";
|
||||
@@ -22,6 +24,11 @@ import {
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
import {
|
||||
buildFeedbackTraceQuery,
|
||||
normalizeFeedbackTraceExportFormat,
|
||||
serializeFeedbackTraces,
|
||||
} from "./feedback.js";
|
||||
|
||||
interface CompanyCommandOptions extends BaseClientOptions {}
|
||||
type CompanyDeleteSelectorMode = "auto" | "id" | "prefix";
|
||||
@@ -44,6 +51,20 @@ interface CompanyExportOptions extends BaseClientOptions {
|
||||
expandReferencedSkills?: boolean;
|
||||
}
|
||||
|
||||
interface CompanyFeedbackOptions extends BaseClientOptions {
|
||||
targetType?: string;
|
||||
vote?: string;
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
issueId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sharedOnly?: boolean;
|
||||
includePayload?: boolean;
|
||||
out?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
interface CompanyImportOptions extends BaseClientOptions {
|
||||
include?: string;
|
||||
target?: CompanyImportTargetMode;
|
||||
@@ -765,8 +786,15 @@ export function isHttpUrl(input: string): boolean {
|
||||
return /^https?:\/\//i.test(input.trim());
|
||||
}
|
||||
|
||||
export function isGithubUrl(input: string): boolean {
|
||||
return /^https?:\/\/github\.com\//i.test(input.trim());
|
||||
export function looksLikeRepoUrl(input: string): boolean {
|
||||
try {
|
||||
const url = new URL(input.trim());
|
||||
if (url.protocol !== "https:") return false;
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
return segments.length >= 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isGithubSegment(input: string): boolean {
|
||||
@@ -797,13 +825,15 @@ function normalizeGithubImportPath(input: string | null | undefined): string | n
|
||||
}
|
||||
|
||||
function buildGithubImportUrl(input: {
|
||||
hostname?: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
ref?: string | null;
|
||||
path?: string | null;
|
||||
companyPath?: string | null;
|
||||
}): string {
|
||||
const url = new URL(`https://github.com/${input.owner}/${input.repo.replace(/\.git$/i, "")}`);
|
||||
const host = input.hostname || "github.com";
|
||||
const url = new URL(`https://${host}/${input.owner}/${input.repo.replace(/\.git$/i, "")}`);
|
||||
const ref = input.ref?.trim();
|
||||
if (ref) {
|
||||
url.searchParams.set("ref", ref);
|
||||
@@ -834,14 +864,15 @@ export function normalizeGithubImportSource(input: string, refOverride?: string)
|
||||
});
|
||||
}
|
||||
|
||||
if (!isGithubUrl(trimmed)) {
|
||||
throw new Error("GitHub source must be a github.com URL or owner/repo[/path] shorthand.");
|
||||
if (!looksLikeRepoUrl(trimmed)) {
|
||||
throw new Error("GitHub source must be a GitHub or GitHub Enterprise URL, or owner/repo[/path] shorthand.");
|
||||
}
|
||||
if (!ref) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const url = new URL(trimmed);
|
||||
const hostname = url.hostname;
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
throw new Error("Invalid GitHub URL.");
|
||||
@@ -852,18 +883,18 @@ export function normalizeGithubImportSource(input: string, refOverride?: string)
|
||||
const existingPath = normalizeGithubImportPath(url.searchParams.get("path"));
|
||||
const existingCompanyPath = normalizeGithubImportPath(url.searchParams.get("companyPath"));
|
||||
if (existingCompanyPath) {
|
||||
return buildGithubImportUrl({ owner, repo, ref, companyPath: existingCompanyPath });
|
||||
return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: existingCompanyPath });
|
||||
}
|
||||
if (existingPath) {
|
||||
return buildGithubImportUrl({ owner, repo, ref, path: existingPath });
|
||||
return buildGithubImportUrl({ hostname, owner, repo, ref, path: existingPath });
|
||||
}
|
||||
if (parts[2] === "tree") {
|
||||
return buildGithubImportUrl({ owner, repo, ref, path: parts.slice(4).join("/") });
|
||||
return buildGithubImportUrl({ hostname, owner, repo, ref, path: parts.slice(4).join("/") });
|
||||
}
|
||||
if (parts[2] === "blob") {
|
||||
return buildGithubImportUrl({ owner, repo, ref, companyPath: parts.slice(4).join("/") });
|
||||
return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: parts.slice(4).join("/") });
|
||||
}
|
||||
return buildGithubImportUrl({ owner, repo, ref });
|
||||
return buildGithubImportUrl({ hostname, owner, repo, ref });
|
||||
}
|
||||
|
||||
async function pathExists(inputPath: string): Promise<boolean> {
|
||||
@@ -1093,6 +1124,91 @@ export function registerCompanyCommands(program: Command): void {
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
company
|
||||
.command("feedback:list")
|
||||
.description("List feedback traces for a company")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--project-id <id>", "Filter by project ID")
|
||||
.option("--issue-id <id>", "Filter by issue ID")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--include-payload", "Include stored payload snapshots in the response")
|
||||
.action(async (opts: CompanyFeedbackOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const traces = (await ctx.api.get<FeedbackTrace[]>(
|
||||
`/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts)}`,
|
||||
)) ?? [];
|
||||
if (ctx.json) {
|
||||
printOutput(traces, { json: true });
|
||||
return;
|
||||
}
|
||||
printOutput(
|
||||
traces.map((trace) => ({
|
||||
id: trace.id,
|
||||
issue: trace.issueIdentifier ?? trace.issueId,
|
||||
vote: trace.vote,
|
||||
status: trace.status,
|
||||
targetType: trace.targetType,
|
||||
target: trace.targetSummary.label,
|
||||
})),
|
||||
{ json: false },
|
||||
);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
company
|
||||
.command("feedback:export")
|
||||
.description("Export feedback traces for a company")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--project-id <id>", "Filter by project ID")
|
||||
.option("--issue-id <id>", "Filter by issue ID")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--include-payload", "Include stored payload snapshots in the export")
|
||||
.option("--out <path>", "Write export to a file path instead of stdout")
|
||||
.option("--format <format>", "Export format: json or ndjson", "ndjson")
|
||||
.action(async (opts: CompanyFeedbackOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const traces = (await ctx.api.get<FeedbackTrace[]>(
|
||||
`/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`,
|
||||
)) ?? [];
|
||||
const serialized = serializeFeedbackTraces(traces, opts.format);
|
||||
if (opts.out?.trim()) {
|
||||
await writeFile(opts.out, serialized, "utf8");
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{ out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) },
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
company
|
||||
.command("export")
|
||||
@@ -1208,13 +1324,13 @@ export function registerCompanyCommands(program: Command): void {
|
||||
| { type: "github"; url: string };
|
||||
|
||||
const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from);
|
||||
const isGithubSource = isGithubUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath);
|
||||
const isGithubSource = looksLikeRepoUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath);
|
||||
|
||||
if (isHttpUrl(from) || isGithubSource) {
|
||||
if (!isGithubUrl(from) && !isGithubShorthand(from)) {
|
||||
if (!looksLikeRepoUrl(from) && !isGithubShorthand(from)) {
|
||||
throw new Error(
|
||||
"Only GitHub URLs and local paths are supported for import. " +
|
||||
"Generic HTTP URLs are not supported. Use a GitHub URL (https://github.com/...) or a local directory path.",
|
||||
"Generic HTTP URLs are not supported. Use a GitHub or GitHub Enterprise URL (https://github.com/... or https://ghe.example.com/...) or a local directory path.",
|
||||
);
|
||||
}
|
||||
sourcePayload = { type: "github", url: normalizeGithubImportSource(from, opts.ref) };
|
||||
@@ -1325,6 +1441,12 @@ export function registerCompanyCommands(program: Command): void {
|
||||
if (!imported) {
|
||||
throw new Error("Import request returned no data.");
|
||||
}
|
||||
const tc = getTelemetryClient();
|
||||
if (tc) {
|
||||
const isPrivate = sourcePayload.type !== "github";
|
||||
const sourceRef = sourcePayload.type === "github" ? sourcePayload.url : from;
|
||||
trackCompanyImported(tc, { sourceType: sourcePayload.type, sourceRef, isPrivate });
|
||||
}
|
||||
let companyUrl: string | undefined;
|
||||
if (!ctx.json) {
|
||||
try {
|
||||
|
||||
645
cli/src/commands/client/feedback.ts
Normal file
645
cli/src/commands/client/feedback.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import { Command } from "commander";
|
||||
import type { Company, FeedbackTrace, FeedbackTraceBundle } from "@paperclipai/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
type ResolvedClientContext,
|
||||
} from "./common.js";
|
||||
|
||||
interface FeedbackFilterOptions extends BaseClientOptions {
|
||||
targetType?: string;
|
||||
vote?: string;
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
issueId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sharedOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface FeedbackTraceQueryOptions {
|
||||
targetType?: string;
|
||||
vote?: string;
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
issueId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sharedOnly?: boolean;
|
||||
}
|
||||
|
||||
interface FeedbackReportOptions extends FeedbackFilterOptions {
|
||||
payloads?: boolean;
|
||||
}
|
||||
|
||||
interface FeedbackExportOptions extends FeedbackFilterOptions {
|
||||
out?: string;
|
||||
}
|
||||
|
||||
interface FeedbackSummary {
|
||||
total: number;
|
||||
thumbsUp: number;
|
||||
thumbsDown: number;
|
||||
withReason: number;
|
||||
statuses: Record<string, number>;
|
||||
}
|
||||
|
||||
interface FeedbackExportManifest {
|
||||
exportedAt: string;
|
||||
serverUrl: string;
|
||||
companyId: string;
|
||||
summary: FeedbackSummary & {
|
||||
uniqueIssues: number;
|
||||
issues: string[];
|
||||
};
|
||||
files: {
|
||||
votes: string[];
|
||||
traces: string[];
|
||||
fullTraces: string[];
|
||||
zip: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FeedbackExportResult {
|
||||
outputDir: string;
|
||||
zipPath: string;
|
||||
manifest: FeedbackExportManifest;
|
||||
}
|
||||
|
||||
export function registerFeedbackCommands(program: Command): void {
|
||||
const feedback = program.command("feedback").description("Inspect and export local feedback traces");
|
||||
|
||||
addCommonClientOptions(
|
||||
feedback
|
||||
.command("report")
|
||||
.description("Render a terminal report for company feedback traces")
|
||||
.option("-C, --company-id <id>", "Company ID (overrides context default)")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--project-id <id>", "Filter by project ID")
|
||||
.option("--issue-id <id>", "Filter by issue ID")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--payloads", "Include raw payload dumps in the terminal report", false)
|
||||
.action(async (opts: FeedbackReportOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
|
||||
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
|
||||
const summary = summarizeFeedbackTraces(traces);
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId,
|
||||
summary,
|
||||
traces,
|
||||
},
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(renderFeedbackReport({
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId,
|
||||
traces,
|
||||
summary,
|
||||
includePayloads: Boolean(opts.payloads),
|
||||
}));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
feedback
|
||||
.command("export")
|
||||
.description("Export feedback votes and raw trace bundles into a folder plus zip archive")
|
||||
.option("-C, --company-id <id>", "Company ID (overrides context default)")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--project-id <id>", "Filter by project ID")
|
||||
.option("--issue-id <id>", "Filter by issue ID")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--out <path>", "Output directory (default: ./feedback-export-<timestamp>)")
|
||||
.action(async (opts: FeedbackExportOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
|
||||
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
|
||||
const outputDir = path.resolve(opts.out?.trim() || defaultFeedbackExportDirName());
|
||||
const exported = await writeFeedbackExportBundle({
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId,
|
||||
traces,
|
||||
outputDir,
|
||||
traceBundleFetcher: (trace) => fetchFeedbackTraceBundle(ctx, trace.id),
|
||||
});
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{
|
||||
companyId,
|
||||
outputDir: exported.outputDir,
|
||||
zipPath: exported.zipPath,
|
||||
summary: exported.manifest.summary,
|
||||
},
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(renderFeedbackExportSummary(exported));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveFeedbackCompanyId(
|
||||
ctx: ResolvedClientContext,
|
||||
explicitCompanyId?: string,
|
||||
): Promise<string> {
|
||||
const direct = explicitCompanyId?.trim() || ctx.companyId?.trim();
|
||||
if (direct) return direct;
|
||||
const companies = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
|
||||
const companyId = companies[0]?.id?.trim();
|
||||
if (!companyId) {
|
||||
throw new Error(
|
||||
"Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or configure a CLI context default.",
|
||||
);
|
||||
}
|
||||
return companyId;
|
||||
}
|
||||
|
||||
export function buildFeedbackTraceQuery(opts: FeedbackTraceQueryOptions, includePayload = true): string {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.targetType) params.set("targetType", opts.targetType);
|
||||
if (opts.vote) params.set("vote", opts.vote);
|
||||
if (opts.status) params.set("status", opts.status);
|
||||
if (opts.projectId) params.set("projectId", opts.projectId);
|
||||
if (opts.issueId) params.set("issueId", opts.issueId);
|
||||
if (opts.from) params.set("from", opts.from);
|
||||
if (opts.to) params.set("to", opts.to);
|
||||
if (opts.sharedOnly) params.set("sharedOnly", "true");
|
||||
if (includePayload) params.set("includePayload", "true");
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
export function normalizeFeedbackTraceExportFormat(value: string | undefined): "json" | "ndjson" {
|
||||
if (!value || value === "ndjson") return "ndjson";
|
||||
if (value === "json") return "json";
|
||||
throw new Error(`Unsupported export format: ${value}`);
|
||||
}
|
||||
|
||||
export function serializeFeedbackTraces(traces: FeedbackTrace[], format: string | undefined): string {
|
||||
if (normalizeFeedbackTraceExportFormat(format) === "json") {
|
||||
return JSON.stringify(traces, null, 2);
|
||||
}
|
||||
return traces.map((trace) => JSON.stringify(trace)).join("\n");
|
||||
}
|
||||
|
||||
export async function fetchCompanyFeedbackTraces(
|
||||
ctx: ResolvedClientContext,
|
||||
companyId: string,
|
||||
opts: FeedbackFilterOptions,
|
||||
): Promise<FeedbackTrace[]> {
|
||||
return (
|
||||
(await ctx.api.get<FeedbackTrace[]>(
|
||||
`/api/companies/${companyId}/feedback-traces${buildFeedbackTraceQuery(opts, true)}`,
|
||||
)) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchFeedbackTraceBundle(
|
||||
ctx: ResolvedClientContext,
|
||||
traceId: string,
|
||||
): Promise<FeedbackTraceBundle> {
|
||||
const bundle = await ctx.api.get<FeedbackTraceBundle>(`/api/feedback-traces/${traceId}/bundle`);
|
||||
if (!bundle) {
|
||||
throw new Error(`Feedback trace bundle ${traceId} not found`);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
export function summarizeFeedbackTraces(traces: FeedbackTrace[]): FeedbackSummary {
|
||||
const statuses: Record<string, number> = {};
|
||||
let thumbsUp = 0;
|
||||
let thumbsDown = 0;
|
||||
let withReason = 0;
|
||||
|
||||
for (const trace of traces) {
|
||||
if (trace.vote === "up") thumbsUp += 1;
|
||||
if (trace.vote === "down") thumbsDown += 1;
|
||||
if (readFeedbackReason(trace)) withReason += 1;
|
||||
statuses[trace.status] = (statuses[trace.status] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
total: traces.length,
|
||||
thumbsUp,
|
||||
thumbsDown,
|
||||
withReason,
|
||||
statuses,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderFeedbackReport(input: {
|
||||
apiBase: string;
|
||||
companyId: string;
|
||||
traces: FeedbackTrace[];
|
||||
summary: FeedbackSummary;
|
||||
includePayloads: boolean;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("");
|
||||
lines.push(pc.bold(pc.magenta("Paperclip Feedback Report")));
|
||||
lines.push(pc.dim(new Date().toISOString()));
|
||||
lines.push(horizontalRule());
|
||||
lines.push(`${pc.dim("Server:")} ${input.apiBase}`);
|
||||
lines.push(`${pc.dim("Company:")} ${input.companyId}`);
|
||||
lines.push("");
|
||||
|
||||
if (input.traces.length === 0) {
|
||||
lines.push(pc.yellow("[!!] No feedback traces found."));
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
lines.push(pc.bold(pc.cyan("Summary")));
|
||||
lines.push(horizontalRule());
|
||||
lines.push(` ${pc.green(pc.bold(String(input.summary.thumbsUp)))} thumbs up`);
|
||||
lines.push(` ${pc.red(pc.bold(String(input.summary.thumbsDown)))} thumbs down`);
|
||||
lines.push(` ${pc.yellow(pc.bold(String(input.summary.withReason)))} downvotes with a reason`);
|
||||
lines.push(` ${pc.bold(String(input.summary.total))} total traces`);
|
||||
lines.push("");
|
||||
lines.push(pc.dim("Export status:"));
|
||||
for (const status of ["pending", "sent", "local_only", "failed"]) {
|
||||
lines.push(` ${padRight(status, 10)} ${input.summary.statuses[status] ?? 0}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(pc.bold(pc.cyan("Trace Details")));
|
||||
lines.push(horizontalRule());
|
||||
|
||||
for (const trace of input.traces) {
|
||||
const voteColor = trace.vote === "up" ? pc.green : pc.red;
|
||||
const voteIcon = trace.vote === "up" ? "^" : "v";
|
||||
const issueRef = trace.issueIdentifier ?? trace.issueId;
|
||||
const label = trace.targetSummary.label?.trim() || trace.targetType;
|
||||
const excerpt = compactText(trace.targetSummary.excerpt);
|
||||
const reason = readFeedbackReason(trace);
|
||||
lines.push(
|
||||
` ${voteColor(voteIcon)} ${pc.bold(issueRef)} ${pc.dim(compactText(trace.issueTitle, 64))}`,
|
||||
);
|
||||
lines.push(
|
||||
` ${pc.dim("Trace:")} ${trace.id.slice(0, 8)} ${pc.dim("Status:")} ${trace.status} ${pc.dim("Date:")} ${formatTimestamp(trace.createdAt)}`,
|
||||
);
|
||||
lines.push(` ${pc.dim("Target:")} ${label}`);
|
||||
if (excerpt) {
|
||||
lines.push(` ${pc.dim("Excerpt:")} ${excerpt}`);
|
||||
}
|
||||
if (reason) {
|
||||
lines.push(` ${pc.yellow(pc.bold("Reason:"))} ${pc.yellow(reason)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (input.includePayloads) {
|
||||
lines.push(pc.bold(pc.cyan("Raw Payloads")));
|
||||
lines.push(horizontalRule());
|
||||
for (const trace of input.traces) {
|
||||
if (!trace.payloadSnapshot) continue;
|
||||
const issueRef = trace.issueIdentifier ?? trace.issueId;
|
||||
lines.push(` ${pc.bold(`${issueRef} (${trace.id.slice(0, 8)})`)}`);
|
||||
const body = JSON.stringify(trace.payloadSnapshot, null, 2)?.split("\n") ?? [];
|
||||
for (const line of body) {
|
||||
lines.push(` ${pc.dim(line)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(horizontalRule());
|
||||
lines.push(pc.dim(`Report complete. ${input.traces.length} trace(s) displayed.`));
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function writeFeedbackExportBundle(input: {
|
||||
apiBase: string;
|
||||
companyId: string;
|
||||
traces: FeedbackTrace[];
|
||||
outputDir: string;
|
||||
traceBundleFetcher?: (trace: FeedbackTrace) => Promise<FeedbackTraceBundle>;
|
||||
}): Promise<FeedbackExportResult> {
|
||||
await ensureEmptyOutputDirectory(input.outputDir);
|
||||
await mkdir(path.join(input.outputDir, "votes"), { recursive: true });
|
||||
await mkdir(path.join(input.outputDir, "traces"), { recursive: true });
|
||||
await mkdir(path.join(input.outputDir, "full-traces"), { recursive: true });
|
||||
|
||||
const summary = summarizeFeedbackTraces(input.traces);
|
||||
const voteFiles: string[] = [];
|
||||
const traceFiles: string[] = [];
|
||||
const fullTraceDirs: string[] = [];
|
||||
const fullTraceFiles: string[] = [];
|
||||
const issueSet = new Set<string>();
|
||||
|
||||
for (const trace of input.traces) {
|
||||
const issueRef = sanitizeFileSegment(trace.issueIdentifier ?? trace.issueId);
|
||||
const voteRecord = buildFeedbackVoteRecord(trace);
|
||||
const voteFileName = `${issueRef}-${trace.feedbackVoteId.slice(0, 8)}.json`;
|
||||
const traceFileName = `${issueRef}-${trace.id.slice(0, 8)}.json`;
|
||||
voteFiles.push(voteFileName);
|
||||
traceFiles.push(traceFileName);
|
||||
issueSet.add(trace.issueIdentifier ?? trace.issueId);
|
||||
await writeFile(
|
||||
path.join(input.outputDir, "votes", voteFileName),
|
||||
`${JSON.stringify(voteRecord, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(input.outputDir, "traces", traceFileName),
|
||||
`${JSON.stringify(trace, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
if (input.traceBundleFetcher) {
|
||||
const bundle = await input.traceBundleFetcher(trace);
|
||||
const bundleDirName = `${issueRef}-${trace.id.slice(0, 8)}`;
|
||||
const bundleDir = path.join(input.outputDir, "full-traces", bundleDirName);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
fullTraceDirs.push(bundleDirName);
|
||||
await writeFile(
|
||||
path.join(bundleDir, "bundle.json"),
|
||||
`${JSON.stringify(bundle, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, "bundle.json"));
|
||||
for (const file of bundle.files) {
|
||||
const targetPath = path.join(bundleDir, file.path);
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, file.contents, "utf8");
|
||||
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, file.path.replace(/\\/g, "/")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zipPath = `${input.outputDir}.zip`;
|
||||
const manifest: FeedbackExportManifest = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
serverUrl: input.apiBase,
|
||||
companyId: input.companyId,
|
||||
summary: {
|
||||
...summary,
|
||||
uniqueIssues: issueSet.size,
|
||||
issues: Array.from(issueSet).sort((left, right) => left.localeCompare(right)),
|
||||
},
|
||||
files: {
|
||||
votes: voteFiles.slice().sort((left, right) => left.localeCompare(right)),
|
||||
traces: traceFiles.slice().sort((left, right) => left.localeCompare(right)),
|
||||
fullTraces: fullTraceDirs.slice().sort((left, right) => left.localeCompare(right)),
|
||||
zip: path.basename(zipPath),
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
path.join(input.outputDir, "index.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const archiveFiles = await collectJsonFilesForArchive(input.outputDir, [
|
||||
"index.json",
|
||||
...manifest.files.votes.map((file) => path.posix.join("votes", file)),
|
||||
...manifest.files.traces.map((file) => path.posix.join("traces", file)),
|
||||
...fullTraceFiles,
|
||||
]);
|
||||
await writeFile(zipPath, createStoredZipArchive(archiveFiles, path.basename(input.outputDir)));
|
||||
|
||||
return {
|
||||
outputDir: input.outputDir,
|
||||
zipPath,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderFeedbackExportSummary(exported: FeedbackExportResult): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("");
|
||||
lines.push(pc.bold(pc.magenta("Paperclip Feedback Export")));
|
||||
lines.push(pc.dim(exported.manifest.exportedAt));
|
||||
lines.push(horizontalRule());
|
||||
lines.push(`${pc.dim("Company:")} ${exported.manifest.companyId}`);
|
||||
lines.push(`${pc.dim("Output:")} ${exported.outputDir}`);
|
||||
lines.push(`${pc.dim("Archive:")} ${exported.zipPath}`);
|
||||
lines.push("");
|
||||
lines.push(pc.bold("Export Summary"));
|
||||
lines.push(horizontalRule());
|
||||
lines.push(` ${pc.green(pc.bold(String(exported.manifest.summary.thumbsUp)))} thumbs up`);
|
||||
lines.push(` ${pc.red(pc.bold(String(exported.manifest.summary.thumbsDown)))} thumbs down`);
|
||||
lines.push(` ${pc.yellow(pc.bold(String(exported.manifest.summary.withReason)))} with reason`);
|
||||
lines.push(` ${pc.bold(String(exported.manifest.summary.uniqueIssues))} unique issues`);
|
||||
lines.push("");
|
||||
lines.push(pc.dim("Files:"));
|
||||
lines.push(` ${path.join(exported.outputDir, "index.json")}`);
|
||||
lines.push(` ${path.join(exported.outputDir, "votes")} (${exported.manifest.files.votes.length} files)`);
|
||||
lines.push(` ${path.join(exported.outputDir, "traces")} (${exported.manifest.files.traces.length} files)`);
|
||||
lines.push(` ${path.join(exported.outputDir, "full-traces")} (${exported.manifest.files.fullTraces.length} bundles)`);
|
||||
lines.push(` ${exported.zipPath}`);
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function readFeedbackReason(trace: FeedbackTrace): string | null {
|
||||
const payload = asRecord(trace.payloadSnapshot);
|
||||
const vote = asRecord(payload?.vote);
|
||||
const reason = vote?.reason;
|
||||
return typeof reason === "string" && reason.trim() ? reason.trim() : null;
|
||||
}
|
||||
|
||||
function buildFeedbackVoteRecord(trace: FeedbackTrace) {
|
||||
return {
|
||||
voteId: trace.feedbackVoteId,
|
||||
traceId: trace.id,
|
||||
issueId: trace.issueId,
|
||||
issueIdentifier: trace.issueIdentifier,
|
||||
issueTitle: trace.issueTitle,
|
||||
vote: trace.vote,
|
||||
targetType: trace.targetType,
|
||||
targetId: trace.targetId,
|
||||
targetSummary: trace.targetSummary,
|
||||
status: trace.status,
|
||||
consentVersion: trace.consentVersion,
|
||||
createdAt: trace.createdAt,
|
||||
updatedAt: trace.updatedAt,
|
||||
reason: readFeedbackReason(trace),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function compactText(value: string | null | undefined, maxLength = 88): string | null {
|
||||
if (!value) return null;
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
if (!compact) return null;
|
||||
if (compact.length <= maxLength) return compact;
|
||||
return `${compact.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 19).replace("T", " ");
|
||||
if (typeof value === "string") return value.slice(0, 19).replace("T", " ");
|
||||
return "-";
|
||||
}
|
||||
|
||||
function horizontalRule(): string {
|
||||
return pc.dim("-".repeat(72));
|
||||
}
|
||||
|
||||
function padRight(value: string, width: number): string {
|
||||
return `${value}${" ".repeat(Math.max(0, width - value.length))}`;
|
||||
}
|
||||
|
||||
function defaultFeedbackExportDirName(): string {
|
||||
const iso = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
return `feedback-export-${iso}`;
|
||||
}
|
||||
|
||||
async function ensureEmptyOutputDirectory(outputDir: string): Promise<void> {
|
||||
try {
|
||||
const info = await stat(outputDir);
|
||||
if (!info.isDirectory()) {
|
||||
throw new Error(`Output path already exists and is not a directory: ${outputDir}`);
|
||||
}
|
||||
const entries = await readdir(outputDir);
|
||||
if (entries.length > 0) {
|
||||
throw new Error(`Output directory already exists and is not empty: ${outputDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/ENOENT/.test(message)) {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectJsonFilesForArchive(
|
||||
outputDir: string,
|
||||
relativePaths: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
for (const relativePath of relativePaths) {
|
||||
const normalized = relativePath.replace(/\\/g, "/");
|
||||
files[normalized] = await readFile(path.join(outputDir, normalized), "utf8");
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function sanitizeFileSegment(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "feedback";
|
||||
}
|
||||
|
||||
function writeUint16(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
}
|
||||
|
||||
function writeUint32(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
target[offset + 2] = (value >>> 16) & 0xff;
|
||||
target[offset + 3] = (value >>> 24) & 0xff;
|
||||
}
|
||||
|
||||
function crc32(bytes: Uint8Array) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function createStoredZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
const localChunks: Uint8Array[] = [];
|
||||
const centralChunks: Uint8Array[] = [];
|
||||
let localOffset = 0;
|
||||
let entryCount = 0;
|
||||
|
||||
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
|
||||
const body = encoder.encode(content);
|
||||
const checksum = crc32(body);
|
||||
|
||||
const localHeader = new Uint8Array(30 + fileName.length);
|
||||
writeUint32(localHeader, 0, 0x04034b50);
|
||||
writeUint16(localHeader, 4, 20);
|
||||
writeUint16(localHeader, 6, 0x0800);
|
||||
writeUint16(localHeader, 8, 0);
|
||||
writeUint32(localHeader, 14, checksum);
|
||||
writeUint32(localHeader, 18, body.length);
|
||||
writeUint32(localHeader, 22, body.length);
|
||||
writeUint16(localHeader, 26, fileName.length);
|
||||
localHeader.set(fileName, 30);
|
||||
|
||||
const centralHeader = new Uint8Array(46 + fileName.length);
|
||||
writeUint32(centralHeader, 0, 0x02014b50);
|
||||
writeUint16(centralHeader, 4, 20);
|
||||
writeUint16(centralHeader, 6, 20);
|
||||
writeUint16(centralHeader, 8, 0x0800);
|
||||
writeUint16(centralHeader, 10, 0);
|
||||
writeUint32(centralHeader, 16, checksum);
|
||||
writeUint32(centralHeader, 20, body.length);
|
||||
writeUint32(centralHeader, 24, body.length);
|
||||
writeUint16(centralHeader, 28, fileName.length);
|
||||
writeUint32(centralHeader, 42, localOffset);
|
||||
centralHeader.set(fileName, 46);
|
||||
|
||||
localChunks.push(localHeader, body);
|
||||
centralChunks.push(centralHeader);
|
||||
localOffset += localHeader.length + body.length;
|
||||
entryCount += 1;
|
||||
}
|
||||
|
||||
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const archive = new Uint8Array(
|
||||
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of localChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
const centralDirectoryOffset = offset;
|
||||
for (const chunk of centralChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
writeUint32(archive, offset, 0x06054b50);
|
||||
writeUint16(archive, offset + 8, entryCount);
|
||||
writeUint16(archive, offset + 10, entryCount);
|
||||
writeUint32(archive, offset + 12, centralDirectoryLength);
|
||||
writeUint32(archive, offset + 16, centralDirectoryOffset);
|
||||
return archive;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Command } from "commander";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
checkoutIssueSchema,
|
||||
createIssueSchema,
|
||||
type FeedbackTrace,
|
||||
updateIssueSchema,
|
||||
type Issue,
|
||||
type IssueComment,
|
||||
@@ -15,6 +17,11 @@ import {
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
import {
|
||||
buildFeedbackTraceQuery,
|
||||
normalizeFeedbackTraceExportFormat,
|
||||
serializeFeedbackTraces,
|
||||
} from "./feedback.js";
|
||||
|
||||
interface IssueBaseOptions extends BaseClientOptions {
|
||||
status?: string;
|
||||
@@ -61,6 +68,18 @@ interface IssueCheckoutOptions extends BaseClientOptions {
|
||||
expectedStatuses?: string;
|
||||
}
|
||||
|
||||
interface IssueFeedbackOptions extends BaseClientOptions {
|
||||
targetType?: string;
|
||||
vote?: string;
|
||||
status?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sharedOnly?: boolean;
|
||||
includePayload?: boolean;
|
||||
out?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export function registerIssueCommands(program: Command): void {
|
||||
const issue = program.command("issue").description("Issue operations");
|
||||
|
||||
@@ -237,6 +256,85 @@ export function registerIssueCommands(program: Command): void {
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("feedback:list")
|
||||
.description("List feedback traces for an issue")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--include-payload", "Include stored payload snapshots in the response")
|
||||
.action(async (issueId: string, opts: IssueFeedbackOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const traces = (await ctx.api.get<FeedbackTrace[]>(
|
||||
`/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts)}`,
|
||||
)) ?? [];
|
||||
if (ctx.json) {
|
||||
printOutput(traces, { json: true });
|
||||
return;
|
||||
}
|
||||
printOutput(
|
||||
traces.map((trace) => ({
|
||||
id: trace.id,
|
||||
issue: trace.issueIdentifier ?? trace.issueId,
|
||||
vote: trace.vote,
|
||||
status: trace.status,
|
||||
targetType: trace.targetType,
|
||||
target: trace.targetSummary.label,
|
||||
})),
|
||||
{ json: false },
|
||||
);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("feedback:export")
|
||||
.description("Export feedback traces for an issue")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--include-payload", "Include stored payload snapshots in the export")
|
||||
.option("--out <path>", "Write export to a file path instead of stdout")
|
||||
.option("--format <format>", "Export format: json or ndjson", "ndjson")
|
||||
.action(async (issueId: string, opts: IssueFeedbackOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const traces = (await ctx.api.get<FeedbackTrace[]>(
|
||||
`/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`,
|
||||
)) ?? [];
|
||||
const serialized = serializeFeedbackTraces(traces, opts.format);
|
||||
if (opts.out?.trim()) {
|
||||
await writeFile(opts.out, serialized, "utf8");
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{ out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) },
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("checkout")
|
||||
|
||||
@@ -54,6 +54,7 @@ function defaultConfig(): PaperclipConfig {
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
bind: "loopback",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
@@ -63,6 +64,9 @@ function defaultConfig(): PaperclipConfig {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: connection.value,
|
||||
backupDir,
|
||||
retentionDays,
|
||||
retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||
filenamePrefix,
|
||||
});
|
||||
spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`);
|
||||
|
||||
@@ -3,10 +3,14 @@ import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
AUTH_BASE_URL_MODES,
|
||||
BIND_MODES,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
DEPLOYMENT_MODES,
|
||||
SECRET_PROVIDERS,
|
||||
STORAGE_PROVIDERS,
|
||||
inferBindModeFromHost,
|
||||
resolveRuntimeBind,
|
||||
type BindMode,
|
||||
type AuthBaseUrlMode,
|
||||
type DeploymentExposure,
|
||||
type DeploymentMode,
|
||||
@@ -23,6 +27,7 @@ import { promptLogging } from "../prompts/logging.js";
|
||||
import { defaultSecretsConfig } from "../prompts/secrets.js";
|
||||
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import { buildPresetServerConfig } from "../config/server-bind.js";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
expandHomePrefix,
|
||||
@@ -33,6 +38,11 @@ import {
|
||||
} from "../config/home.js";
|
||||
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
import {
|
||||
getTelemetryClient,
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
} from "../telemetry.js";
|
||||
|
||||
type SetupMode = "quickstart" | "advanced";
|
||||
|
||||
@@ -41,10 +51,14 @@ type OnboardOptions = {
|
||||
run?: boolean;
|
||||
yes?: boolean;
|
||||
invokedByRun?: boolean;
|
||||
bind?: BindMode;
|
||||
};
|
||||
|
||||
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
||||
|
||||
const TAILNET_BIND_WARNING =
|
||||
"No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set.";
|
||||
|
||||
const ONBOARD_ENV_KEYS = [
|
||||
"PAPERCLIP_PUBLIC_URL",
|
||||
"DATABASE_URL",
|
||||
@@ -54,6 +68,9 @@ const ONBOARD_ENV_KEYS = [
|
||||
"PAPERCLIP_DB_BACKUP_DIR",
|
||||
"PAPERCLIP_DEPLOYMENT_MODE",
|
||||
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||
"PAPERCLIP_BIND",
|
||||
"PAPERCLIP_BIND_HOST",
|
||||
"PAPERCLIP_TAILNET_BIND_HOST",
|
||||
"HOST",
|
||||
"PORT",
|
||||
"SERVE_UI",
|
||||
@@ -99,29 +116,62 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null {
|
||||
return path.resolve(expandHomePrefix(rawValue.trim()));
|
||||
}
|
||||
|
||||
function quickstartDefaultsFromEnv(): {
|
||||
function describeServerBinding(server: Pick<PaperclipConfig["server"], "bind" | "customBindHost" | "host" | "port">): string {
|
||||
const bind = server.bind ?? inferBindModeFromHost(server.host);
|
||||
const detail =
|
||||
bind === "custom"
|
||||
? server.customBindHost ?? server.host
|
||||
: bind === "tailnet"
|
||||
? "detected tailscale address"
|
||||
: server.host;
|
||||
return `${bind}${detail ? ` (${detail})` : ""}:${server.port}`;
|
||||
}
|
||||
|
||||
function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): {
|
||||
defaults: OnboardDefaults;
|
||||
usedEnvKeys: string[];
|
||||
ignoredEnvKeys: Array<{ key: string; reason: string }>;
|
||||
} {
|
||||
const preferTrustedLocal = opts?.preferTrustedLocal ?? false;
|
||||
const instanceId = resolvePaperclipInstanceId();
|
||||
const defaultStorage = defaultStorageConfig();
|
||||
const defaultSecrets = defaultSecretsConfig();
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
|
||||
const publicUrl =
|
||||
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
|
||||
process.env.BETTER_AUTH_URL?.trim() ||
|
||||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
|
||||
undefined;
|
||||
const deploymentMode =
|
||||
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
|
||||
const publicUrl = preferTrustedLocal
|
||||
? undefined
|
||||
: (
|
||||
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
|
||||
process.env.BETTER_AUTH_URL?.trim() ||
|
||||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
|
||||
undefined
|
||||
);
|
||||
const deploymentMode = preferTrustedLocal
|
||||
? "local_trusted"
|
||||
: (parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted");
|
||||
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
|
||||
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
);
|
||||
const deploymentExposure =
|
||||
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
|
||||
const bindFromEnv = parseEnumFromEnv<BindMode>(process.env.PAPERCLIP_BIND, BIND_MODES);
|
||||
const customBindHostFromEnv = process.env.PAPERCLIP_BIND_HOST?.trim() || undefined;
|
||||
const hostFromEnv = process.env.HOST?.trim() || undefined;
|
||||
const configuredBindHost = customBindHostFromEnv ?? hostFromEnv;
|
||||
const bind = preferTrustedLocal
|
||||
? "loopback"
|
||||
: (
|
||||
deploymentMode === "local_trusted"
|
||||
? "loopback"
|
||||
: (bindFromEnv ?? (configuredBindHost ? inferBindModeFromHost(configuredBindHost) : "lan"))
|
||||
);
|
||||
const resolvedBind = resolveRuntimeBind({
|
||||
bind,
|
||||
host: hostFromEnv ?? (bind === "loopback" ? "127.0.0.1" : "0.0.0.0"),
|
||||
customBindHost: customBindHostFromEnv,
|
||||
tailnetBindHost: process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim(),
|
||||
});
|
||||
const authPublicBaseUrl = publicUrl;
|
||||
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
|
||||
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
|
||||
@@ -178,7 +228,9 @@ function quickstartDefaultsFromEnv(): {
|
||||
server: {
|
||||
deploymentMode,
|
||||
exposure: deploymentExposure,
|
||||
host: process.env.HOST ?? "127.0.0.1",
|
||||
bind: resolvedBind.bind,
|
||||
...(resolvedBind.customBindHost ? { customBindHost: resolvedBind.customBindHost } : {}),
|
||||
host: resolvedBind.host,
|
||||
port: Number(process.env.PORT) || 3100,
|
||||
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
|
||||
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
|
||||
@@ -215,12 +267,49 @@ function quickstartDefaultsFromEnv(): {
|
||||
},
|
||||
};
|
||||
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
|
||||
if (preferTrustedLocal) {
|
||||
const forcedLocalReason = "Ignored because --yes quickstart forces trusted local loopback defaults";
|
||||
for (const key of [
|
||||
"PAPERCLIP_DEPLOYMENT_MODE",
|
||||
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||
"PAPERCLIP_BIND",
|
||||
"PAPERCLIP_BIND_HOST",
|
||||
"HOST",
|
||||
"PAPERCLIP_AUTH_BASE_URL_MODE",
|
||||
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
|
||||
"PAPERCLIP_PUBLIC_URL",
|
||||
"BETTER_AUTH_URL",
|
||||
"BETTER_AUTH_BASE_URL",
|
||||
] as const) {
|
||||
if (process.env[key] !== undefined) {
|
||||
ignoredEnvKeys.push({ key, reason: forcedLocalReason });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
|
||||
ignoredEnvKeys.push({
|
||||
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||
reason: "Ignored because deployment mode local_trusted always forces private exposure",
|
||||
});
|
||||
}
|
||||
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND !== undefined) {
|
||||
ignoredEnvKeys.push({
|
||||
key: "PAPERCLIP_BIND",
|
||||
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
|
||||
});
|
||||
}
|
||||
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND_HOST !== undefined) {
|
||||
ignoredEnvKeys.push({
|
||||
key: "PAPERCLIP_BIND_HOST",
|
||||
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
|
||||
});
|
||||
}
|
||||
if (deploymentMode === "local_trusted" && process.env.HOST !== undefined) {
|
||||
ignoredEnvKeys.push({
|
||||
key: "HOST",
|
||||
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
|
||||
});
|
||||
}
|
||||
|
||||
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
|
||||
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
|
||||
@@ -234,6 +323,10 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
|
||||
}
|
||||
|
||||
export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
if (opts.bind && !["loopback", "lan", "tailnet"].includes(opts.bind)) {
|
||||
throw new Error(`Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`);
|
||||
}
|
||||
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
@@ -244,11 +337,12 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
),
|
||||
);
|
||||
|
||||
let existingConfig: PaperclipConfig | null = null;
|
||||
if (configExists(opts.config)) {
|
||||
p.log.message(pc.dim(`${configPath} exists, updating config`));
|
||||
p.log.message(pc.dim(`${configPath} exists`));
|
||||
|
||||
try {
|
||||
readConfig(opts.config);
|
||||
existingConfig = readConfig(opts.config);
|
||||
} catch (err) {
|
||||
p.log.message(
|
||||
pc.yellow(
|
||||
@@ -258,9 +352,85 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (existingConfig) {
|
||||
p.log.message(
|
||||
pc.dim("Existing Paperclip install detected; keeping the current configuration unchanged."),
|
||||
);
|
||||
p.log.message(pc.dim(`Use ${pc.cyan("paperclipai configure")} if you want to change settings.`));
|
||||
|
||||
const jwtSecret = ensureAgentJwtSecret(configPath);
|
||||
const envFilePath = resolveAgentJwtEnvFile(configPath);
|
||||
if (jwtSecret.created) {
|
||||
p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
} else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) {
|
||||
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`);
|
||||
} else {
|
||||
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
}
|
||||
|
||||
const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath);
|
||||
if (keyResult.status === "created") {
|
||||
p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`);
|
||||
} else if (keyResult.status === "existing") {
|
||||
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
|
||||
}
|
||||
|
||||
p.note(
|
||||
[
|
||||
"Existing config preserved",
|
||||
`Database: ${existingConfig.database.mode}`,
|
||||
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
|
||||
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
|
||||
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${describeServerBinding(existingConfig.server)}`,
|
||||
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
|
||||
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
|
||||
`Storage: ${existingConfig.storage.provider}`,
|
||||
`Secrets: ${existingConfig.secrets.provider} (strict mode ${existingConfig.secrets.strictMode ? "on" : "off"})`,
|
||||
"Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured",
|
||||
].join("\n"),
|
||||
"Configuration ready",
|
||||
);
|
||||
|
||||
p.note(
|
||||
[
|
||||
`Run: ${pc.cyan("paperclipai run")}`,
|
||||
`Reconfigure later: ${pc.cyan("paperclipai configure")}`,
|
||||
`Diagnose setup: ${pc.cyan("paperclipai doctor")}`,
|
||||
].join("\n"),
|
||||
"Next commands",
|
||||
);
|
||||
|
||||
let shouldRunNow = opts.run === true || opts.yes === true;
|
||||
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
const answer = await p.confirm({
|
||||
message: "Start Paperclip now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!p.isCancel(answer)) {
|
||||
shouldRunNow = answer;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRunNow && !opts.invokedByRun) {
|
||||
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
|
||||
const { runCommand } = await import("./run.js");
|
||||
await runCommand({ config: configPath, repair: true, yes: true });
|
||||
return;
|
||||
}
|
||||
|
||||
p.outro("Existing Paperclip setup is ready.");
|
||||
return;
|
||||
}
|
||||
|
||||
let setupMode: SetupMode = "quickstart";
|
||||
if (opts.yes) {
|
||||
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));
|
||||
p.log.message(
|
||||
pc.dim(
|
||||
opts.bind
|
||||
? `\`--yes\` enabled: using Quickstart defaults with bind=${opts.bind}.`
|
||||
: "`--yes` enabled: using Quickstart defaults.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const setupModeChoice = await p.select({
|
||||
message: "Choose setup path",
|
||||
@@ -285,8 +455,13 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
setupMode = setupModeChoice as SetupMode;
|
||||
}
|
||||
|
||||
const tc = getTelemetryClient();
|
||||
if (tc) trackInstallStarted(tc);
|
||||
|
||||
let llm: PaperclipConfig["llm"] | undefined;
|
||||
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
|
||||
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv({
|
||||
preferTrustedLocal: opts.yes === true && !opts.bind,
|
||||
});
|
||||
let {
|
||||
database,
|
||||
logging,
|
||||
@@ -296,6 +471,19 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
secrets,
|
||||
} = derivedDefaults;
|
||||
|
||||
if (opts.bind === "loopback" || opts.bind === "lan" || opts.bind === "tailnet") {
|
||||
const preset = buildPresetServerConfig(opts.bind, {
|
||||
port: server.port,
|
||||
allowedHostnames: server.allowedHostnames,
|
||||
serveUi: server.serveUi,
|
||||
});
|
||||
server = preset.server;
|
||||
auth = preset.auth;
|
||||
if (opts.bind === "tailnet" && server.host === "127.0.0.1") {
|
||||
p.log.warn(TAILNET_BIND_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
if (setupMode === "advanced") {
|
||||
p.log.step(pc.bold("Database"));
|
||||
database = await promptDatabase(database);
|
||||
@@ -383,7 +571,13 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
);
|
||||
} else {
|
||||
p.log.step(pc.bold("Quickstart"));
|
||||
p.log.message(pc.dim("Using quickstart defaults."));
|
||||
p.log.message(
|
||||
pc.dim(
|
||||
opts.bind
|
||||
? `Using quickstart defaults with bind=${opts.bind}.`
|
||||
: `Using quickstart defaults: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}.`,
|
||||
),
|
||||
);
|
||||
if (usedEnvKeys.length > 0) {
|
||||
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
|
||||
} else {
|
||||
@@ -417,6 +611,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
logging,
|
||||
server,
|
||||
auth,
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage,
|
||||
secrets,
|
||||
};
|
||||
@@ -430,12 +627,16 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
|
||||
writeConfig(config, opts.config);
|
||||
|
||||
if (tc) trackInstallCompleted(tc, {
|
||||
adapterType: server.deploymentMode,
|
||||
});
|
||||
|
||||
p.note(
|
||||
[
|
||||
`Database: ${database.mode}`,
|
||||
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
||||
`Logging: ${logging.mode} -> ${logging.logDir}`,
|
||||
`Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`,
|
||||
`Server: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}`,
|
||||
`Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`,
|
||||
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
|
||||
`Storage: ${storage.provider}`,
|
||||
|
||||
352
cli/src/commands/routines.ts
Normal file
352
cli/src/commands/routines.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
createDb,
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
ensurePostgresDatabase,
|
||||
formatEmbeddedPostgresError,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
|
||||
type RoutinesDisableAllOptions = {
|
||||
config?: string;
|
||||
dataDir?: string;
|
||||
companyId?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type DisableAllRoutinesResult = {
|
||||
companyId: string;
|
||||
totalRoutines: number;
|
||||
pausedCount: number;
|
||||
alreadyPausedCount: number;
|
||||
archivedCount: number;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
type EmbeddedPostgresHandle = {
|
||||
port: number;
|
||||
startedByThisProcess: boolean;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type ClosableDb = ReturnType<typeof createDb> & {
|
||||
$client?: {
|
||||
end?: (options?: { timeout?: number }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(preferredPort: number): Promise<number> {
|
||||
let port = Math.max(1, Math.trunc(preferredPort));
|
||||
while (!(await isPortAvailable(port))) {
|
||||
port += 1;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function readPidFilePort(postmasterPidFile: string): number | null {
|
||||
if (!fs.existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const lines = fs.readFileSync(postmasterPidFile, "utf8").split("\n");
|
||||
const port = Number(lines[3]?.trim());
|
||||
return Number.isInteger(port) && port > 0 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
||||
if (!fs.existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const pid = Number(fs.readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
|
||||
const moduleName = "embedded-postgres";
|
||||
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
||||
try {
|
||||
const mod = await import(moduleName);
|
||||
EmbeddedPostgres = mod.default as EmbeddedPostgresCtor;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
if (runningPid) {
|
||||
return {
|
||||
port: readPidFilePort(postmasterPidFile) ?? preferredPort,
|
||||
startedByThisProcess: false,
|
||||
stop: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const port = await findAvailablePort(preferredPort);
|
||||
const logBuffer = createEmbeddedPostgresLogBuffer();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: logBuffer.append,
|
||||
onError: logBuffer.append,
|
||||
});
|
||||
|
||||
if (!fs.existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||
try {
|
||||
await instance.initialise();
|
||||
} catch (error) {
|
||||
throw formatEmbeddedPostgresError(error, {
|
||||
fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`,
|
||||
recentLogs: logBuffer.getRecentLogs(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(postmasterPidFile)) {
|
||||
fs.rmSync(postmasterPidFile, { force: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.start();
|
||||
} catch (error) {
|
||||
throw formatEmbeddedPostgresError(error, {
|
||||
fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`,
|
||||
recentLogs: logBuffer.getRecentLogs(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
startedByThisProcess: true,
|
||||
stop: async () => {
|
||||
await instance.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function closeDb(db: ClosableDb): Promise<void> {
|
||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function openConfiguredDb(configPath: string): Promise<{
|
||||
db: ClosableDb;
|
||||
stop: () => Promise<void>;
|
||||
}> {
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
throw new Error(`Config not found at ${configPath}.`);
|
||||
}
|
||||
|
||||
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
||||
try {
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
embeddedHandle = await ensureEmbeddedPostgres(
|
||||
config.database.embeddedPostgresDataDir,
|
||||
config.database.embeddedPostgresPort,
|
||||
);
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
const db = createDb(connectionString) as ClosableDb;
|
||||
return {
|
||||
db,
|
||||
stop: async () => {
|
||||
await closeDb(db);
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const connectionString = nonEmpty(config.database.connectionString);
|
||||
if (!connectionString) {
|
||||
throw new Error(`Config at ${configPath} does not define a database connection string.`);
|
||||
}
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
const db = createDb(connectionString) as ClosableDb;
|
||||
return {
|
||||
db,
|
||||
stop: async () => {
|
||||
await closeDb(db);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop().catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableAllRoutinesInConfig(
|
||||
options: Pick<RoutinesDisableAllOptions, "config" | "companyId">,
|
||||
): Promise<DisableAllRoutinesResult> {
|
||||
const configPath = resolveConfigPath(options.config);
|
||||
loadPaperclipEnvFile(configPath);
|
||||
const companyId =
|
||||
nonEmpty(options.companyId)
|
||||
?? nonEmpty(process.env.PAPERCLIP_COMPANY_ID)
|
||||
?? null;
|
||||
if (!companyId) {
|
||||
throw new Error("Company ID is required. Pass --company-id or set PAPERCLIP_COMPANY_ID.");
|
||||
}
|
||||
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
throw new Error(`Config not found at ${configPath}.`);
|
||||
}
|
||||
|
||||
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
||||
let db: ClosableDb | null = null;
|
||||
try {
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
embeddedHandle = await ensureEmbeddedPostgres(
|
||||
config.database.embeddedPostgresDataDir,
|
||||
config.database.embeddedPostgresPort,
|
||||
);
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
db = createDb(connectionString) as ClosableDb;
|
||||
} else {
|
||||
const connectionString = nonEmpty(config.database.connectionString);
|
||||
if (!connectionString) {
|
||||
throw new Error(`Config at ${configPath} does not define a database connection string.`);
|
||||
}
|
||||
await applyPendingMigrations(connectionString);
|
||||
db = createDb(connectionString) as ClosableDb;
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({
|
||||
id: routines.id,
|
||||
status: routines.status,
|
||||
})
|
||||
.from(routines)
|
||||
.where(eq(routines.companyId, companyId));
|
||||
|
||||
const alreadyPausedCount = existing.filter((routine) => routine.status === "paused").length;
|
||||
const archivedCount = existing.filter((routine) => routine.status === "archived").length;
|
||||
const idsToPause = existing
|
||||
.filter((routine) => routine.status !== "paused" && routine.status !== "archived")
|
||||
.map((routine) => routine.id);
|
||||
|
||||
if (idsToPause.length > 0) {
|
||||
await db
|
||||
.update(routines)
|
||||
.set({
|
||||
status: "paused",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(inArray(routines.id, idsToPause));
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
totalRoutines: existing.length,
|
||||
pausedCount: idsToPause.length,
|
||||
alreadyPausedCount,
|
||||
archivedCount,
|
||||
};
|
||||
} finally {
|
||||
if (db) {
|
||||
await closeDb(db);
|
||||
}
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableAllRoutinesCommand(options: RoutinesDisableAllOptions): Promise<void> {
|
||||
const result = await disableAllRoutinesInConfig(options);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.totalRoutines === 0) {
|
||||
console.log(pc.dim(`No routines found for company ${result.companyId}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Paused ${result.pausedCount} routine(s) for company ${result.companyId} ` +
|
||||
`(${result.alreadyPausedCount} already paused, ${result.archivedCount} archived).`,
|
||||
);
|
||||
}
|
||||
|
||||
export function registerRoutineCommands(program: Command): void {
|
||||
const routinesCommand = program.command("routines").description("Local routine maintenance commands");
|
||||
|
||||
routinesCommand
|
||||
.command("disable-all")
|
||||
.description("Pause all non-archived routines in the configured local instance for one company")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
|
||||
.option("-C, --company-id <id>", "Company ID")
|
||||
.option("--json", "Output raw JSON")
|
||||
.action(async (opts: RoutinesDisableAllOptions) => {
|
||||
try {
|
||||
await disableAllRoutinesCommand(opts);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(pc.red(message));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
@@ -21,6 +22,7 @@ interface RunOptions {
|
||||
instance?: string;
|
||||
repair?: boolean;
|
||||
yes?: boolean;
|
||||
bind?: "loopback" | "lan" | "tailnet";
|
||||
}
|
||||
|
||||
interface StartedServer {
|
||||
@@ -57,7 +59,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
}
|
||||
|
||||
p.log.step("No config found. Starting onboarding...");
|
||||
await onboard({ config: configPath, invokedByRun: true });
|
||||
await onboard({ config: configPath, invokedByRun: true, bind: opts.bind });
|
||||
}
|
||||
|
||||
p.log.step("Running doctor checks...");
|
||||
@@ -146,11 +148,35 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDevWorkspaceBuildDeps(projectRoot: string): void {
|
||||
const buildScript = path.resolve(projectRoot, "scripts/ensure-plugin-build-deps.mjs");
|
||||
if (!fs.existsSync(buildScript)) return;
|
||||
|
||||
const result = spawnSync(process.execPath, [buildScript], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Failed to prepare workspace build artifacts before starting the Paperclip dev server.\n${formatError(result.error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
throw new Error(
|
||||
"Failed to prepare workspace build artifacts before starting the Paperclip dev server.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function importServerEntry(): Promise<StartedServer> {
|
||||
// Dev mode: try local workspace path (monorepo with tsx)
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
||||
if (fs.existsSync(devEntry)) {
|
||||
ensureDevWorkspaceBuildDeps(projectRoot);
|
||||
maybeEnableUiDevMiddleware(devEntry);
|
||||
const mod = await import(pathToFileURL(devEntry).href);
|
||||
return await startServerFromModule(mod, devEntry);
|
||||
|
||||
@@ -214,6 +214,8 @@ export function buildWorktreeConfig(input: {
|
||||
server: {
|
||||
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
|
||||
exposure: source?.server.exposure ?? "private",
|
||||
...(source?.server.bind ? { bind: source.server.bind } : {}),
|
||||
...(source?.server.customBindHost ? { customBindHost: source.server.customBindHost } : {}),
|
||||
host: source?.server.host ?? "127.0.0.1",
|
||||
port: serverPort,
|
||||
allowedHostnames: source?.server.allowedHostnames ?? [],
|
||||
@@ -224,6 +226,9 @@ export function buildWorktreeConfig(input: {
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
disableSignUp: source?.auth.disableSignUp ?? false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: source?.telemetry?.enabled ?? true,
|
||||
},
|
||||
storage: {
|
||||
provider: source?.storage.provider ?? "local_disk",
|
||||
localDisk: {
|
||||
|
||||
@@ -39,8 +39,12 @@ import {
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
routines,
|
||||
routineTriggers,
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
formatEmbeddedPostgresError,
|
||||
} from "@paperclipai/db";
|
||||
import type { Command } from "commander";
|
||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
@@ -78,6 +82,7 @@ import {
|
||||
|
||||
type WorktreeInitOptions = {
|
||||
name?: string;
|
||||
color?: string;
|
||||
instance?: string;
|
||||
home?: string;
|
||||
fromConfig?: string;
|
||||
@@ -114,6 +119,17 @@ type WorktreeMergeHistoryOptions = {
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeReseedOptions = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
seedMode?: string;
|
||||
yes?: boolean;
|
||||
allowLiveTarget?: boolean;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
@@ -465,6 +481,62 @@ async function findAvailablePort(preferredPort: number, reserved = new Set<numbe
|
||||
return port;
|
||||
}
|
||||
|
||||
function resolveRepoManagedWorktreesRoot(cwd: string): string | null {
|
||||
const normalized = path.resolve(cwd);
|
||||
const marker = `${path.sep}.paperclip${path.sep}worktrees${path.sep}`;
|
||||
const index = normalized.indexOf(marker);
|
||||
if (index === -1) return null;
|
||||
const repoRoot = normalized.slice(0, index);
|
||||
return path.resolve(repoRoot, ".paperclip", "worktrees");
|
||||
}
|
||||
|
||||
function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, cwd: string): {
|
||||
serverPorts: Set<number>;
|
||||
databasePorts: Set<number>;
|
||||
} {
|
||||
const serverPorts = new Set<number>();
|
||||
const databasePorts = new Set<number>();
|
||||
const configPaths = new Set<string>();
|
||||
const instancesDir = path.resolve(homeDir, "instances");
|
||||
if (existsSync(instancesDir)) {
|
||||
for (const entry of readdirSync(instancesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === currentInstanceId) continue;
|
||||
|
||||
const configPath = path.resolve(instancesDir, entry.name, "config.json");
|
||||
if (existsSync(configPath)) {
|
||||
configPaths.add(configPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(cwd);
|
||||
if (repoManagedWorktreesRoot && existsSync(repoManagedWorktreesRoot)) {
|
||||
for (const entry of readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const configPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json");
|
||||
if (existsSync(configPath)) {
|
||||
configPaths.add(configPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const config = readConfig(configPath);
|
||||
if (config?.server.port) {
|
||||
serverPorts.add(config.server.port);
|
||||
}
|
||||
if (config?.database.mode === "embedded-postgres") {
|
||||
databasePorts.add(config.database.embeddedPostgresPort);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed sibling configs.
|
||||
}
|
||||
}
|
||||
|
||||
return { serverPorts, databasePorts };
|
||||
}
|
||||
|
||||
function detectGitBranchName(cwd: string): string | null {
|
||||
try {
|
||||
const value = execFileSync("git", ["branch", "--show-current"], {
|
||||
@@ -663,6 +735,65 @@ export function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
|
||||
}
|
||||
|
||||
export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): ResolvedWorktreeReseedSource {
|
||||
const fromSelector = nonEmpty(input.from);
|
||||
const fromConfig = nonEmpty(input.fromConfig);
|
||||
const fromDataDir = nonEmpty(input.fromDataDir);
|
||||
const fromInstance = nonEmpty(input.fromInstance);
|
||||
const hasExplicitConfigSource = Boolean(fromConfig || fromDataDir || fromInstance);
|
||||
|
||||
if (fromSelector && hasExplicitConfigSource) {
|
||||
throw new Error(
|
||||
"Use either --from <worktree> or --from-config/--from-data-dir/--from-instance, not both.",
|
||||
);
|
||||
}
|
||||
|
||||
if (fromSelector) {
|
||||
const endpoint = resolveWorktreeEndpointFromSelector(fromSelector, { allowCurrent: true });
|
||||
return {
|
||||
configPath: endpoint.configPath,
|
||||
label: endpoint.label,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasExplicitConfigSource) {
|
||||
const configPath = resolveSourceConfigPath({
|
||||
fromConfig: fromConfig ?? undefined,
|
||||
fromDataDir: fromDataDir ?? undefined,
|
||||
fromInstance: fromInstance ?? undefined,
|
||||
});
|
||||
return {
|
||||
configPath,
|
||||
label: configPath,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Pass --from <worktree> or --from-config/--from-instance explicitly so the reseed source is unambiguous.",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveWorktreeReseedTargetPaths(input: {
|
||||
configPath: string;
|
||||
rootPath: string;
|
||||
}): WorktreeLocalPaths {
|
||||
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(input.configPath));
|
||||
const homeDir = nonEmpty(envEntries.PAPERCLIP_HOME);
|
||||
const instanceId = nonEmpty(envEntries.PAPERCLIP_INSTANCE_ID);
|
||||
|
||||
if (!homeDir || !instanceId) {
|
||||
throw new Error(
|
||||
`Target config ${input.configPath} does not look like a worktree-local Paperclip instance. Expected PAPERCLIP_HOME and PAPERCLIP_INSTANCE_ID in the adjacent .env.`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolveWorktreeLocalPaths({
|
||||
cwd: input.rootPath,
|
||||
homeDir,
|
||||
instanceId,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||
if (config.database.mode === "postgres") {
|
||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||
@@ -750,24 +881,39 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
||||
}
|
||||
|
||||
const port = await findAvailablePort(preferredPort);
|
||||
const logBuffer = createEmbeddedPostgresLogBuffer();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: logBuffer.append,
|
||||
onError: logBuffer.append,
|
||||
});
|
||||
|
||||
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||
await instance.initialise();
|
||||
try {
|
||||
await instance.initialise();
|
||||
} catch (error) {
|
||||
throw formatEmbeddedPostgresError(error, {
|
||||
fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`,
|
||||
recentLogs: logBuffer.getRecentLogs(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (existsSync(postmasterPidFile)) {
|
||||
rmSync(postmasterPidFile, { force: true });
|
||||
}
|
||||
await instance.start();
|
||||
try {
|
||||
await instance.start();
|
||||
} catch (error) {
|
||||
throw formatEmbeddedPostgresError(error, {
|
||||
fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`,
|
||||
recentLogs: logBuffer.getRecentLogs(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
@@ -778,6 +924,36 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
||||
};
|
||||
}
|
||||
|
||||
export async function pauseSeededScheduledRoutines(connectionString: string): Promise<number> {
|
||||
const db = createDb(connectionString);
|
||||
try {
|
||||
const scheduledRoutineIds = await db
|
||||
.selectDistinct({ routineId: routineTriggers.routineId })
|
||||
.from(routineTriggers)
|
||||
.where(and(eq(routineTriggers.kind, "schedule"), eq(routineTriggers.enabled, true)));
|
||||
const idsToPause = scheduledRoutineIds
|
||||
.map((row) => row.routineId)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
if (idsToPause.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const paused = await db
|
||||
.update(routines)
|
||||
.set({
|
||||
status: "paused",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(inArray(routines.id, idsToPause), sql`${routines.status} <> 'paused'`, sql`${routines.status} <> 'archived'`))
|
||||
.returning({ id: routines.id });
|
||||
|
||||
return paused.length;
|
||||
} finally {
|
||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedWorktreeDatabase(input: {
|
||||
sourceConfigPath: string;
|
||||
sourceConfig: PaperclipConfig;
|
||||
@@ -804,6 +980,8 @@ async function seedWorktreeDatabase(input: {
|
||||
input.sourceConfig.database.embeddedPostgresDataDir,
|
||||
input.sourceConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
const sourceAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${sourceHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(sourceAdminConnectionString, "paperclip");
|
||||
}
|
||||
const sourceConnectionString = resolveSourceConnectionString(
|
||||
input.sourceConfig,
|
||||
@@ -813,7 +991,7 @@ async function seedWorktreeDatabase(input: {
|
||||
const backup = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
||||
retentionDays: 7,
|
||||
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||
filenamePrefix: `${input.instanceId}-seed`,
|
||||
includeMigrationJournal: true,
|
||||
excludeTables: seedPlan.excludedTables,
|
||||
@@ -833,6 +1011,7 @@ async function seedWorktreeDatabase(input: {
|
||||
backupFile: backup.backupFile,
|
||||
});
|
||||
await applyPendingMigrations(targetConnectionString);
|
||||
await pauseSeededScheduledRoutines(targetConnectionString);
|
||||
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
|
||||
targetConnectionString,
|
||||
currentCwd: input.targetPaths.cwd,
|
||||
@@ -869,8 +1048,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
instanceId,
|
||||
});
|
||||
const branding = {
|
||||
name: worktreeName,
|
||||
color: generateWorktreeColor(),
|
||||
name: opts.name ?? worktreeName,
|
||||
color: opts.color ?? generateWorktreeColor(),
|
||||
};
|
||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
||||
@@ -886,10 +1065,14 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd);
|
||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||
const serverPort = await findAvailablePort(preferredServerPort);
|
||||
const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts);
|
||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
|
||||
const databasePort = await findAvailablePort(
|
||||
preferredDbPort,
|
||||
new Set([...claimedPorts.databasePorts, serverPort]),
|
||||
);
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths,
|
||||
@@ -1078,6 +1261,11 @@ type ResolvedWorktreeEndpoint = {
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
type ResolvedWorktreeReseedSource = {
|
||||
configPath: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
||||
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
@@ -1571,6 +1759,13 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function resolveRunningEmbeddedPostgresPid(config: PaperclipConfig): number | null {
|
||||
if (config.database.mode !== "embedded-postgres") {
|
||||
return null;
|
||||
}
|
||||
return readRunningPostmasterPid(path.resolve(config.database.embeddedPostgresDataDir, "postmaster.pid"));
|
||||
}
|
||||
|
||||
async function collectMergePlan(input: {
|
||||
sourceDb: ClosableDb;
|
||||
targetDb: ClosableDb;
|
||||
@@ -2512,6 +2707,89 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
||||
|
||||
const seedMode = opts.seedMode ?? "full";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
}
|
||||
|
||||
const targetEndpoint = opts.to
|
||||
? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true })
|
||||
: resolveCurrentEndpoint();
|
||||
const source = resolveWorktreeReseedSource(opts);
|
||||
|
||||
if (path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath)) {
|
||||
throw new Error("Source and target Paperclip configs are the same. Choose different --from/--to values.");
|
||||
}
|
||||
if (!existsSync(source.configPath)) {
|
||||
throw new Error(`Source config not found at ${source.configPath}.`);
|
||||
}
|
||||
|
||||
const targetConfig = readConfig(targetEndpoint.configPath);
|
||||
if (!targetConfig) {
|
||||
throw new Error(`Target config not found at ${targetEndpoint.configPath}.`);
|
||||
}
|
||||
const sourceConfig = readConfig(source.configPath);
|
||||
if (!sourceConfig) {
|
||||
throw new Error(`Source config not found at ${source.configPath}.`);
|
||||
}
|
||||
|
||||
const targetPaths = resolveWorktreeReseedTargetPaths({
|
||||
configPath: targetEndpoint.configPath,
|
||||
rootPath: targetEndpoint.rootPath,
|
||||
});
|
||||
const runningTargetPid = resolveRunningEmbeddedPostgresPid(targetConfig);
|
||||
if (runningTargetPid && !opts.allowLiveTarget) {
|
||||
throw new Error(
|
||||
`Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${targetEndpoint.rootPath} before reseeding, or re-run with --allow-live-target if you want to override this guard.`,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmed = opts.yes
|
||||
? true
|
||||
: await p.confirm({
|
||||
message: `Overwrite the isolated Paperclip DB for ${targetEndpoint.label} from ${source.label} using ${seedMode} seed mode?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.log.warn("Reseed cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (runningTargetPid && opts.allowLiveTarget) {
|
||||
p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`);
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Reseeding ${targetEndpoint.label} from ${source.label} (${seedMode})...`);
|
||||
try {
|
||||
const seeded = await seedWorktreeDatabase({
|
||||
sourceConfigPath: source.configPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
targetPaths,
|
||||
instanceId: targetPaths.instanceId,
|
||||
seedMode,
|
||||
});
|
||||
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
|
||||
p.log.message(pc.dim(`Source: ${source.configPath}`));
|
||||
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
|
||||
p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`));
|
||||
for (const rebound of seeded.reboundWorkspaces) {
|
||||
p.log.message(
|
||||
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
||||
);
|
||||
}
|
||||
p.outro(pc.green(`Reseed complete for ${targetEndpoint.label}.`));
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to reseed worktree database."));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorktreeCommands(program: Command): void {
|
||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||
|
||||
@@ -2574,6 +2852,19 @@ export function registerWorktreeCommands(program: Command): void {
|
||||
.option("--yes", "Skip the interactive confirmation prompt when applying", false)
|
||||
.action(worktreeMergeHistoryCommand);
|
||||
|
||||
worktree
|
||||
.command("reseed")
|
||||
.description("Re-seed an existing worktree-local instance from another Paperclip instance or worktree")
|
||||
.option("--from <worktree>", "Source worktree path, directory name, branch name, or current")
|
||||
.option("--to <worktree>", "Target worktree path, directory name, branch name, or current (defaults to current)")
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config")
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: full)", "full")
|
||||
.option("--yes", "Skip the destructive confirmation prompt", false)
|
||||
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
||||
.action(worktreeReseedCommand);
|
||||
|
||||
program
|
||||
.command("worktree:cleanup")
|
||||
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
loggingConfigSchema,
|
||||
serverConfigSchema,
|
||||
authConfigSchema,
|
||||
telemetryConfigSchema,
|
||||
storageConfigSchema,
|
||||
storageLocalDiskConfigSchema,
|
||||
storageS3ConfigSchema,
|
||||
@@ -19,10 +20,11 @@ export {
|
||||
type LoggingConfig,
|
||||
type ServerConfig,
|
||||
type AuthConfig,
|
||||
type TelemetryConfig,
|
||||
type StorageConfig,
|
||||
type StorageLocalDiskConfig,
|
||||
type StorageS3Config,
|
||||
type SecretsConfig,
|
||||
type SecretsLocalEncryptedConfig,
|
||||
type ConfigMeta,
|
||||
} from "@paperclipai/shared";
|
||||
} from "../../../packages/shared/src/config-schema.js";
|
||||
|
||||
183
cli/src/config/server-bind.ts
Normal file
183
cli/src/config/server-bind.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
ALL_INTERFACES_BIND_HOST,
|
||||
LOOPBACK_BIND_HOST,
|
||||
inferBindModeFromHost,
|
||||
isAllInterfacesHost,
|
||||
isLoopbackHost,
|
||||
type BindMode,
|
||||
type DeploymentExposure,
|
||||
type DeploymentMode,
|
||||
} from "@paperclipai/shared";
|
||||
import type { AuthConfig, ServerConfig } from "./schema.js";
|
||||
|
||||
const TAILSCALE_DETECT_TIMEOUT_MS = 3000;
|
||||
|
||||
type BaseServerInput = {
|
||||
port: number;
|
||||
allowedHostnames: string[];
|
||||
serveUi: boolean;
|
||||
};
|
||||
|
||||
export function inferConfiguredBind(server?: Partial<ServerConfig>): BindMode {
|
||||
if (server?.bind) return server.bind;
|
||||
return inferBindModeFromHost(server?.customBindHost ?? server?.host);
|
||||
}
|
||||
|
||||
export function detectTailnetBindHost(): string | undefined {
|
||||
const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim();
|
||||
if (explicit) return explicit;
|
||||
|
||||
try {
|
||||
const stdout = execFileSync("tailscale", ["ip", "-4"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: TAILSCALE_DETECT_TIMEOUT_MS,
|
||||
});
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPresetServerConfig(
|
||||
bind: Exclude<BindMode, "custom">,
|
||||
input: BaseServerInput,
|
||||
): { server: ServerConfig; auth: AuthConfig } {
|
||||
const host =
|
||||
bind === "loopback"
|
||||
? LOOPBACK_BIND_HOST
|
||||
: bind === "tailnet"
|
||||
? (detectTailnetBindHost() ?? LOOPBACK_BIND_HOST)
|
||||
: ALL_INTERFACES_BIND_HOST;
|
||||
|
||||
return {
|
||||
server: {
|
||||
deploymentMode: bind === "loopback" ? "local_trusted" : "authenticated",
|
||||
exposure: "private",
|
||||
bind,
|
||||
customBindHost: undefined,
|
||||
host,
|
||||
port: input.port,
|
||||
allowedHostnames: input.allowedHostnames,
|
||||
serveUi: input.serveUi,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomServerConfig(input: BaseServerInput & {
|
||||
deploymentMode: DeploymentMode;
|
||||
exposure: DeploymentExposure;
|
||||
host: string;
|
||||
publicBaseUrl?: string;
|
||||
}): { server: ServerConfig; auth: AuthConfig } {
|
||||
const normalizedHost = input.host.trim();
|
||||
const bind = isLoopbackHost(normalizedHost)
|
||||
? "loopback"
|
||||
: isAllInterfacesHost(normalizedHost)
|
||||
? "lan"
|
||||
: "custom";
|
||||
|
||||
return {
|
||||
server: {
|
||||
deploymentMode: input.deploymentMode,
|
||||
exposure: input.deploymentMode === "local_trusted" ? "private" : input.exposure,
|
||||
bind,
|
||||
customBindHost: bind === "custom" ? normalizedHost : undefined,
|
||||
host: normalizedHost,
|
||||
port: input.port,
|
||||
allowedHostnames: input.allowedHostnames,
|
||||
serveUi: input.serveUi,
|
||||
},
|
||||
auth:
|
||||
input.deploymentMode === "authenticated" && input.exposure === "public"
|
||||
? {
|
||||
baseUrlMode: "explicit",
|
||||
disableSignUp: false,
|
||||
publicBaseUrl: input.publicBaseUrl,
|
||||
}
|
||||
: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveQuickstartServerConfig(input: {
|
||||
bind?: BindMode | null;
|
||||
deploymentMode?: DeploymentMode | null;
|
||||
exposure?: DeploymentExposure | null;
|
||||
host?: string | null;
|
||||
port: number;
|
||||
allowedHostnames: string[];
|
||||
serveUi: boolean;
|
||||
publicBaseUrl?: string;
|
||||
}): { server: ServerConfig; auth: AuthConfig } {
|
||||
const trimmedHost = input.host?.trim();
|
||||
const explicitBind = input.bind ?? null;
|
||||
|
||||
if (explicitBind === "loopback" || explicitBind === "lan" || explicitBind === "tailnet") {
|
||||
return buildPresetServerConfig(explicitBind, {
|
||||
port: input.port,
|
||||
allowedHostnames: input.allowedHostnames,
|
||||
serveUi: input.serveUi,
|
||||
});
|
||||
}
|
||||
|
||||
if (explicitBind === "custom") {
|
||||
return buildCustomServerConfig({
|
||||
deploymentMode: input.deploymentMode ?? "authenticated",
|
||||
exposure: input.exposure ?? "private",
|
||||
host: trimmedHost || LOOPBACK_BIND_HOST,
|
||||
port: input.port,
|
||||
allowedHostnames: input.allowedHostnames,
|
||||
serveUi: input.serveUi,
|
||||
publicBaseUrl: input.publicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (trimmedHost) {
|
||||
return buildCustomServerConfig({
|
||||
deploymentMode: input.deploymentMode ?? (isLoopbackHost(trimmedHost) ? "local_trusted" : "authenticated"),
|
||||
exposure: input.exposure ?? "private",
|
||||
host: trimmedHost,
|
||||
port: input.port,
|
||||
allowedHostnames: input.allowedHostnames,
|
||||
serveUi: input.serveUi,
|
||||
publicBaseUrl: input.publicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.deploymentMode === "authenticated") {
|
||||
if (input.exposure === "public") {
|
||||
return buildCustomServerConfig({
|
||||
deploymentMode: "authenticated",
|
||||
exposure: "public",
|
||||
host: ALL_INTERFACES_BIND_HOST,
|
||||
port: input.port,
|
||||
allowedHostnames: input.allowedHostnames,
|
||||
serveUi: input.serveUi,
|
||||
publicBaseUrl: input.publicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return buildPresetServerConfig("lan", {
|
||||
port: input.port,
|
||||
allowedHostnames: input.allowedHostnames,
|
||||
serveUi: input.serveUi,
|
||||
});
|
||||
}
|
||||
|
||||
return buildPresetServerConfig("loopback", {
|
||||
port: input.port,
|
||||
allowedHostnames: input.allowedHostnames,
|
||||
serveUi: input.serveUi,
|
||||
});
|
||||
}
|
||||
@@ -15,11 +15,15 @@ import { registerAgentCommands } from "./commands/client/agent.js";
|
||||
import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { registerRoutineCommands } from "./commands/routines.js";
|
||||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -28,7 +32,7 @@ const DATA_DIR_OPTION_HELP =
|
||||
program
|
||||
.name("paperclipai")
|
||||
.description("Paperclip CLI — setup, diagnose, and configure your instance")
|
||||
.version("0.2.7");
|
||||
.version(cliVersion);
|
||||
|
||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
|
||||
@@ -38,6 +42,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
initTelemetryFromConfigFile(options.config);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -45,7 +50,8 @@ program
|
||||
.description("Interactive first-run setup wizard")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.option("-y, --yes", "Accept defaults (quickstart + start immediately)", false)
|
||||
.option("--bind <mode>", "Quickstart reachability preset (loopback, lan, tailnet)")
|
||||
.option("-y, --yes", "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", false)
|
||||
.option("--run", "Start Paperclip immediately after saving config", false)
|
||||
.action(onboard);
|
||||
|
||||
@@ -103,6 +109,7 @@ program
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.option("-i, --instance <id>", "Local instance id (default: default)")
|
||||
.option("--bind <mode>", "On first run, use onboarding reachability preset (loopback, lan, tailnet)")
|
||||
.option("--repair", "Attempt automatic repairs during doctor", true)
|
||||
.option("--no-repair", "Disable automatic repairs during doctor")
|
||||
.action(runCommand);
|
||||
@@ -137,6 +144,8 @@ registerAgentCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerRoutineCommands(program);
|
||||
registerFeedbackCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
@@ -154,7 +163,20 @@ auth
|
||||
|
||||
registerClientAuthCommands(auth);
|
||||
|
||||
program.parseAsync().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
async function main(): Promise<void> {
|
||||
let failed = false;
|
||||
try {
|
||||
await program.parseAsync();
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
await flushTelemetry();
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { isLoopbackHost, type BindMode } from "@paperclipai/shared";
|
||||
import type { AuthConfig, ServerConfig } from "../config/schema.js";
|
||||
import { parseHostnameCsv } from "../config/hostnames.js";
|
||||
import { buildCustomServerConfig, buildPresetServerConfig, inferConfiguredBind } from "../config/server-bind.js";
|
||||
|
||||
const TAILNET_BIND_WARNING =
|
||||
"No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set.";
|
||||
|
||||
function cancelled(): never {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export async function promptServer(opts?: {
|
||||
currentServer?: Partial<ServerConfig>;
|
||||
@@ -8,69 +18,37 @@ export async function promptServer(opts?: {
|
||||
}): Promise<{ server: ServerConfig; auth: AuthConfig }> {
|
||||
const currentServer = opts?.currentServer;
|
||||
const currentAuth = opts?.currentAuth;
|
||||
const currentBind = inferConfiguredBind(currentServer);
|
||||
|
||||
const deploymentModeSelection = await p.select({
|
||||
message: "Deployment mode",
|
||||
const bindSelection = await p.select({
|
||||
message: "Reachability",
|
||||
options: [
|
||||
{
|
||||
value: "local_trusted",
|
||||
label: "Local trusted",
|
||||
hint: "Easiest for local setup (no login, localhost-only)",
|
||||
value: "loopback" as const,
|
||||
label: "Trusted local",
|
||||
hint: "Recommended for first run: localhost only, no login friction",
|
||||
},
|
||||
{
|
||||
value: "authenticated",
|
||||
label: "Authenticated",
|
||||
hint: "Login required; use for private network or public hosting",
|
||||
value: "lan" as const,
|
||||
label: "Private network",
|
||||
hint: "Broad private bind for LAN, VPN, or legacy --tailscale-auth style access",
|
||||
},
|
||||
{
|
||||
value: "tailnet" as const,
|
||||
label: "Tailnet",
|
||||
hint: "Private authenticated access using the machine's detected Tailscale address",
|
||||
},
|
||||
{
|
||||
value: "custom" as const,
|
||||
label: "Custom",
|
||||
hint: "Choose exact auth mode, exposure, and host manually",
|
||||
},
|
||||
],
|
||||
initialValue: currentServer?.deploymentMode ?? "local_trusted",
|
||||
initialValue: currentBind,
|
||||
});
|
||||
|
||||
if (p.isCancel(deploymentModeSelection)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
|
||||
|
||||
let exposure: ServerConfig["exposure"] = "private";
|
||||
if (deploymentMode === "authenticated") {
|
||||
const exposureSelection = await p.select({
|
||||
message: "Exposure profile",
|
||||
options: [
|
||||
{
|
||||
value: "private",
|
||||
label: "Private network",
|
||||
hint: "Private access (for example Tailscale), lower setup friction",
|
||||
},
|
||||
{
|
||||
value: "public",
|
||||
label: "Public internet",
|
||||
hint: "Internet-facing deployment with stricter requirements",
|
||||
},
|
||||
],
|
||||
initialValue: currentServer?.exposure ?? "private",
|
||||
});
|
||||
if (p.isCancel(exposureSelection)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
exposure = exposureSelection as ServerConfig["exposure"];
|
||||
}
|
||||
|
||||
const hostDefault = deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0";
|
||||
const hostStr = await p.text({
|
||||
message: "Bind host",
|
||||
defaultValue: currentServer?.host ?? hostDefault,
|
||||
placeholder: hostDefault,
|
||||
validate: (val) => {
|
||||
if (!val.trim()) return "Host is required";
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(hostStr)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (p.isCancel(bindSelection)) cancelled();
|
||||
const bind = bindSelection as BindMode;
|
||||
|
||||
const portStr = await p.text({
|
||||
message: "Server port",
|
||||
@@ -84,15 +62,113 @@ export async function promptServer(opts?: {
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(portStr)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
if (p.isCancel(portStr)) cancelled();
|
||||
const port = Number(portStr) || 3100;
|
||||
const serveUi = currentServer?.serveUi ?? true;
|
||||
|
||||
if (bind === "loopback") {
|
||||
return buildPresetServerConfig("loopback", {
|
||||
port,
|
||||
allowedHostnames: [],
|
||||
serveUi,
|
||||
});
|
||||
}
|
||||
|
||||
if (bind === "lan" || bind === "tailnet") {
|
||||
const allowedHostnamesInput = await p.text({
|
||||
message: "Allowed private hostnames (comma-separated, optional)",
|
||||
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
||||
placeholder:
|
||||
bind === "tailnet"
|
||||
? "your-machine.tailnet.ts.net"
|
||||
: "dotta-macbook-pro, host.docker.internal",
|
||||
validate: (val) => {
|
||||
try {
|
||||
parseHostnameCsv(val);
|
||||
return;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : "Invalid hostname list";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
||||
|
||||
const preset = buildPresetServerConfig(bind, {
|
||||
port,
|
||||
allowedHostnames: parseHostnameCsv(allowedHostnamesInput),
|
||||
serveUi,
|
||||
});
|
||||
if (bind === "tailnet" && isLoopbackHost(preset.server.host)) {
|
||||
p.log.warn(TAILNET_BIND_WARNING);
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
const deploymentModeSelection = await p.select({
|
||||
message: "Auth mode",
|
||||
options: [
|
||||
{
|
||||
value: "local_trusted",
|
||||
label: "Local trusted",
|
||||
hint: "No login required; only safe with loopback-only or similarly trusted access",
|
||||
},
|
||||
{
|
||||
value: "authenticated",
|
||||
label: "Authenticated",
|
||||
hint: "Login required; supports both private-network and public deployments",
|
||||
},
|
||||
],
|
||||
initialValue: currentServer?.deploymentMode ?? "authenticated",
|
||||
});
|
||||
|
||||
if (p.isCancel(deploymentModeSelection)) cancelled();
|
||||
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
|
||||
|
||||
let exposure: ServerConfig["exposure"] = "private";
|
||||
if (deploymentMode === "authenticated") {
|
||||
const exposureSelection = await p.select({
|
||||
message: "Exposure profile",
|
||||
options: [
|
||||
{
|
||||
value: "private",
|
||||
label: "Private network",
|
||||
hint: "Private access only, with automatic URL handling",
|
||||
},
|
||||
{
|
||||
value: "public",
|
||||
label: "Public internet",
|
||||
hint: "Internet-facing deployment with explicit public URL requirements",
|
||||
},
|
||||
],
|
||||
initialValue: currentServer?.exposure ?? "private",
|
||||
});
|
||||
if (p.isCancel(exposureSelection)) cancelled();
|
||||
exposure = exposureSelection as ServerConfig["exposure"];
|
||||
}
|
||||
|
||||
const defaultHost =
|
||||
currentServer?.customBindHost ??
|
||||
currentServer?.host ??
|
||||
(deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0");
|
||||
const host = await p.text({
|
||||
message: "Bind host",
|
||||
defaultValue: defaultHost,
|
||||
placeholder: defaultHost,
|
||||
validate: (val) => {
|
||||
if (!val.trim()) return "Host is required";
|
||||
if (deploymentMode === "local_trusted" && !isLoopbackHost(val.trim())) {
|
||||
return "Local trusted mode requires a loopback host such as 127.0.0.1";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(host)) cancelled();
|
||||
|
||||
let allowedHostnames: string[] = [];
|
||||
if (deploymentMode === "authenticated" && exposure === "private") {
|
||||
const allowedHostnamesInput = await p.text({
|
||||
message: "Allowed hostnames (comma-separated, optional)",
|
||||
message: "Allowed private hostnames (comma-separated, optional)",
|
||||
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
||||
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
|
||||
validate: (val) => {
|
||||
@@ -105,15 +181,11 @@ export async function promptServer(opts?: {
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(allowedHostnamesInput)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
||||
allowedHostnames = parseHostnameCsv(allowedHostnamesInput);
|
||||
}
|
||||
|
||||
const port = Number(portStr) || 3100;
|
||||
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
|
||||
let publicBaseUrl: string | undefined;
|
||||
if (deploymentMode === "authenticated" && exposure === "public") {
|
||||
const urlInput = await p.text({
|
||||
message: "Public base URL",
|
||||
@@ -133,32 +205,17 @@ export async function promptServer(opts?: {
|
||||
}
|
||||
},
|
||||
});
|
||||
if (p.isCancel(urlInput)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
auth = {
|
||||
baseUrlMode: "explicit",
|
||||
disableSignUp: false,
|
||||
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
|
||||
};
|
||||
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
|
||||
auth = {
|
||||
baseUrlMode: "explicit",
|
||||
disableSignUp: false,
|
||||
publicBaseUrl: currentAuth.publicBaseUrl,
|
||||
};
|
||||
if (p.isCancel(urlInput)) cancelled();
|
||||
publicBaseUrl = urlInput.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
return {
|
||||
server: {
|
||||
deploymentMode,
|
||||
exposure,
|
||||
host: hostStr.trim(),
|
||||
port,
|
||||
allowedHostnames,
|
||||
serveUi: currentServer?.serveUi ?? true,
|
||||
},
|
||||
auth,
|
||||
};
|
||||
return buildCustomServerConfig({
|
||||
deploymentMode,
|
||||
exposure,
|
||||
host: host.trim(),
|
||||
port,
|
||||
allowedHostnames,
|
||||
serveUi,
|
||||
publicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
49
cli/src/telemetry.ts
Normal file
49
cli/src/telemetry.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
TelemetryClient,
|
||||
resolveTelemetryConfig,
|
||||
loadOrCreateState,
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
trackCompanyImported,
|
||||
} from "../../packages/shared/src/telemetry/index.js";
|
||||
import { resolvePaperclipInstanceRoot } from "./config/home.js";
|
||||
import { readConfig } from "./config/store.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
|
||||
let client: TelemetryClient | null = null;
|
||||
|
||||
export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClient | null {
|
||||
if (client) return client;
|
||||
|
||||
const config = resolveTelemetryConfig(fileConfig);
|
||||
if (!config.enabled) return null;
|
||||
|
||||
const stateDir = path.join(resolvePaperclipInstanceRoot(), "telemetry");
|
||||
client = new TelemetryClient(config, () => loadOrCreateState(stateDir, cliVersion), cliVersion);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function initTelemetryFromConfigFile(configPath?: string): TelemetryClient | null {
|
||||
try {
|
||||
return initTelemetry(readConfig(configPath)?.telemetry);
|
||||
} catch {
|
||||
return initTelemetry();
|
||||
}
|
||||
}
|
||||
|
||||
export function getTelemetryClient(): TelemetryClient | null {
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function flushTelemetry(): Promise<void> {
|
||||
if (client) {
|
||||
await client.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
trackCompanyImported,
|
||||
};
|
||||
10
cli/src/version.ts
Normal file
10
cli/src/version.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
type PackageJson = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require("../package.json") as PackageJson;
|
||||
|
||||
export const cliVersion = pkg.version ?? "0.0.0";
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "../packages/shared/src"]
|
||||
}
|
||||
|
||||
@@ -32,10 +32,12 @@ Mode taxonomy and design intent are documented in `doc/DEPLOYMENT-MODES.md`.
|
||||
Current CLI behavior:
|
||||
|
||||
- `paperclipai onboard` and `paperclipai configure --section server` set deployment mode in config
|
||||
- server onboarding/configure ask for reachability intent and write `server.bind`
|
||||
- `paperclipai run --bind <loopback|lan|tailnet>` passes a quickstart bind preset into first-run onboarding when config is missing
|
||||
- runtime can override mode with `PAPERCLIP_DEPLOYMENT_MODE`
|
||||
- `paperclipai run` and `paperclipai doctor` do not yet expose a direct `--mode` flag
|
||||
- `paperclipai run` and `paperclipai doctor` still do not expose a direct low-level `--mode` flag
|
||||
|
||||
Target behavior (planned) is documented in `doc/DEPLOYMENT-MODES.md` section 5.
|
||||
Canonical behavior is documented in `doc/DEPLOYMENT-MODES.md`.
|
||||
|
||||
Allow an authenticated/private hostname (for example custom Tailscale DNS):
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ Paperclip supports two runtime modes:
|
||||
|
||||
This keeps one authenticated auth stack while still separating low-friction private-network defaults from internet-facing hardening requirements.
|
||||
|
||||
Paperclip now treats **bind** as a separate concern from auth:
|
||||
|
||||
- auth model: `local_trusted` vs `authenticated`, plus `private/public`
|
||||
- reachability model: `server.bind = loopback | lan | tailnet | custom`
|
||||
|
||||
## 2. Canonical Model
|
||||
|
||||
| Runtime Mode | Exposure | Human auth | Primary use |
|
||||
@@ -25,6 +30,15 @@ This keeps one authenticated auth stack while still separating low-friction priv
|
||||
| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) |
|
||||
| `authenticated` | `public` | Login required | Internet-facing/cloud deployment |
|
||||
|
||||
## Reachability Model
|
||||
|
||||
| Bind | Meaning | Typical use |
|
||||
|---|---|---|
|
||||
| `loopback` | Listen on localhost only | default local usage, reverse-proxy deployments |
|
||||
| `lan` | Listen on all interfaces (`0.0.0.0`) | LAN/VPN/private-network access |
|
||||
| `tailnet` | Listen on a detected Tailscale IP | Tailscale-only access |
|
||||
| `custom` | Listen on an explicit host/IP | advanced interface-specific setups |
|
||||
|
||||
## 3. Security Policy
|
||||
|
||||
## `local_trusted`
|
||||
@@ -38,12 +52,14 @@ This keeps one authenticated auth stack while still separating low-friction priv
|
||||
- login required
|
||||
- low-friction URL handling (`auto` base URL mode)
|
||||
- private-host trust policy required
|
||||
- bind can be `loopback`, `lan`, `tailnet`, or `custom`
|
||||
|
||||
## `authenticated + public`
|
||||
|
||||
- login required
|
||||
- explicit public URL required
|
||||
- stricter deployment checks and failures in doctor
|
||||
- recommended bind is `loopback` behind a reverse proxy; direct `lan/custom` is advanced
|
||||
|
||||
## 4. Onboarding UX Contract
|
||||
|
||||
@@ -55,14 +71,22 @@ pnpm paperclipai onboard
|
||||
|
||||
Server prompt behavior:
|
||||
|
||||
1. ask mode, default `local_trusted`
|
||||
2. option copy:
|
||||
- `local_trusted`: "Easiest for local setup (no login, localhost-only)"
|
||||
- `authenticated`: "Login required; use for private network or public hosting"
|
||||
3. if `authenticated`, ask exposure:
|
||||
- `private`: "Private network access (for example Tailscale), lower setup friction"
|
||||
- `public`: "Internet-facing deployment, stricter security requirements"
|
||||
4. ask explicit public URL only for `authenticated + public`
|
||||
1. quickstart `--yes` defaults to `server.bind=loopback` and therefore `local_trusted/private`
|
||||
2. advanced server setup asks reachability first:
|
||||
- `Trusted local` → `bind=loopback`, `local_trusted/private`
|
||||
- `Private network` → `bind=lan`, `authenticated/private`
|
||||
- `Tailnet` → `bind=tailnet`, `authenticated/private`
|
||||
- `Custom` → manual mode/exposure/host entry
|
||||
3. raw host entry is only required for the `Custom` path
|
||||
4. explicit public URL is only required for `authenticated + public`
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai onboard --yes
|
||||
pnpm paperclipai onboard --yes --bind lan
|
||||
pnpm paperclipai run --bind tailnet
|
||||
```
|
||||
|
||||
`configure --section server` follows the same interactive behavior.
|
||||
|
||||
|
||||
@@ -39,15 +39,39 @@ This starts:
|
||||
|
||||
`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching.
|
||||
|
||||
`pnpm dev:once` auto-applies pending local migrations by default before starting the dev server.
|
||||
|
||||
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
|
||||
|
||||
Inspect or stop the current repo's managed dev runner:
|
||||
|
||||
```sh
|
||||
pnpm dev:list
|
||||
pnpm dev:stop
|
||||
```
|
||||
|
||||
`pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server.
|
||||
|
||||
Tailscale/private-auth dev mode:
|
||||
|
||||
```sh
|
||||
pnpm dev --tailscale-auth
|
||||
pnpm dev --bind lan
|
||||
```
|
||||
|
||||
This runs dev as `authenticated/private` and binds the server to `0.0.0.0` for private-network access.
|
||||
This runs dev as `authenticated/private` with a private-network bind preset.
|
||||
|
||||
For Tailscale-only reachability on a detected tailnet address:
|
||||
|
||||
```sh
|
||||
pnpm dev --bind tailnet
|
||||
```
|
||||
|
||||
Legacy aliases still map to the old broad private-network behavior:
|
||||
|
||||
```sh
|
||||
pnpm dev --tailscale-auth
|
||||
pnpm dev --authenticated-private
|
||||
```
|
||||
|
||||
Allow additional private hostnames (for example custom Tailscale hostnames):
|
||||
|
||||
@@ -86,7 +110,7 @@ docker run --name paperclip \
|
||||
Or use Compose:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.quickstart.yml up --build
|
||||
docker compose -f docker/docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details.
|
||||
@@ -134,6 +158,8 @@ For `codex_local`, Paperclip also manages a per-company Codex home under the ins
|
||||
|
||||
- `~/.paperclip/instances/default/companies/<company-id>/codex-home`
|
||||
|
||||
If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary).
|
||||
|
||||
## Worktree-local Instances
|
||||
|
||||
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
|
||||
@@ -162,6 +188,10 @@ Seed modes:
|
||||
|
||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||
|
||||
`pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first.
|
||||
|
||||
Provisioned git worktrees also pause seeded routines that still have enabled schedule triggers in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development without disabling webhook/API-only routines.
|
||||
|
||||
That repo-local env also sets:
|
||||
|
||||
- `PAPERCLIP_IN_WORKTREE=true`
|
||||
@@ -206,6 +236,77 @@ paperclipai worktree init --from-data-dir ~/.paperclip
|
||||
paperclipai worktree init --force
|
||||
```
|
||||
|
||||
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
|
||||
|
||||
```sh
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
|
||||
pnpm paperclipai worktree init --force --seed-mode minimal \
|
||||
--name PAP-884-ai-commits-component \
|
||||
--from-config ~/.paperclip/instances/default/config.json
|
||||
```
|
||||
|
||||
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
|
||||
|
||||
For an already-created worktree where you want the CLI to decide whether to rebuild missing worktree metadata or just reseed the isolated DB, use `worktree repair`.
|
||||
|
||||
**`pnpm paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--branch <name>` | Existing branch/worktree selector to repair, or a branch name to create under `.paperclip/worktrees` |
|
||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id when deriving the source config (default: `default`) |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
||||
| `--no-seed` | Repair metadata only when bootstrapping a missing worktree config |
|
||||
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
# From inside a linked worktree, rebuild missing .paperclip metadata and reseed it from the default instance.
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
pnpm paperclipai worktree repair
|
||||
|
||||
# From the primary checkout, create or repair a linked worktree for a branch under .paperclip/worktrees/.
|
||||
cd /path/to/paperclip
|
||||
pnpm paperclipai worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
```
|
||||
|
||||
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
|
||||
|
||||
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--from <worktree>` | Source worktree path, directory name, branch name, or `current` |
|
||||
| `--to <worktree>` | Target worktree path, directory name, branch name, or `current` (defaults to `current`) |
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id when deriving the source config |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `full`) |
|
||||
| `--yes` | Skip the destructive confirmation prompt |
|
||||
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
# From the main repo, reseed a worktree from the current default/master instance.
|
||||
cd /path/to/paperclip
|
||||
pnpm paperclipai worktree reseed \
|
||||
--from current \
|
||||
--to PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat \
|
||||
--seed-mode full \
|
||||
--yes
|
||||
|
||||
# From inside a worktree, reseed it from the default instance config.
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
pnpm paperclipai worktree reseed \
|
||||
--from-instance default \
|
||||
--seed-mode full
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||
|
||||
| Option | Description |
|
||||
|
||||
132
doc/DOCKER.md
132
doc/DOCKER.md
@@ -2,6 +2,28 @@
|
||||
|
||||
Run Paperclip in Docker without installing Node or pnpm locally.
|
||||
|
||||
All commands below assume you are in the **project root** (the directory containing `package.json`), not inside `docker/`.
|
||||
|
||||
## Building the image
|
||||
|
||||
```sh
|
||||
docker build -t paperclip-local .
|
||||
```
|
||||
|
||||
The Dockerfile installs common agent tools (`git`, `gh`, `curl`, `wget`, `ripgrep`, `python3`) and the Claude, Codex, and OpenCode CLIs.
|
||||
|
||||
Build arguments:
|
||||
|
||||
| Arg | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) |
|
||||
| `USER_GID` | `1000` | GID for the container `node` group |
|
||||
|
||||
```sh
|
||||
docker build -t paperclip-local \
|
||||
--build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .
|
||||
```
|
||||
|
||||
## One-liner (build + run)
|
||||
|
||||
```sh
|
||||
@@ -10,6 +32,7 @@ docker run --name paperclip \
|
||||
-p 3100:3100 \
|
||||
-e HOST=0.0.0.0 \
|
||||
-e PAPERCLIP_HOME=/paperclip \
|
||||
-e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
|
||||
-v "$(pwd)/data/docker-paperclip:/paperclip" \
|
||||
paperclip-local
|
||||
```
|
||||
@@ -25,10 +48,15 @@ Data persistence:
|
||||
|
||||
All persisted under your bind mount (`./data/docker-paperclip` in the example above).
|
||||
|
||||
## Compose Quickstart
|
||||
## Docker Compose
|
||||
|
||||
### Quickstart (embedded SQLite)
|
||||
|
||||
Single container, no external database. Data persists via a bind mount.
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.quickstart.yml up --build
|
||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
|
||||
docker compose -f docker/docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
Defaults:
|
||||
@@ -39,11 +67,36 @@ Defaults:
|
||||
Optional overrides:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build
|
||||
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=../data/pc \
|
||||
docker compose -f docker/docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
**Note:** `PAPERCLIP_DATA_DIR` is resolved relative to the compose file (`docker/`), so `../data/pc` maps to `data/pc` in the project root.
|
||||
|
||||
If you change host port or use a non-local domain, set `PAPERCLIP_PUBLIC_URL` to the external URL you will use in browser/auth flows.
|
||||
|
||||
Pass `OPENAI_API_KEY` and/or `ANTHROPIC_API_KEY` to enable local adapter runs.
|
||||
|
||||
### Full stack (with PostgreSQL)
|
||||
|
||||
Paperclip server + PostgreSQL 17. The database is health-checked before the server starts.
|
||||
|
||||
```sh
|
||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
|
||||
docker compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
PostgreSQL data persists in a named Docker volume (`pgdata`). Paperclip data persists in `paperclip-data`.
|
||||
|
||||
### Untrusted PR review
|
||||
|
||||
Isolated container for reviewing untrusted pull requests with Codex or Claude, without exposing your host machine. See `doc/UNTRUSTED-PR-REVIEW.md` for the full workflow.
|
||||
|
||||
```sh
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml build
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml run --rm --service-ports review
|
||||
```
|
||||
|
||||
## Authenticated Compose (Single Public URL)
|
||||
|
||||
For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults:
|
||||
@@ -93,11 +146,71 @@ Notes:
|
||||
- Without API keys, the app still runs normally.
|
||||
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
|
||||
|
||||
## Untrusted PR Review Container
|
||||
## Podman Quadlet (systemd)
|
||||
|
||||
If you want a separate Docker environment for reviewing untrusted pull requests with `codex` or `claude`, use the dedicated review workflow in `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||
The `docker/quadlet/` directory contains unit files to run Paperclip + PostgreSQL as systemd services via Podman Quadlet.
|
||||
|
||||
That setup keeps CLI auth state in Docker volumes instead of your host home directory and uses a separate scratch workspace for PR checkouts and preview runs.
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `docker/quadlet/paperclip.pod` | Pod definition — groups containers into a shared network namespace |
|
||||
| `docker/quadlet/paperclip.container` | Paperclip server — joins the pod, connects to Postgres at `127.0.0.1` |
|
||||
| `docker/quadlet/paperclip-db.container` | PostgreSQL 17 — joins the pod, health-checked |
|
||||
|
||||
### Setup
|
||||
|
||||
1. Build the image (see above).
|
||||
|
||||
2. Copy quadlet files to your systemd directory:
|
||||
|
||||
```sh
|
||||
# Rootless (recommended)
|
||||
cp docker/quadlet/*.pod docker/quadlet/*.container \
|
||||
~/.config/containers/systemd/
|
||||
|
||||
# Or rootful
|
||||
sudo cp docker/quadlet/*.pod docker/quadlet/*.container \
|
||||
/etc/containers/systemd/
|
||||
```
|
||||
|
||||
3. Create a secrets env file (keep out of version control):
|
||||
|
||||
```sh
|
||||
cat > ~/.config/containers/systemd/paperclip.env <<EOL
|
||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
||||
POSTGRES_USER=paperclip
|
||||
POSTGRES_PASSWORD=paperclip
|
||||
POSTGRES_DB=paperclip
|
||||
DATABASE_URL=postgres://paperclip:paperclip@127.0.0.1:5432/paperclip
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# ANTHROPIC_API_KEY=sk-...
|
||||
EOL
|
||||
```
|
||||
|
||||
4. Create the data directory and start:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.local/share/paperclip
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start paperclip-pod
|
||||
```
|
||||
|
||||
### Quadlet management
|
||||
|
||||
```sh
|
||||
journalctl --user -u paperclip -f # App logs
|
||||
journalctl --user -u paperclip-db -f # DB logs
|
||||
systemctl --user status paperclip-pod # Pod status
|
||||
systemctl --user restart paperclip-pod # Restart all
|
||||
systemctl --user stop paperclip-pod # Stop all
|
||||
```
|
||||
|
||||
### Quadlet notes
|
||||
|
||||
- **First boot**: Unlike Docker Compose's `condition: service_healthy`, Quadlet's `After=` only waits for the DB unit to *start*, not for PostgreSQL to be ready. On a cold first boot you may see one or two restart attempts in `journalctl --user -u paperclip` while PostgreSQL initialises — this is expected and resolves automatically via `Restart=on-failure`.
|
||||
- Containers in a pod share `localhost`, so Paperclip reaches Postgres at `127.0.0.1:5432`.
|
||||
- PostgreSQL data persists in the `paperclip-pgdata` named volume.
|
||||
- Paperclip data persists at `~/.local/share/paperclip`.
|
||||
- For rootful quadlet deployment, remove `%h` prefixes and use absolute paths.
|
||||
|
||||
## Onboard Smoke Test (Ubuntu + npm only)
|
||||
|
||||
@@ -133,4 +246,9 @@ Notes:
|
||||
- In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access.
|
||||
- Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation.
|
||||
- Set `SMOKE_DETACH=true` to leave the container running for automation and optionally write shell-ready metadata to `SMOKE_METADATA_FILE`.
|
||||
- The image definition is in `Dockerfile.onboard-smoke`.
|
||||
- The image definition is in `docker/Dockerfile.onboard-smoke`.
|
||||
|
||||
## General Notes
|
||||
|
||||
- The `docker-entrypoint.sh` adjusts the container `node` user UID/GID at startup to match the values passed via `USER_UID`/`USER_GID`, avoiding permission issues on bind-mounted volumes.
|
||||
- Paperclip data persists via Docker volumes/bind mounts (compose) or at `~/.local/share/paperclip` (quadlet).
|
||||
|
||||
@@ -3,7 +3,7 @@ Use this exact checklist.
|
||||
1. Start Paperclip in auth mode.
|
||||
```bash
|
||||
cd <paperclip-repo-root>
|
||||
pnpm dev --tailscale-auth
|
||||
pnpm dev --bind lan
|
||||
```
|
||||
Then verify:
|
||||
```bash
|
||||
|
||||
@@ -51,10 +51,9 @@ Public packages are discovered from:
|
||||
|
||||
- `packages/`
|
||||
- `server/`
|
||||
- `ui/`
|
||||
- `cli/`
|
||||
|
||||
`ui/` is ignored because it is private.
|
||||
|
||||
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
|
||||
|
||||
- finds all public packages
|
||||
@@ -65,6 +64,89 @@ The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts
|
||||
|
||||
Those rewrites are temporary. The working tree is restored after publish or dry-run.
|
||||
|
||||
## `@paperclipai/ui` packaging
|
||||
|
||||
The UI package publishes prebuilt static assets, not the source workspace.
|
||||
|
||||
The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that:
|
||||
|
||||
- keeps the release-managed `name` and `version`
|
||||
- publishes only `dist/`
|
||||
- omits the source-only dependency graph from downstream installs
|
||||
|
||||
After packing or publishing, `postpack` restores the development manifest automatically.
|
||||
|
||||
### Manual first publish for `@paperclipai/ui`
|
||||
|
||||
If you need to publish only the UI package once by hand, use the real package name:
|
||||
|
||||
- `@paperclipai/ui`
|
||||
|
||||
Recommended flow from the repo root:
|
||||
|
||||
```bash
|
||||
# optional sanity check: this 404s until the first publish exists
|
||||
npm view @paperclipai/ui version
|
||||
|
||||
# make sure the dist payload is fresh
|
||||
pnpm --filter @paperclipai/ui build
|
||||
|
||||
# confirm your local npm auth before the real publish
|
||||
npm whoami
|
||||
|
||||
# safe preview of the exact publish payload
|
||||
cd ui
|
||||
pnpm publish --dry-run --no-git-checks --access public
|
||||
|
||||
# real publish
|
||||
pnpm publish --no-git-checks --access public
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Publish from `ui/`, not the repo root.
|
||||
- `prepack` automatically rewrites `ui/package.json` to the lean publish manifest, and `postpack` restores the dev manifest after the command finishes.
|
||||
- If `npm view @paperclipai/ui version` already returns the same version that is in [`ui/package.json`](../ui/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
|
||||
|
||||
If the first real publish returns npm `E404`, check npm-side prerequisites before retrying:
|
||||
|
||||
- `npm whoami` must succeed first. An expired or missing npm login will block the publish.
|
||||
- For an organization-scoped package like `@paperclipai/ui`, the `paperclipai` npm organization must exist and the publisher must be a member with permission to publish to that scope.
|
||||
- The initial publish must include `--access public` for a public scoped package.
|
||||
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
|
||||
|
||||
### Manual first publish for `@paperclipai/mcp-server`
|
||||
|
||||
If you need to publish only the MCP server package once by hand, use:
|
||||
|
||||
- `@paperclipai/mcp-server`
|
||||
|
||||
Recommended flow from the repo root:
|
||||
|
||||
```bash
|
||||
# optional sanity check: this 404s until the first publish exists
|
||||
npm view @paperclipai/mcp-server version
|
||||
|
||||
# make sure the build output is fresh
|
||||
pnpm --filter @paperclipai/mcp-server build
|
||||
|
||||
# confirm your local npm auth before the real publish
|
||||
npm whoami
|
||||
|
||||
# safe preview of the exact publish payload
|
||||
cd packages/mcp-server
|
||||
pnpm publish --dry-run --no-git-checks --access public
|
||||
|
||||
# real publish
|
||||
pnpm publish --no-git-checks --access public
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Publish from `packages/mcp-server/`, not the repo root.
|
||||
- If `npm view @paperclipai/mcp-server version` already returns the same version that is in [`packages/mcp-server/package.json`](../packages/mcp-server/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
|
||||
- The same npm-side prerequisites apply as above: valid npm auth, permission to publish to the `@paperclipai` scope, `--access public`, and the required publish auth/2FA policy.
|
||||
|
||||
## Version formats
|
||||
|
||||
Paperclip uses calendar versions:
|
||||
@@ -135,6 +217,7 @@ This is the fastest way to restore the default install path if a stable release
|
||||
|
||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
||||
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
||||
- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs)
|
||||
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
||||
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
||||
- [`doc/RELEASING.md`](RELEASING.md)
|
||||
|
||||
@@ -35,6 +35,7 @@ At minimum that includes:
|
||||
|
||||
- `paperclipai`
|
||||
- `@paperclipai/server`
|
||||
- `@paperclipai/ui`
|
||||
- public packages under `packages/`
|
||||
|
||||
### 2.1. In npm, open each package settings page
|
||||
|
||||
@@ -184,6 +184,11 @@ Invariant: at least one root `company` level goal per company.
|
||||
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
|
||||
- `lead_agent_id` uuid fk `agents.id` null
|
||||
- `target_date` date null
|
||||
- `env` jsonb null (same secret-aware env binding format used by agent config)
|
||||
|
||||
Invariant:
|
||||
|
||||
- project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected
|
||||
|
||||
## 7.6 `issues` (core task entity)
|
||||
|
||||
@@ -491,7 +496,7 @@ All endpoints are under `/api` and return JSON.
|
||||
```json
|
||||
{
|
||||
"agentId": "uuid",
|
||||
"expectedStatuses": ["todo", "backlog", "blocked"]
|
||||
"expectedStatuses": ["todo", "backlog", "blocked", "in_review"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
33
doc/SPEC.md
33
doc/SPEC.md
@@ -186,17 +186,21 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
|
||||
|
||||
### Execution Adapters
|
||||
|
||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
|
||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include:
|
||||
|
||||
| Adapter | Mechanism | Example |
|
||||
| -------------------- | ----------------------- | --------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||
| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval |
|
||||
| `hermes_local` | Hermes agent process | Local Hermes agent |
|
||||
| Adapter | Mechanism | Example |
|
||||
| ---------------- | -------------------------- | -------------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| `claude_local` | Local Claude Code process | Claude Code heartbeat worker |
|
||||
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
|
||||
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
|
||||
| `pi_local` | Local Pi process | Pi CLI heartbeat worker |
|
||||
| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker |
|
||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||
| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker |
|
||||
|
||||
The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||
The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||
|
||||
### Adapter Interface
|
||||
|
||||
@@ -376,7 +380,7 @@ Flow:
|
||||
| Layer | Technology |
|
||||
| -------- | ------------------------------------------------------------ |
|
||||
| Frontend | React + Vite |
|
||||
| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) |
|
||||
| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) |
|
||||
| Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) |
|
||||
| Auth | [Better Auth](https://www.better-auth.com/) |
|
||||
|
||||
@@ -406,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
|
||||
|
||||
### Work Artifacts
|
||||
|
||||
Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope.
|
||||
Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline.
|
||||
|
||||
### Open Questions
|
||||
|
||||
@@ -476,15 +480,14 @@ Each is a distinct page/route:
|
||||
- [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill
|
||||
- [ ] **Default CEO** — strategic planning, delegation, board communication
|
||||
- [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API
|
||||
- [ ] **REST API** — full API for agent interaction (Hono)
|
||||
- [ ] **REST API** — full API for agent interaction (Express)
|
||||
- [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views
|
||||
- [ ] **Agent auth** — connection string generation with URL + key + instructions
|
||||
- [ ] **One-command dev setup** — embedded PGlite, everything local
|
||||
- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter)
|
||||
- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters)
|
||||
|
||||
### Not V1
|
||||
|
||||
- Template export/import
|
||||
- Knowledge base - a future plugin
|
||||
- Advanced governance models (hiring budgets, multi-member boards)
|
||||
- Revenue/expense tracking beyond token costs - a future plugin
|
||||
@@ -509,7 +512,7 @@ Things Paperclip explicitly does **not** do:
|
||||
- **Not a SaaS** — single-tenant, self-hosted
|
||||
- **Not opinionated about Agent implementation** — any language, any framework, any runtime
|
||||
- **Not automatically self-healing** — surfaces problems, doesn't silently fix them
|
||||
- **Does not manage work artifacts** — no repo management, no deployment, no file systems
|
||||
- **Does not manage delivery infrastructure** — no repo management, no deployment, no file systems (but does manage task-linked documents and attachments)
|
||||
- **Does not auto-reassign work** — stale tasks are surfaced, not silently redistributed
|
||||
- **Does not track external revenue/expenses** — that's a future plugin. Token/LLM cost budgeting is core.
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@ By default this workflow does **not** mount your host repo checkout, your host h
|
||||
## Files
|
||||
|
||||
- `docker/untrusted-review/Dockerfile`
|
||||
- `docker-compose.untrusted-review.yml`
|
||||
- `docker/docker-compose.untrusted-review.yml`
|
||||
- `review-checkout-pr` inside the container
|
||||
|
||||
## Build and start a shell
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.untrusted-review.yml build
|
||||
docker compose -f docker-compose.untrusted-review.yml run --rm --service-ports review
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml build
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml run --rm --service-ports review
|
||||
```
|
||||
|
||||
That opens an interactive shell in the review container with:
|
||||
@@ -47,7 +47,7 @@ claude login
|
||||
If you prefer API-key auth instead of CLI login, pass keys through Compose env:
|
||||
|
||||
```sh
|
||||
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker-compose.untrusted-review.yml run --rm review
|
||||
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker/docker-compose.untrusted-review.yml run --rm review
|
||||
```
|
||||
|
||||
## Check out a PR safely
|
||||
@@ -117,7 +117,7 @@ Notes:
|
||||
Remove the review container volumes when you want a clean environment:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.untrusted-review.yml down -v
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml down -v
|
||||
```
|
||||
|
||||
That deletes:
|
||||
|
||||
@@ -22,6 +22,7 @@ The question is not "which memory project wins?" The question is "what is the sm
|
||||
### Hosted memory APIs
|
||||
|
||||
- `mem0`
|
||||
- `AWS Bedrock AgentCore Memory`
|
||||
- `supermemory`
|
||||
- `Memori`
|
||||
|
||||
@@ -49,6 +50,7 @@ These emphasize local persistence, inspectability, and low operational overhead.
|
||||
|---|---|---|---|---|
|
||||
| [nuggets](https://github.com/NeoVertex1/nuggets) | local memory engine + messaging gateway | topic-scoped HRR memory with `remember`, `recall`, `forget`, fact promotion into `MEMORY.md` | good example of lightweight local memory and automatic promotion | very specific architecture; not a general multi-tenant service |
|
||||
| [mem0](https://github.com/mem0ai/mem0) | hosted + OSS SDK | `add`, `search`, `getAll`, `get`, `update`, `delete`, `deleteAll`; entity partitioning via `user_id`, `agent_id`, `run_id`, `app_id` | closest to a clean provider API with identities and metadata filters | provider owns extraction heavily; Paperclip should not assume every backend behaves like mem0 |
|
||||
| [AWS Bedrock AgentCore Memory](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory.html) | AWS-managed memory service | explicit short-term and long-term memories, actor/session/event APIs, memory strategies, namespace templates, optional self-managed extraction pipeline | strong example of provider-managed memory with clear scoped ids, retention controls, and standalone API access outside a single agent framework | AWS-hosted and IAM-centric; Paperclip would still need its own company/run/comment provenance, cost rollups, and likely a plugin wrapper instead of baking AWS semantics into core |
|
||||
| [MemOS](https://github.com/MemTensor/MemOS) | memory OS / framework | unified add-retrieve-edit-delete, memory cubes, multimodal memory, tool memory, async scheduler, feedback/correction | strong source for optional capabilities beyond plain search | much broader than the minimal contract Paperclip should standardize first |
|
||||
| [supermemory](https://github.com/supermemoryai/supermemory) | hosted memory + context API | `add`, `profile`, `search.memories`, `search.documents`, document upload, settings; automatic profile building and forgetting | strong example of "context bundle" rather than raw search results | heavily productized around its own ontology and hosted flow |
|
||||
| [memU](https://github.com/NevaMind-AI/memU) | proactive agent memory framework | file-system metaphor, proactive loop, intent prediction, always-on companion model | good source for when memory should trigger agent behavior, not just retrieval | proactive assistant framing is broader than Paperclip's task-centric control plane |
|
||||
@@ -77,6 +79,7 @@ These differences are exactly why Paperclip needs a layered contract instead of
|
||||
### 1. Who owns extraction?
|
||||
|
||||
- `mem0`, `supermemory`, and `Memori` expect the provider to infer memories from conversations.
|
||||
- `AWS Bedrock AgentCore Memory` supports both provider-managed extraction and self-managed pipelines where the host writes curated long-term memory records.
|
||||
- `memsearch` expects the host to decide what markdown to write, then indexes it.
|
||||
- `MemOS`, `memU`, `EverMemOS`, and `OpenViking` sit somewhere in between and often expose richer memory construction pipelines.
|
||||
|
||||
@@ -104,6 +107,7 @@ Paperclip should make plain search the minimum contract and richer outputs optio
|
||||
### 4. Is memory synchronous or asynchronous?
|
||||
|
||||
- local tools often work synchronously in-process.
|
||||
- `AWS Bedrock AgentCore Memory` is synchronous at the API edge, but its long-term memory path includes background extraction/indexing behavior and retention policies managed by the provider.
|
||||
- larger systems add schedulers, background indexing, compaction, or sync jobs.
|
||||
|
||||
Paperclip needs both direct request/response operations and background maintenance hooks.
|
||||
|
||||
@@ -7,10 +7,10 @@ Define a Paperclip memory service and surface API that can sit above multiple me
|
||||
- company scoping
|
||||
- auditability
|
||||
- provenance back to Paperclip work objects
|
||||
- budget / cost visibility
|
||||
- budget and cost visibility
|
||||
- plugin-first extensibility
|
||||
|
||||
This plan is based on the external landscape summarized in `doc/memory-landscape.md` and on the current Paperclip architecture in:
|
||||
This plan is based on the external landscape summarized in `doc/memory-landscape.md`, the AWS AgentCore comparison captured in [PAP-1274](/PAP/issues/PAP-1274), and the current Paperclip architecture in:
|
||||
|
||||
- `doc/SPEC-implementation.md`
|
||||
- `doc/plugins/PLUGIN_SPEC.md`
|
||||
@@ -19,23 +19,26 @@ This plan is based on the external landscape summarized in `doc/memory-landscape
|
||||
|
||||
## Recommendation In One Sentence
|
||||
|
||||
Paperclip should not embed one opinionated memory engine into core. It should add a company-scoped memory control plane with a small normalized adapter contract, then let built-ins and plugins implement the provider-specific behavior.
|
||||
Paperclip should add a company-scoped memory control plane with company default plus agent override resolution, shared hook delivery, and full operation attribution, while leaving extraction and storage semantics to built-ins and plugins.
|
||||
|
||||
## Product Decisions
|
||||
|
||||
### 1. Memory is company-scoped by default
|
||||
### 1. Memory resolution is company default plus agent override
|
||||
|
||||
Every memory binding belongs to exactly one company.
|
||||
|
||||
That binding can then be:
|
||||
Resolution order in V1:
|
||||
|
||||
- the company default
|
||||
- an agent override
|
||||
- a project override later if we need it
|
||||
- company default binding
|
||||
- optional per-agent override
|
||||
|
||||
There is no per-project override in V1.
|
||||
|
||||
Project context can still appear in scope and provenance so providers can use it for retrieval and partitioning, but projects do not participate in binding selection.
|
||||
|
||||
No cross-company memory sharing in the initial design.
|
||||
|
||||
### 2. Providers are selected by key
|
||||
### 2. Providers are selected by stable binding key
|
||||
|
||||
Each configured memory provider gets a stable key inside a company, for example:
|
||||
|
||||
@@ -44,36 +47,53 @@ Each configured memory provider gets a stable key inside a company, for example:
|
||||
- `local-markdown`
|
||||
- `research-kb`
|
||||
|
||||
Agents and services resolve the active provider by key, not by hard-coded vendor logic.
|
||||
Agents, tools, and background hooks resolve the active provider by key, not by hard-coded vendor logic.
|
||||
|
||||
### 3. Plugins are the primary provider path
|
||||
|
||||
Built-ins are useful for a zero-config local path, but most providers should arrive through the existing Paperclip plugin runtime.
|
||||
|
||||
That keeps the core small and matches the current direction that optional knowledge-like systems live at the edges.
|
||||
That keeps the core small and matches the broader Paperclip direction that specialized knowledge systems live at the edges.
|
||||
|
||||
### 4. Paperclip owns routing, provenance, and accounting
|
||||
### 4. Paperclip owns routing, provenance, and policy
|
||||
|
||||
Providers should not decide how Paperclip entities map to governance.
|
||||
|
||||
Paperclip core should own:
|
||||
|
||||
- binding resolution
|
||||
- who is allowed to call a memory operation
|
||||
- which company / agent / project scope is active
|
||||
- what issue / run / comment / document the operation belongs to
|
||||
- how usage gets recorded
|
||||
- which company, agent, issue, project, run, and subject scope is active
|
||||
- what source object the operation belongs to
|
||||
- how usage and costs are attributed
|
||||
- how operators inspect what happened
|
||||
|
||||
### 5. Automatic memory should be narrow at first
|
||||
### 5. Paperclip exposes shared hooks, providers own extraction
|
||||
|
||||
Paperclip should emit a common set of memory hooks that built-ins, third-party adapters, and plugins can all use.
|
||||
|
||||
Those hooks should pass structured Paperclip source objects plus normalized metadata. The provider then decides how to extract from those objects.
|
||||
|
||||
Paperclip should not force one extraction pipeline or one canonical "memory text" transform before the provider sees the input.
|
||||
|
||||
### 6. Automatic memory should start narrow, but the hook surface should be general
|
||||
|
||||
Automatic capture is useful, but broad silent capture is dangerous.
|
||||
|
||||
Initial automatic hooks should be:
|
||||
Initial built-in automatic hooks should be:
|
||||
|
||||
- pre-run hydrate for agent context recall
|
||||
- post-run capture from agent runs
|
||||
- issue comment / document capture when the binding enables it
|
||||
- pre-run recall for agent context hydration
|
||||
- optional issue comment capture
|
||||
- optional issue document capture
|
||||
|
||||
Everything else should start explicit.
|
||||
The hook registry itself should be general enough that other providers can subscribe to the same events without core changes.
|
||||
|
||||
### 7. No approval gate for binding changes in the open-source product
|
||||
|
||||
For the open-source version, changing memory bindings should not require approvals.
|
||||
|
||||
Paperclip should still log those changes in activity and preserve full auditability. Approval-gated memory governance can remain an enterprise or future policy layer.
|
||||
|
||||
## Proposed Concepts
|
||||
|
||||
@@ -83,7 +103,7 @@ A built-in or plugin-supplied implementation that stores and retrieves memory.
|
||||
|
||||
Examples:
|
||||
|
||||
- local markdown + vector index
|
||||
- local markdown plus semantic index
|
||||
- mem0 adapter
|
||||
- supermemory adapter
|
||||
- MemOS adapter
|
||||
@@ -94,6 +114,15 @@ A company-scoped configuration record that points to a provider and carries prov
|
||||
|
||||
This is the object selected by key.
|
||||
|
||||
### Memory binding target
|
||||
|
||||
A mapping from a Paperclip target to a binding.
|
||||
|
||||
V1 targets:
|
||||
|
||||
- `company`
|
||||
- `agent`
|
||||
|
||||
### Memory scope
|
||||
|
||||
The normalized Paperclip scope passed into a provider request.
|
||||
@@ -105,7 +134,9 @@ At minimum:
|
||||
- optional `projectId`
|
||||
- optional `issueId`
|
||||
- optional `runId`
|
||||
- optional `subjectId` for external/user identity
|
||||
- optional `subjectId` for external or user identity
|
||||
- optional `sessionKey` for providers that organize memory around sessions
|
||||
- optional `namespace` for providers that need an explicit partition hint
|
||||
|
||||
### Memory source reference
|
||||
|
||||
@@ -121,24 +152,36 @@ Supported source kinds should include:
|
||||
- `manual_note`
|
||||
- `external_document`
|
||||
|
||||
### Memory hook
|
||||
|
||||
A normalized trigger emitted by Paperclip when something memory-relevant happens.
|
||||
|
||||
Initial hook kinds:
|
||||
|
||||
- `pre_run_hydrate`
|
||||
- `post_run_capture`
|
||||
- `issue_comment_capture`
|
||||
- `issue_document_capture`
|
||||
- `manual_capture`
|
||||
|
||||
### Memory operation
|
||||
|
||||
A normalized write, query, browse, or delete action performed through Paperclip.
|
||||
A normalized capture, record-write, query, browse, get, correction, or delete action performed through Paperclip.
|
||||
|
||||
Paperclip should log every operation, whether the provider is local or external.
|
||||
Paperclip should log every memory operation whether the provider is local, plugin-backed, or external.
|
||||
|
||||
## Required Adapter Contract
|
||||
|
||||
The required core should be small enough to fit `memsearch`, `mem0`, `Memori`, `MemOS`, or `OpenViking`.
|
||||
The required core should be small enough to fit `memsearch`, `mem0`, `Memori`, `MemOS`, or `OpenViking`, but strong enough to satisfy Paperclip's attribution and inspectability requirements.
|
||||
|
||||
```ts
|
||||
export interface MemoryAdapterCapabilities {
|
||||
profile?: boolean;
|
||||
browse?: boolean;
|
||||
correction?: boolean;
|
||||
asyncIngestion?: boolean;
|
||||
multimodal?: boolean;
|
||||
providerManagedExtraction?: boolean;
|
||||
asyncExtraction?: boolean;
|
||||
providerNativeBrowse?: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryScope {
|
||||
@@ -148,6 +191,8 @@ export interface MemoryScope {
|
||||
issueId?: string;
|
||||
runId?: string;
|
||||
subjectId?: string;
|
||||
sessionKey?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export interface MemorySourceRef {
|
||||
@@ -168,10 +213,34 @@ export interface MemorySourceRef {
|
||||
externalRef?: string;
|
||||
}
|
||||
|
||||
export interface MemoryHookContext {
|
||||
hookKind:
|
||||
| "pre_run_hydrate"
|
||||
| "post_run_capture"
|
||||
| "issue_comment_capture"
|
||||
| "issue_document_capture"
|
||||
| "manual_capture";
|
||||
hookId: string;
|
||||
triggeredAt: string;
|
||||
actorAgentId?: string;
|
||||
heartbeatRunId?: string;
|
||||
}
|
||||
|
||||
export interface MemorySourcePayload {
|
||||
text?: string;
|
||||
mimeType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
object?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MemoryUsage {
|
||||
provider: string;
|
||||
biller?: string;
|
||||
model?: string;
|
||||
billingType?: "metered_api" | "subscription_included" | "subscription_overage" | "unknown";
|
||||
attributionMode?: "billed_directly" | "included_in_run" | "external_invoice" | "untracked";
|
||||
inputTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
outputTokens?: number;
|
||||
embeddingTokens?: number;
|
||||
costCents?: number;
|
||||
@@ -179,20 +248,32 @@ export interface MemoryUsage {
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MemoryWriteRequest {
|
||||
bindingKey: string;
|
||||
scope: MemoryScope;
|
||||
source: MemorySourceRef;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
mode?: "append" | "upsert" | "summarize";
|
||||
}
|
||||
|
||||
export interface MemoryRecordHandle {
|
||||
providerKey: string;
|
||||
providerRecordId: string;
|
||||
}
|
||||
|
||||
export interface MemoryCaptureRequest {
|
||||
bindingKey: string;
|
||||
scope: MemoryScope;
|
||||
source: MemorySourceRef;
|
||||
payload: MemorySourcePayload;
|
||||
hook?: MemoryHookContext;
|
||||
mode?: "capture_residue" | "capture_record";
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MemoryRecordWriteRequest {
|
||||
bindingKey: string;
|
||||
scope: MemoryScope;
|
||||
source?: MemorySourceRef;
|
||||
records: Array<{
|
||||
text: string;
|
||||
summary?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MemoryQueryRequest {
|
||||
bindingKey: string;
|
||||
scope: MemoryScope;
|
||||
@@ -202,6 +283,14 @@ export interface MemoryQueryRequest {
|
||||
metadataFilter?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MemoryListRequest {
|
||||
bindingKey: string;
|
||||
scope: MemoryScope;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
metadataFilter?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MemorySnippet {
|
||||
handle: MemoryRecordHandle;
|
||||
text: string;
|
||||
@@ -217,30 +306,149 @@ export interface MemoryContextBundle {
|
||||
usage?: MemoryUsage[];
|
||||
}
|
||||
|
||||
export interface MemoryListPage {
|
||||
items: MemorySnippet[];
|
||||
nextCursor?: string;
|
||||
usage?: MemoryUsage[];
|
||||
}
|
||||
|
||||
export interface MemoryExtractionJob {
|
||||
providerJobId: string;
|
||||
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
hookKind?: MemoryHookContext["hookKind"];
|
||||
source?: MemorySourceRef;
|
||||
error?: string;
|
||||
submittedAt?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface MemoryAdapter {
|
||||
key: string;
|
||||
capabilities: MemoryAdapterCapabilities;
|
||||
write(req: MemoryWriteRequest): Promise<{
|
||||
capture(req: MemoryCaptureRequest): Promise<{
|
||||
records?: MemoryRecordHandle[];
|
||||
jobs?: MemoryExtractionJob[];
|
||||
usage?: MemoryUsage[];
|
||||
}>;
|
||||
upsertRecords(req: MemoryRecordWriteRequest): Promise<{
|
||||
records?: MemoryRecordHandle[];
|
||||
usage?: MemoryUsage[];
|
||||
}>;
|
||||
query(req: MemoryQueryRequest): Promise<MemoryContextBundle>;
|
||||
list(req: MemoryListRequest): Promise<MemoryListPage>;
|
||||
get(handle: MemoryRecordHandle, scope: MemoryScope): Promise<MemorySnippet | null>;
|
||||
forget(handles: MemoryRecordHandle[], scope: MemoryScope): Promise<{ usage?: MemoryUsage[] }>;
|
||||
}
|
||||
```
|
||||
|
||||
This contract intentionally does not force a provider to expose its internal graph, filesystem, or ontology.
|
||||
This contract intentionally does not force a provider to expose its internal graph, file tree, or ontology. It does require enough structure for Paperclip to browse, attribute, and audit what happened.
|
||||
|
||||
## Optional Adapter Surfaces
|
||||
|
||||
These should be capability-gated, not required:
|
||||
|
||||
- `browse(scope, filters)` for file-system / graph / timeline inspection
|
||||
- `correct(handle, patch)` for natural-language correction flows
|
||||
- `profile(scope)` when the provider can synthesize stable preferences or summaries
|
||||
- `sync(source)` for connectors or background ingestion
|
||||
- `listExtractionJobs(scope, cursor)` when async extraction needs richer operator visibility
|
||||
- `retryExtractionJob(jobId)` when a provider supports re-drive
|
||||
- `explain(queryResult)` for providers that can expose retrieval traces
|
||||
- provider-native browse or graph surfaces exposed through plugin UI
|
||||
|
||||
## Lessons From AWS AgentCore Memory API
|
||||
|
||||
AWS AgentCore Memory is a useful check on whether this plan is too abstract or missing important operational surfaces.
|
||||
|
||||
The broad direction still looks right:
|
||||
|
||||
- AWS splits memory into a control plane (`CreateMemory`, `UpdateMemory`, `ListMemories`) and a data plane (`CreateEvent`, `RetrieveMemoryRecords`, `GetMemoryRecord`, `ListMemoryRecords`)
|
||||
- AWS separates raw interaction capture from curated long-term memory records
|
||||
- AWS supports both provider-managed extraction and self-managed pipelines
|
||||
- AWS treats browse and list operations as first-class APIs, not ad hoc debugging helpers
|
||||
- AWS exposes extraction jobs instead of hiding asynchronous maintenance completely
|
||||
|
||||
That lines up with the Paperclip plan at a high level: provider configuration, scoped writes, scoped retrieval, provider-managed extraction as a capability, and a browse and inspect surface.
|
||||
|
||||
The concrete changes Paperclip should take from AWS are:
|
||||
|
||||
### 1. Keep config APIs separate from runtime traffic
|
||||
|
||||
The rollout should preserve a clean separation between:
|
||||
|
||||
- control-plane APIs for binding CRUD, defaults, overrides, and capability metadata
|
||||
- runtime APIs and tools for capture, record writes, query, list, get, forget, and extraction status
|
||||
|
||||
This keeps governance changes distinct from high-volume memory traffic.
|
||||
|
||||
### 2. Distinguish capture from curated record writes
|
||||
|
||||
AWS does not flatten everything into one write primitive. It distinguishes captured events from durable memory records.
|
||||
|
||||
Paperclip should do the same:
|
||||
|
||||
- `capture(...)` for raw run, comment, document, or activity residue
|
||||
- `upsertRecords(...)` for curated durable facts and notes
|
||||
|
||||
That is a better fit for provider-managed extraction and for manual curation flows.
|
||||
|
||||
### 3. Make list and browse first-class
|
||||
|
||||
AWS exposes list and retrieve surfaces directly. Paperclip should not make browse optional at the portable layer.
|
||||
|
||||
The minimum portable surface should include:
|
||||
|
||||
- `query`
|
||||
- `list`
|
||||
- `get`
|
||||
|
||||
Provider-native graph or file browsing can remain optional beyond that.
|
||||
|
||||
### 4. Add pagination and cursors for operator inspection
|
||||
|
||||
AWS consistently uses pagination on browse-heavy APIs.
|
||||
|
||||
Paperclip should add cursor-based pagination to:
|
||||
|
||||
- record listing
|
||||
- extraction job listing
|
||||
- memory operation explorer APIs
|
||||
|
||||
Prompt hydration can continue to use `topK`, but operator surfaces need cursors.
|
||||
|
||||
### 5. Add explicit session and namespace hints
|
||||
|
||||
AWS uses `actorId`, `sessionId`, `namespace`, and `memoryStrategyId` heavily.
|
||||
|
||||
Paperclip should keep its own control-plane-centric model, but the adapter contract needs obvious places to map those concepts:
|
||||
|
||||
- `sessionKey`
|
||||
- `namespace`
|
||||
|
||||
The provider adapter can map them to AWS or other vendor-specific identifiers without leaking those identifiers into core.
|
||||
|
||||
### 6. Treat asynchronous extraction as a real operational surface
|
||||
|
||||
AWS exposes extraction jobs explicitly. Paperclip should too.
|
||||
|
||||
Operators should be able to see:
|
||||
|
||||
- pending extraction work
|
||||
- failed extraction work
|
||||
- which hook or source caused the work
|
||||
- whether a retry is available
|
||||
|
||||
### 7. Keep Paperclip provenance primary
|
||||
|
||||
Paperclip should continue to center:
|
||||
|
||||
- `companyId`
|
||||
- `agentId`
|
||||
- `projectId`
|
||||
- `issueId`
|
||||
- `runId`
|
||||
- issue comments, documents, and activity as sources
|
||||
|
||||
The lesson from AWS is to support clean mapping into provider-specific models, not to let provider identifiers take over the core product model.
|
||||
|
||||
## What Paperclip Should Persist
|
||||
|
||||
@@ -248,39 +456,67 @@ Paperclip should not mirror the full provider memory corpus into Postgres unless
|
||||
|
||||
Paperclip core should persist:
|
||||
|
||||
- memory bindings and overrides
|
||||
- memory bindings
|
||||
- company default and agent override resolution targets
|
||||
- provider keys and capability metadata
|
||||
- normalized memory operation logs
|
||||
- provider record handles returned by operations when available
|
||||
- source references back to issue comments, documents, runs, and activity
|
||||
- usage and cost data
|
||||
- provider record handles returned by operations when available
|
||||
- hook delivery records and extraction job state
|
||||
- usage and cost attribution
|
||||
|
||||
For external providers, the memory payload itself can remain in the provider.
|
||||
For external providers, the actual memory payload can remain in the provider.
|
||||
|
||||
## Hook Model
|
||||
|
||||
### Automatic hooks
|
||||
### Shared hook surface
|
||||
|
||||
Paperclip should expose one shared hook system for memory.
|
||||
|
||||
That same system must be available to:
|
||||
|
||||
- built-in memory providers
|
||||
- plugin-based memory providers
|
||||
- third-party adapter integrations that want to use memory hooks
|
||||
|
||||
### What a hook delivers
|
||||
|
||||
Each hook delivery should include:
|
||||
|
||||
- resolved binding key
|
||||
- normalized `MemoryScope`
|
||||
- `MemorySourceRef`
|
||||
- structured source payload
|
||||
- hook metadata such as hook kind, trigger time, and related run id
|
||||
|
||||
The payload should include structured objects where possible so the provider can decide how to extract and chunk.
|
||||
|
||||
### Initial automatic hooks
|
||||
|
||||
These should be low-risk and easy to reason about:
|
||||
|
||||
1. `pre-run hydrate`
|
||||
1. `pre_run_hydrate`
|
||||
Before an agent run starts, Paperclip may call `query(... intent = "agent_preamble")` using the active binding.
|
||||
|
||||
2. `post-run capture`
|
||||
After a run finishes, Paperclip may write a summary or transcript-derived note tied to the run.
|
||||
2. `post_run_capture`
|
||||
After a run finishes, Paperclip may call `capture(...)` with structured run output, excerpts, and provenance.
|
||||
|
||||
3. `issue comment / document capture`
|
||||
When enabled on the binding, Paperclip may capture selected issue comments or issue documents as memory sources.
|
||||
3. `issue_comment_capture`
|
||||
When enabled on the binding, Paperclip may call `capture(...)` for selected issue comments.
|
||||
|
||||
### Explicit hooks
|
||||
4. `issue_document_capture`
|
||||
When enabled on the binding, Paperclip may call `capture(...)` for selected issue documents.
|
||||
|
||||
These should be tool- or UI-driven first:
|
||||
### Explicit tools and APIs
|
||||
|
||||
These should be tool-driven or UI-driven first:
|
||||
|
||||
- `memory.search`
|
||||
- `memory.note`
|
||||
- `memory.forget`
|
||||
- `memory.correct`
|
||||
- `memory.browse`
|
||||
- memory record list and get
|
||||
- extraction-job inspection
|
||||
|
||||
### Not automatic in the first version
|
||||
|
||||
@@ -309,34 +545,69 @@ The initial browse surface should support:
|
||||
|
||||
- active binding by company and agent
|
||||
- recent memory operations
|
||||
- recent write sources
|
||||
- recent write and capture sources
|
||||
- record list and record detail with source backlinks
|
||||
- query results with source backlinks
|
||||
- filters by agent, issue, run, source kind, and date
|
||||
- provider usage / cost / latency summaries
|
||||
- extraction job status
|
||||
- filters by agent, issue, project, run, source kind, and date
|
||||
- provider usage, cost, and latency summaries
|
||||
|
||||
When a provider supports richer browsing, the plugin can add deeper views through the existing plugin UI surfaces.
|
||||
|
||||
## Cost And Evaluation
|
||||
|
||||
Every adapter response should be able to return usage records.
|
||||
Paperclip should treat memory accounting as two related but distinct concerns:
|
||||
|
||||
Paperclip should roll up:
|
||||
### 1. `memory_operations` is the authoritative audit trail
|
||||
|
||||
- memory inference tokens
|
||||
- embedding tokens
|
||||
- external provider cost
|
||||
Every memory action should create a normalized operation record that captures:
|
||||
|
||||
- binding
|
||||
- scope
|
||||
- source provenance
|
||||
- operation type
|
||||
- success or failure
|
||||
- latency
|
||||
- query count
|
||||
- write count
|
||||
- usage details reported by the provider
|
||||
- attribution mode
|
||||
- related run, issue, and agent when available
|
||||
|
||||
It should also record evaluation-oriented metrics where possible:
|
||||
This is where operators answer "what memory work happened and why?"
|
||||
|
||||
### 2. `cost_events` remains the canonical spend ledger for billable metered usage
|
||||
|
||||
The current `cost_events` model is already the canonical cost ledger for token and model spend, and `agent_runtime_state` plus `heartbeat_runs.usageJson` already roll up and summarize run usage.
|
||||
|
||||
The recommendation is:
|
||||
|
||||
- if a memory operation runs inside a normal Paperclip agent heartbeat and the model usage is already counted on that run, do not create a duplicate `cost_event`
|
||||
- instead, store the memory operation with `attributionMode = "included_in_run"` and link it to the related `heartbeatRunId`
|
||||
- if a memory provider makes a direct metered model call outside the agent run accounting path, the provider must report usage and Paperclip should create a `cost_event`
|
||||
- that direct `cost_event` should still link back to the memory operation, agent, company, and issue or run context when possible
|
||||
|
||||
### 3. `finance_events` should carry flat subscription or invoice-style costs
|
||||
|
||||
If a memory service incurs:
|
||||
|
||||
- monthly subscription cost
|
||||
- storage invoices
|
||||
- provider platform charges not tied to one request
|
||||
|
||||
those should be represented as `finance_events`, not as synthetic per-query memory operations.
|
||||
|
||||
That keeps usage telemetry separate from accounting entries like invoices and flat fees.
|
||||
|
||||
### 4. Evaluation metrics still matter
|
||||
|
||||
Paperclip should record evaluation-oriented metrics where possible:
|
||||
|
||||
- recall hit rate
|
||||
- empty query rate
|
||||
- manual correction count
|
||||
- per-binding success / failure counts
|
||||
- extraction failure count
|
||||
- per-binding success and failure counts
|
||||
|
||||
This is important because a memory system that "works" but silently burns budget is not acceptable in Paperclip.
|
||||
This is important because a memory system that "works" but silently burns budget or silently fails extraction is not acceptable in Paperclip.
|
||||
|
||||
## Suggested Data Model Additions
|
||||
|
||||
@@ -344,23 +615,36 @@ At the control-plane level, the likely new core tables are:
|
||||
|
||||
- `memory_bindings`
|
||||
- company-scoped key
|
||||
- provider id / plugin id
|
||||
- provider id or plugin id
|
||||
- config blob
|
||||
- enabled status
|
||||
|
||||
- `memory_binding_targets`
|
||||
- target type (`company`, `agent`, later `project`)
|
||||
- target type (`company`, `agent`)
|
||||
- target id
|
||||
- binding id
|
||||
|
||||
- `memory_operations`
|
||||
- company id
|
||||
- binding id
|
||||
- operation type (`write`, `query`, `forget`, `browse`, `correct`)
|
||||
- operation type (`capture`, `record_upsert`, `query`, `list`, `get`, `forget`, `correct`)
|
||||
- scope fields
|
||||
- source refs
|
||||
- usage / latency / cost
|
||||
- success / error
|
||||
- usage, latency, and attribution mode
|
||||
- related heartbeat run id
|
||||
- related cost event id
|
||||
- success or error
|
||||
|
||||
- `memory_extraction_jobs`
|
||||
- company id
|
||||
- binding id
|
||||
- operation id
|
||||
- provider job id
|
||||
- hook kind
|
||||
- status
|
||||
- source refs
|
||||
- error
|
||||
- submitted, started, and finished timestamps
|
||||
|
||||
Provider-specific long-form state should stay in plugin state or the provider itself unless a built-in local provider needs its own schema.
|
||||
|
||||
@@ -382,45 +666,46 @@ The design should still treat that built-in as just another provider behind the
|
||||
### Phase 1: Control-plane contract
|
||||
|
||||
- add memory binding models and API types
|
||||
- add plugin capability / registration surface for memory providers
|
||||
- add operation logging and usage reporting
|
||||
- add company default plus agent override resolution
|
||||
- add plugin capability and registration surface for memory providers
|
||||
|
||||
### Phase 2: One built-in + one plugin example
|
||||
### Phase 2: Hook delivery and operation audit
|
||||
|
||||
- add shared memory hook emission in core
|
||||
- add operation logging, extraction job state, and usage attribution
|
||||
- add direct-provider cost and finance-event linkage rules
|
||||
|
||||
### Phase 3: One built-in plus one plugin example
|
||||
|
||||
- ship a local markdown-first provider
|
||||
- ship one hosted adapter example to validate the external-provider path
|
||||
|
||||
### Phase 3: UI inspection
|
||||
### Phase 4: UI inspection
|
||||
|
||||
- add company / agent memory settings
|
||||
- add company and agent memory settings
|
||||
- add a memory operation explorer
|
||||
- add record list and detail surfaces
|
||||
- add source backlinks to issues and runs
|
||||
|
||||
### Phase 4: Automatic hooks
|
||||
|
||||
- pre-run hydrate
|
||||
- post-run capture
|
||||
- selected issue comment / document capture
|
||||
|
||||
### Phase 5: Rich capabilities
|
||||
|
||||
- correction flows
|
||||
- provider-native browse / graph views
|
||||
- project-level overrides if needed
|
||||
- provider-native browse or graph views
|
||||
- evaluation dashboards
|
||||
- retention and quota controls
|
||||
|
||||
## Open Questions
|
||||
## Remaining Open Questions
|
||||
|
||||
- Should project overrides exist in V1 of the memory service, or should we force company default + agent override first?
|
||||
- Do we want Paperclip-managed extraction pipelines at all, or should built-ins be the only place where Paperclip owns extraction?
|
||||
- Should memory usage extend the current `cost_events` model directly, or should memory operations keep a parallel usage log and roll up into `cost_events` secondarily?
|
||||
- Do we want provider install / binding changes to require approvals for some companies?
|
||||
- Which built-in local provider should ship first: pure markdown, markdown plus embeddings, or a lightweight local vector store?
|
||||
- How much source payload should Paperclip snapshot inside `memory_operations` for debugging without duplicating large transcripts?
|
||||
- Should correction flows mutate provider state directly, create superseding records, or both depending on provider capability?
|
||||
- What default retention and size limits should the local built-in enforce?
|
||||
|
||||
## Bottom Line
|
||||
|
||||
The right abstraction is:
|
||||
|
||||
- Paperclip owns memory bindings, scopes, provenance, governance, and usage reporting.
|
||||
- Paperclip owns bindings, resolution, hooks, provenance, policy, and attribution.
|
||||
- Providers own extraction, ranking, storage, and provider-native memory semantics.
|
||||
|
||||
That gives Paperclip a stable "memory service" without locking the product to one memory philosophy or one vendor.
|
||||
That gives Paperclip a stable memory service without locking the product to one memory philosophy or one vendor, and it integrates the AWS lessons without importing AWS's model into core.
|
||||
|
||||
362
doc/plans/2026-04-06-smart-model-routing.md
Normal file
362
doc/plans/2026-04-06-smart-model-routing.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# 2026-04-06 Smart Model Routing
|
||||
|
||||
Status: Proposed
|
||||
Date: 2026-04-06
|
||||
Audience: Product and engineering
|
||||
Related:
|
||||
- `doc/SPEC-implementation.md`
|
||||
- `doc/PRODUCT.md`
|
||||
- `doc/plans/2026-03-14-adapter-skill-sync-rollout.md`
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document defines a V1 plan for "smart model routing" in Paperclip.
|
||||
|
||||
The goal is not to build a generic cross-provider router in the server. The goal is:
|
||||
|
||||
- let supported adapters use a cheaper model for lightweight heartbeat orchestration work
|
||||
- keep the main task execution on the adapter's normal primary model
|
||||
- preserve Paperclip's existing task, session, and audit invariants
|
||||
- report cost and model usage truthfully when more than one model participates in a single heartbeat
|
||||
|
||||
The motivating use case is a local coding adapter where a cheap model can handle the first fast pass:
|
||||
|
||||
- read the wake context
|
||||
- orient to the task and workspace
|
||||
- leave an immediate progress comment when appropriate
|
||||
- perform bounded lightweight triage
|
||||
|
||||
Then the primary model does the substantive work.
|
||||
|
||||
## 2. Hermes Findings
|
||||
|
||||
Hermes does have a real "smart model routing" feature, but it is narrower than the name suggests.
|
||||
|
||||
Observed behavior:
|
||||
|
||||
- `agent/smart_model_routing.py` implements a conservative classifier for "simple" turns
|
||||
- the cheap path only triggers for short, single-line, non-code, non-URL, non-tool-heavy messages
|
||||
- complexity is detected with hardcoded thresholds plus a keyword denylist like `debug`, `implement`, `test`, `plan`, `tool`, `docker`, and similar terms
|
||||
- if the cheap route cannot be resolved, Hermes silently falls back to the primary model
|
||||
|
||||
Important architectural detail:
|
||||
|
||||
- Hermes applies this routing before constructing the agent for that turn
|
||||
- the route is resolved in `cron/scheduler.py` and passed into agent creation as the active provider/model/runtime
|
||||
|
||||
More useful than the routing heuristic itself is Hermes' broader model-slot design:
|
||||
|
||||
- main conversational model
|
||||
- fallback model for failover
|
||||
- auxiliary model slots for side tasks like compression and classification
|
||||
|
||||
That separation is a better fit for Paperclip than copying Hermes' exact keyword heuristic.
|
||||
|
||||
## 3. Current Paperclip State
|
||||
|
||||
Paperclip already has the right execution shape for adapter-specific routing, but it currently assumes one model per heartbeat run.
|
||||
|
||||
Current implementation facts:
|
||||
|
||||
- `server/src/services/heartbeat.ts` builds rich run context, including `paperclipWake`, workspace metadata, and session handoff context
|
||||
- each adapter receives a single resolved `config` object and executes once
|
||||
- built-in local adapters read one `config.model` and pass it directly to the underlying CLI
|
||||
- UI config today exposes one main `model` field plus adapter-specific thinking-effort controls
|
||||
- cost accounting currently records one provider/model tuple per run via `AdapterExecutionResult`
|
||||
|
||||
What this means:
|
||||
|
||||
- there is no shared routing layer in the server today
|
||||
- model choice already lives at the adapter boundary, which is good
|
||||
- multi-model execution in a single heartbeat needs explicit contract work or cost reporting will become misleading
|
||||
|
||||
## 4. Product Decision
|
||||
|
||||
Paperclip should implement smart model routing as an adapter-local, opt-in execution pattern.
|
||||
|
||||
V1 decision:
|
||||
|
||||
1. Do not add a global server-side router that tries to understand every adapter.
|
||||
2. Do not copy Hermes' prompt-keyword classifier as Paperclip's default routing policy.
|
||||
3. Add an adapter-specific "cheap preflight" phase for supported adapters.
|
||||
4. Keep the primary model as the canonical work model.
|
||||
5. Persist only the primary session unless an adapter can prove that cross-model session resume is safe.
|
||||
|
||||
Rationale:
|
||||
|
||||
- Paperclip heartbeats are structured, issue-scoped, and already include wake metadata
|
||||
- routing by execution phase is more reliable than routing by free-text prompt complexity
|
||||
- session semantics differ by adapter, so resume behavior must stay adapter-owned
|
||||
|
||||
## 5. Proposed V1 Behavior
|
||||
|
||||
## 5.1 Config shape
|
||||
|
||||
Supported adapters should add an optional routing block to `adapterConfig`.
|
||||
|
||||
Proposed shape:
|
||||
|
||||
```ts
|
||||
smartModelRouting?: {
|
||||
enabled: boolean;
|
||||
cheapModel: string;
|
||||
cheapThinkingEffort?: string;
|
||||
maxPreflightTurns?: number;
|
||||
allowInitialProgressComment?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- keep existing `model` as the primary model
|
||||
- `cheapModel` is adapter-specific, not global
|
||||
- adapters that cannot safely support this block simply ignore it
|
||||
|
||||
For adapters with provider-specific model fields later, the shape can expand to include provider/base-url overrides. V1 should start simple.
|
||||
|
||||
## 5.2 Routing policy
|
||||
|
||||
Supported adapters should run cheap preflight only when all are true:
|
||||
|
||||
- `smartModelRouting.enabled` is true
|
||||
- `cheapModel` is configured
|
||||
- the run is issue-scoped
|
||||
- the adapter is starting a fresh session, not resuming a persisted one
|
||||
- the run is expected to do real task work rather than just resume an existing thread
|
||||
|
||||
Supported adapters should skip cheap preflight when any are true:
|
||||
|
||||
- a persisted task session already exists
|
||||
- the adapter cannot safely isolate preflight from the primary session
|
||||
- the issue or wake type implies the task is already mid-flight and continuity matters more than first-response speed
|
||||
|
||||
This is intentionally phase-based, not text-heuristic-based.
|
||||
|
||||
## 5.3 Cheap preflight responsibilities
|
||||
|
||||
The cheap phase should be narrow and bounded.
|
||||
|
||||
Allowed responsibilities:
|
||||
|
||||
- ingest wake context and issue summary
|
||||
- inspect the workspace at a shallow level
|
||||
- leave a short "starting investigation" style comment when appropriate
|
||||
- collect a compact handoff summary for the primary phase
|
||||
|
||||
Not allowed in V1:
|
||||
|
||||
- long tool loops
|
||||
- risky file mutations
|
||||
- being the canonical persisted task session
|
||||
- deciding final completion without either explicit adapter support or a trivial success case
|
||||
|
||||
Implementation detail:
|
||||
|
||||
- the adapter should inject an explicit preflight prompt telling the model this is a bounded orchestration pass
|
||||
- preflight should use a very small turn budget, for example 1-2 turns
|
||||
|
||||
## 5.4 Primary execution responsibilities
|
||||
|
||||
After preflight, the adapter launches the normal primary execution using the existing prompt and primary model.
|
||||
|
||||
The primary phase should receive:
|
||||
|
||||
- the normal Paperclip prompt
|
||||
- any preflight-generated handoff summary
|
||||
- normal workspace and wake context
|
||||
|
||||
The primary phase remains the source of truth for:
|
||||
|
||||
- persisted session state
|
||||
- final task completion
|
||||
- most file changes
|
||||
- most cost
|
||||
|
||||
## 6. Required Contract Changes
|
||||
|
||||
The current `AdapterExecutionResult` is too narrow for truthful multi-model accounting.
|
||||
|
||||
Add an optional segmented execution report, for example:
|
||||
|
||||
```ts
|
||||
executionSegments?: Array<{
|
||||
phase: "cheap_preflight" | "primary";
|
||||
provider?: string | null;
|
||||
biller?: string | null;
|
||||
model?: string | null;
|
||||
billingType?: AdapterBillingType | null;
|
||||
usage?: UsageSummary;
|
||||
costUsd?: number | null;
|
||||
summary?: string | null;
|
||||
}>
|
||||
```
|
||||
|
||||
V1 server behavior:
|
||||
|
||||
- if `executionSegments` is absent, keep current single-result behavior unchanged
|
||||
- if present, write one `cost_events` row per segment that has cost or token usage
|
||||
- store the segment array in run usage/result metadata for later UI inspection
|
||||
- keep the existing top-level `provider` / `model` fields as a summary, preferably the primary phase when present
|
||||
|
||||
This avoids breaking existing adapters while giving routed adapters truthful reporting.
|
||||
|
||||
## 7. Adapter Rollout Plan
|
||||
|
||||
## 7.1 Phase 1: contract and server plumbing
|
||||
|
||||
Work:
|
||||
|
||||
1. Extend adapter result types with segmented execution metadata.
|
||||
2. Update heartbeat cost recording to emit multiple cost events when segments are present.
|
||||
3. Include segment summaries in run metadata for transcript/debug views.
|
||||
|
||||
Success criteria:
|
||||
|
||||
- existing adapters behave exactly as before
|
||||
- a routed adapter can report cheap plus primary usage without collapsing them into one fake model
|
||||
|
||||
## 7.2 Phase 2: `codex_local`
|
||||
|
||||
Why first:
|
||||
|
||||
- Codex already has rich prompt/handoff handling
|
||||
- the adapter already injects Paperclip skills and workspace metadata cleanly
|
||||
- the current implementation already distinguishes bootstrap, wake delta, and handoff prompt sections
|
||||
|
||||
Implementation work:
|
||||
|
||||
1. Add config support for `smartModelRouting`.
|
||||
2. Add a cheap-preflight prompt builder.
|
||||
3. Run cheap preflight only on fresh sessions.
|
||||
4. Pass a compact preflight handoff note into the primary prompt.
|
||||
5. Report segmented usage and model metadata.
|
||||
|
||||
Important guardrail:
|
||||
|
||||
- do not resume the cheap-model session as the primary session in V1
|
||||
|
||||
## 7.3 Phase 3: `claude_local`
|
||||
|
||||
Implementation work is similar, but the session model-switch risk is even less attractive.
|
||||
|
||||
Same rule:
|
||||
|
||||
- cheap preflight is ephemeral
|
||||
- primary Claude session remains canonical
|
||||
|
||||
## 7.4 Phase 4: other adapters
|
||||
|
||||
Candidates:
|
||||
|
||||
- `cursor`
|
||||
- `gemini_local`
|
||||
- `opencode_local`
|
||||
- external plugin adapters through `createServerAdapter()`
|
||||
|
||||
These should come later because each runtime has different session and model-switch semantics.
|
||||
|
||||
## 8. UI and Config Changes
|
||||
|
||||
For supported built-in adapters, the agent config UI should expose:
|
||||
|
||||
- `model` as the primary model
|
||||
- `smart model routing` toggle
|
||||
- `cheap model`
|
||||
- optional cheap thinking effort
|
||||
- optional `allow initial progress comment` toggle
|
||||
|
||||
The run detail UI should also show when routing occurred, for example:
|
||||
|
||||
- cheap preflight model
|
||||
- primary model
|
||||
- token/cost split
|
||||
|
||||
This matters because Paperclip's board UI is supposed to make cost and behavior legible.
|
||||
|
||||
## 9. Why Not Copy Hermes Exactly
|
||||
|
||||
Hermes' cheap-route heuristic is useful precedent, but Paperclip should not start there.
|
||||
|
||||
Reasons:
|
||||
|
||||
- Hermes is optimizing free-form conversational turns
|
||||
- Paperclip agents run structured, issue-scoped heartbeats with explicit task and workspace context
|
||||
- Paperclip already knows whether a run is fresh vs resumed, issue-scoped vs approval follow-up, and what workspace/session exists
|
||||
- those execution facts are stronger routing signals than prompt keyword matching
|
||||
|
||||
If Paperclip later wants a cheap-only completion path for trivial runs, that can be a second-stage feature built on observed run data, not the first implementation.
|
||||
|
||||
## 10. Risks
|
||||
|
||||
## 10.1 Duplicate or noisy comments
|
||||
|
||||
If the cheap phase posts an update and the primary phase posts another near-identical update, the issue thread gets worse.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep cheap comments optional
|
||||
- make the preflight prompt explicitly avoid repeating status if a useful comment was already posted
|
||||
|
||||
## 10.2 Misleading cost reporting
|
||||
|
||||
If we only record the primary model, the board loses visibility into the routing cost tradeoff.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- add segmented execution reporting before shipping adapter behavior
|
||||
|
||||
## 10.3 Session corruption
|
||||
|
||||
Cross-model session reuse may fail or degrade context quality.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- V1 does not persist or resume cheap preflight sessions
|
||||
|
||||
## 10.4 Cheap model overreach
|
||||
|
||||
A cheap model with full tools and permissions may do too much low-quality work.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- hard cap preflight turns
|
||||
- use an explicit orchestration-only prompt
|
||||
- start with supported adapters where we can test the behavior well
|
||||
|
||||
## 11. Verification Plan
|
||||
|
||||
Required tests:
|
||||
|
||||
- adapter unit tests for route eligibility
|
||||
- adapter unit tests for "fresh session -> cheap preflight + primary"
|
||||
- adapter unit tests for "resumed session -> primary only"
|
||||
- heartbeat tests for segmented cost-event creation
|
||||
- UI tests for config save/load of cheap-model fields
|
||||
|
||||
Manual checks:
|
||||
|
||||
- create a fresh issue for a routed Codex or Claude agent
|
||||
- verify the run metadata shows both phases
|
||||
- verify only the primary session is persisted
|
||||
- verify cost rows reflect both models
|
||||
- verify the issue thread does not get duplicate kickoff comments
|
||||
|
||||
## 12. Recommended Sequence
|
||||
|
||||
1. Add segmented execution reporting to the adapter/server contract.
|
||||
2. Implement `codex_local` cheap preflight.
|
||||
3. Validate cost visibility and transcript UX.
|
||||
4. Implement `claude_local` cheap preflight.
|
||||
5. Decide later whether any adapters need Hermes-style text heuristics in addition to phase-based routing.
|
||||
|
||||
## 13. Recommendation
|
||||
|
||||
Paperclip should ship smart model routing as:
|
||||
|
||||
- adapter-specific
|
||||
- opt-in
|
||||
- phase-based
|
||||
- session-safe
|
||||
- cost-truthful
|
||||
|
||||
The right V1 is not "choose the cheapest model for simple prompts." The right V1 is "use a cheap model for bounded orchestration work on fresh runs, then hand off to the primary model for the real task."
|
||||
209
doc/plans/2026-04-06-subissue-creation-on-issue-detail.md
Normal file
209
doc/plans/2026-04-06-subissue-creation-on-issue-detail.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 2026-04-06 Sub-issue Creation On Issue Detail Plan
|
||||
|
||||
Status: Proposed
|
||||
Date: 2026-04-06
|
||||
Audience: Product and engineering
|
||||
Related:
|
||||
- `ui/src/pages/IssueDetail.tsx`
|
||||
- `ui/src/components/IssueProperties.tsx`
|
||||
- `ui/src/components/NewIssueDialog.tsx`
|
||||
- `ui/src/context/DialogContext.tsx`
|
||||
- `packages/shared/src/validators/issue.ts`
|
||||
- `server/src/services/issues.ts`
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document defines the implementation plan for adding manual sub-issue creation from the issue detail page.
|
||||
|
||||
Requested UX:
|
||||
|
||||
- the `Sub-issues` tab should always show an `Add sub-issue` action, even when there are no children yet
|
||||
- the properties pane should also expose a `Sub-issues` section with the same `Add sub-issue` entry point
|
||||
- both entry points should open the existing new-issue dialog in a "create sub-issue" mode
|
||||
- the dialog should only show sub-issue-specific UI when it was opened from one of those entry points
|
||||
|
||||
This is a UI-first change. The backend already supports child issue creation with `parentId`.
|
||||
|
||||
## 2. Current State
|
||||
|
||||
### 2.1 Existing child issue display
|
||||
|
||||
`ui/src/pages/IssueDetail.tsx` already derives `childIssues` by filtering the company issue list on `parentId === issue.id`.
|
||||
|
||||
Current limitation:
|
||||
|
||||
- the `Sub-issues` tab only renders the empty state or the child issue list
|
||||
- there is no action to create a child issue from that tab
|
||||
|
||||
### 2.2 Existing properties pane
|
||||
|
||||
`ui/src/components/IssueProperties.tsx` shows `Blocked by`, `Blocking`, and `Parent`, but it has no sub-issue section or child issue affordance.
|
||||
|
||||
### 2.3 Existing dialog state
|
||||
|
||||
`ui/src/context/DialogContext.tsx` can open the global new-issue dialog with defaults such as status, priority, project, assignee, title, and description.
|
||||
|
||||
Current limitation:
|
||||
|
||||
- there is no way to pass sub-issue context like `parentId`
|
||||
- `ui/src/components/NewIssueDialog.tsx` therefore cannot submit a child issue or render parent-specific context
|
||||
|
||||
### 2.4 Backend contract already exists
|
||||
|
||||
The create-issue validator already accepts `parentId`.
|
||||
|
||||
`server/src/services/issues.ts` already uses:
|
||||
|
||||
- `parentId` for parent-child issue relationships
|
||||
- `parentId` as the default workspace inheritance source when `inheritExecutionWorkspaceFromIssueId` is not provided
|
||||
|
||||
That means the required API and workspace inheritance behavior already exist. No server or schema change is required for the first pass.
|
||||
|
||||
## 3. Proposed Implementation
|
||||
|
||||
## 3.1 Extend dialog defaults for sub-issue context
|
||||
|
||||
Extend `NewIssueDefaults` in `ui/src/context/DialogContext.tsx` with:
|
||||
|
||||
- `parentId?: string`
|
||||
- optional parent display metadata for the dialog header, for example:
|
||||
- `parentIdentifier?: string`
|
||||
- `parentTitle?: string`
|
||||
|
||||
This keeps the dialog self-contained and avoids re-fetching parent context purely for presentation.
|
||||
|
||||
## 3.2 Add issue-detail entry points
|
||||
|
||||
Use `openNewIssue(...)` from `ui/src/pages/IssueDetail.tsx` in two places:
|
||||
|
||||
1. `Sub-issues` tab
|
||||
2. properties pane via props passed into `IssueProperties`
|
||||
|
||||
Both entry points should pass:
|
||||
|
||||
- `parentId: issue.id`
|
||||
- `parentIdentifier: issue.identifier ?? issue.id`
|
||||
- `parentTitle: issue.title`
|
||||
- `projectId: issue.projectId ?? undefined`
|
||||
|
||||
Using the current issue's `projectId` preserves the common expectation that sub-issues stay inside the same project unless the operator changes it in the dialog.
|
||||
|
||||
No special assignee default should be forced in V1.
|
||||
|
||||
## 3.3 Add a dedicated properties-pane section
|
||||
|
||||
Extend `IssueProperties` to accept:
|
||||
|
||||
- `childIssues: Issue[]`
|
||||
- `onCreateSubissue: () => void`
|
||||
|
||||
Render a new `Sub-issues` section near `Blocked by` / `Blocking`:
|
||||
|
||||
- if children exist, show compact links or pills to the existing sub-issues
|
||||
- always show an `Add sub-issue` button
|
||||
|
||||
This keeps the child issue affordance visible in the property area without requiring a generic parent selector.
|
||||
|
||||
## 3.4 Update the sub-issues tab layout
|
||||
|
||||
Refactor the `Sub-issues` tab in `IssueDetail` to render:
|
||||
|
||||
- a small header row with child count
|
||||
- an `Add sub-issue` button
|
||||
- the existing empty state or child issue list beneath it
|
||||
|
||||
This satisfies the requirement that the action is visible whether or not sub-issues already exist.
|
||||
|
||||
## 3.5 Add sub-issue mode to the new-issue dialog
|
||||
|
||||
Update `ui/src/components/NewIssueDialog.tsx` so that when `newIssueDefaults.parentId` is present:
|
||||
|
||||
- the dialog submits `parentId`
|
||||
- the header/button copy can switch to `New sub-issue` / `Create sub-issue`
|
||||
- a compact parent context row is shown, for example `Parent: PAP-1150 add the ability...`
|
||||
|
||||
Important constraint:
|
||||
|
||||
- this parent context row should only render when the dialog was opened with sub-issue defaults
|
||||
- opening the dialog from global create actions should remain unchanged and should not expose a generic parent control
|
||||
|
||||
That preserves the requested UX boundary: sub-issue creation is intentional, not part of the default create-issue surface.
|
||||
|
||||
## 3.6 Query invalidation and refresh behavior
|
||||
|
||||
No new data-fetch path is needed.
|
||||
|
||||
The existing create success handler in `NewIssueDialog` already invalidates:
|
||||
|
||||
- `queryKeys.issues.list(companyId)`
|
||||
- issue-related list badges
|
||||
|
||||
That should be enough for the parent `IssueDetail` view to recompute `childIssues` after creation because it derives children from the company issue list query.
|
||||
|
||||
If the detail page ever moves away from the full company issue list, this should be revisited, but it does not require additional work for the current architecture.
|
||||
|
||||
## 4. Implementation Order
|
||||
|
||||
1. Extend `DialogContext` issue defaults with sub-issue fields.
|
||||
2. Wire `IssueDetail` to open the dialog in sub-issue mode from the `Sub-issues` tab.
|
||||
3. Extend `IssueProperties` to display child issues and the `Add sub-issue` action.
|
||||
4. Update `NewIssueDialog` submission and header UI for sub-issue mode.
|
||||
5. Add UI tests for the new entry points and payload behavior.
|
||||
|
||||
## 5. Testing Plan
|
||||
|
||||
Add focused UI tests covering:
|
||||
|
||||
1. `IssueDetail`
|
||||
- `Sub-issues` tab shows `Add sub-issue` when there are zero children
|
||||
- clicking the action opens the dialog with parent defaults
|
||||
|
||||
2. `IssueProperties`
|
||||
- the properties pane renders the sub-issue section
|
||||
- `Add sub-issue` remains available when there are no child issues
|
||||
|
||||
3. `NewIssueDialog`
|
||||
- when opened with `parentId`, submit payload includes `parentId`
|
||||
- sub-issue-specific copy appears only in that mode
|
||||
- when opened normally, no parent UI is shown and payload is unchanged
|
||||
|
||||
No backend test expansion is required unless implementation discovers a client/server contract gap.
|
||||
|
||||
## 6. Risks And Decisions
|
||||
|
||||
### 6.1 Parent metadata source
|
||||
|
||||
Decision: pass parent label metadata through dialog defaults instead of making `NewIssueDialog` fetch the parent issue.
|
||||
|
||||
Reason:
|
||||
|
||||
- less coupling
|
||||
- no loading state inside the dialog
|
||||
- simpler tests
|
||||
|
||||
### 6.2 Project inheritance
|
||||
|
||||
Decision: prefill `projectId` from the parent issue, but keep it editable.
|
||||
|
||||
Reason:
|
||||
|
||||
- matches expected operator behavior
|
||||
- avoids silently moving a sub-issue outside the current project by default
|
||||
|
||||
### 6.3 Keep parent selection out of the generic dialog
|
||||
|
||||
Decision: do not add a freeform parent picker in this change.
|
||||
|
||||
Reason:
|
||||
|
||||
- the request explicitly wants sub-issue controls only when the flow starts from a sub-issue action
|
||||
- this keeps the default issue creation surface simpler
|
||||
|
||||
## 7. Success Criteria
|
||||
|
||||
This plan is complete when an operator can:
|
||||
|
||||
1. open any issue detail page
|
||||
2. click `Add sub-issue` from either the `Sub-issues` tab or the properties pane
|
||||
3. land in the existing new-issue dialog with clear parent context
|
||||
4. create the child issue and see it appear under the parent without a page reload
|
||||
@@ -0,0 +1,302 @@
|
||||
# 2026-04-07 Issue Detail Speed And Optimistic Inventory
|
||||
|
||||
Status: Proposed
|
||||
Date: 2026-04-07
|
||||
Audience: Product and engineering
|
||||
Related:
|
||||
- `ui/src/pages/IssueDetail.tsx`
|
||||
- `ui/src/components/IssueProperties.tsx`
|
||||
- `ui/src/api/issues.ts`
|
||||
- `ui/src/lib/queryKeys.ts`
|
||||
- `server/src/routes/issues.ts`
|
||||
- `server/src/services/issues.ts`
|
||||
- [PAP-1192](/PAP/issues/PAP-1192)
|
||||
- [PAP-1191](/PAP/issues/PAP-1191)
|
||||
- [PAP-1188](/PAP/issues/PAP-1188)
|
||||
- [PAP-1119](/PAP/issues/PAP-1119)
|
||||
- [PAP-945](/PAP/issues/PAP-945)
|
||||
- [PAP-1165](/PAP/issues/PAP-1165)
|
||||
- [PAP-890](/PAP/issues/PAP-890)
|
||||
- [PAP-254](/PAP/issues/PAP-254)
|
||||
- [PAP-138](/PAP/issues/PAP-138)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This note inventories the Paperclip issues that point to the same UX class of problem:
|
||||
|
||||
- pages feel slow because they over-fetch or refetch too much
|
||||
- actions feel slow because the UI waits for the round trip before reflecting obvious local intent
|
||||
- optimistic updates exist in some places, but not in a consistent system
|
||||
|
||||
The immediate trigger is [PAP-1192](/PAP/issues/PAP-1192): the issue detail page now feels very slow.
|
||||
|
||||
## 2. Short Answer
|
||||
|
||||
The issue detail page is not obviously blocked by one pathological endpoint. The main problem is the shape of the page:
|
||||
|
||||
- `IssueDetail` fans out into many independent queries on mount
|
||||
- some of those queries fetch full company-wide collections for data that is local to one issue
|
||||
- common mutations invalidate almost every issue-related query, which creates avoidable refetch storms
|
||||
- the page has only a minimal top-level `Loading...` fallback and very little staged or sectional loading UX
|
||||
|
||||
Measured against the current assigned issue (`PAP-1191`) on local dev, the slowest single request was the full company issues list:
|
||||
|
||||
- `GET /api/issues/:id` about `18ms`
|
||||
- `GET /api/issues/:id/comments|activity|approvals|attachments` about `6-8ms`
|
||||
- `GET /api/companies/:companyId/agents|projects` about `9-11ms`
|
||||
- `GET /api/companies/:companyId/issues` about `76ms`
|
||||
|
||||
That strongly suggests the current pain is aggregate client fan-out plus over-broad invalidation, not one obviously broken endpoint.
|
||||
|
||||
## 3. Similar Issue Inventory
|
||||
|
||||
## 3.1 Issue-detail and issue-action siblings
|
||||
|
||||
- [PAP-1192](/PAP/issues/PAP-1192): issue page feels like it loads forever
|
||||
- [PAP-1188](/PAP/issues/PAP-1188): assignee changes in the issue properties pane were slow and needed optimistic UI
|
||||
- [PAP-945](/PAP/issues/PAP-945): optimistic comment rendering
|
||||
- [PAP-1003](/PAP/issues/PAP-1003): optimistic comments had duplicate draft/pending behavior
|
||||
- [PAP-947](/PAP/issues/PAP-947): follow-up breakage from optimistic comments
|
||||
- [PAP-254](/PAP/issues/PAP-254): long issue threads become sluggish when adding comments
|
||||
- [PAP-189](/PAP/issues/PAP-189): comment semantics while an issue has a live run
|
||||
|
||||
Pattern: the issue page already has a history of needing both optimistic behavior and bounded thread/loading behavior. `PAP-1192` is the same family, not a new category.
|
||||
|
||||
## 3.2 Inbox and list-view siblings
|
||||
|
||||
- [PAP-1119](/PAP/issues/PAP-1119): optimistic archive had fade-out then snap-back
|
||||
- [PAP-1165](/PAP/issues/PAP-1165): issue search slow
|
||||
- [PAP-890](/PAP/issues/PAP-890): issue search slow, make it very fast
|
||||
- [PAP-138](/PAP/issues/PAP-138): inbox loading feels stuck
|
||||
- [PAP-470](/PAP/issues/PAP-470): create-issue save state felt slow and awkward
|
||||
|
||||
Pattern: Paperclip already has several places where the right fix was "show intent immediately, then reconcile," not "wait for refetch."
|
||||
|
||||
## 3.3 Broader app-loading siblings
|
||||
|
||||
- [PAP-472](/PAP/issues/PAP-472): dashboard charts load very slowly
|
||||
- [PAP-797](/PAP/issues/PAP-797): reduce loading states through static generation/caching where possible
|
||||
- [PAP-799](/PAP/issues/PAP-799): embed company data at build time to eliminate loading states
|
||||
- [PAP-703](/PAP/issues/PAP-703): faster chat and better visual feedback
|
||||
|
||||
Pattern: the product has recurring pressure to reduce blank/loading states across the app, so the issue-detail work should fit that broader direction.
|
||||
|
||||
## 4. Current Issue Detail Findings
|
||||
|
||||
## 4.1 Mount query fan-out is high
|
||||
|
||||
`ui/src/pages/IssueDetail.tsx` mounts all of these data sources up front:
|
||||
|
||||
- issue detail
|
||||
- comments
|
||||
- activity
|
||||
- linked runs
|
||||
- linked approvals
|
||||
- attachments
|
||||
- live runs
|
||||
- active run
|
||||
- full company issues list
|
||||
- agents list
|
||||
- auth session
|
||||
- projects list
|
||||
- feedback votes
|
||||
- instance general settings
|
||||
- plugin slots
|
||||
|
||||
This is too much for the initial view of a single issue.
|
||||
|
||||
## 4.2 The page fetches full company issue data just to derive child issues
|
||||
|
||||
`IssueDetail` currently does:
|
||||
|
||||
- `issuesApi.list(selectedCompanyId!)`
|
||||
- then filters client-side for `parentId === issue.id`
|
||||
|
||||
That is expensive relative to the need.
|
||||
|
||||
Important detail:
|
||||
|
||||
- the server route already supports `parentId`
|
||||
- `server/src/services/issues.ts` already supports `parentId`
|
||||
- but `ui/src/api/issues.ts` does not expose `parentId` in the filter type
|
||||
|
||||
So the client is missing an already-supported narrow query path.
|
||||
|
||||
## 4.3 Comments are still fetched as full-thread loads
|
||||
|
||||
`server/src/routes/issues.ts` and `server/src/services/issues.ts` already support:
|
||||
|
||||
- `after`
|
||||
- `order`
|
||||
- `limit`
|
||||
|
||||
But `IssueDetail` still calls `issuesApi.listComments(issueId)` with no cursor or limit and then re-invalidates the full thread after common comment actions.
|
||||
|
||||
That means we already have the server-side building blocks for incremental comment loading, but the page is not using them.
|
||||
|
||||
## 4.4 Cache invalidation is broader than necessary
|
||||
|
||||
`invalidateIssue()` in `IssueDetail` invalidates:
|
||||
|
||||
- detail
|
||||
- activity
|
||||
- runs
|
||||
- approvals
|
||||
- feedback votes
|
||||
- attachments
|
||||
- documents
|
||||
- live runs
|
||||
- active run
|
||||
- multiple issue collections
|
||||
- sidebar badges
|
||||
|
||||
That is acceptable for correctness, but it is expensive for perceived speed and makes optimistic work feel less stable because the page keeps re-painting from fresh network results.
|
||||
|
||||
## 4.5 Live run state is fetched twice
|
||||
|
||||
The page polls both:
|
||||
|
||||
- `issues.liveRuns(issueId)` every 3s
|
||||
- `issues.activeRun(issueId)` every 3s
|
||||
|
||||
That is duplicate polling for closely related state.
|
||||
|
||||
## 4.6 Properties panel duplicates more list fetching
|
||||
|
||||
`ui/src/components/IssueProperties.tsx` fetches:
|
||||
|
||||
- session
|
||||
- agents list
|
||||
- projects list
|
||||
- labels
|
||||
- and, when the blocker picker opens, the full company issues list
|
||||
|
||||
The page and panel are each doing their own list work instead of sharing a narrower issue-detail data model.
|
||||
|
||||
## 4.7 The perceived loading UX is too thin
|
||||
|
||||
`IssueDetail` only shows:
|
||||
|
||||
- plain `Loading...` while the main issue query is pending
|
||||
|
||||
After that, many sub-sections can appear empty or incomplete until their own queries resolve. That makes the page feel slower than the raw request times suggest.
|
||||
|
||||
## 5. Recommended Plan
|
||||
|
||||
## 5.1 Phase 1: Fix perceived speed first
|
||||
|
||||
Ship UX changes that make the page feel immediate before deeper backend reshaping:
|
||||
|
||||
- replace the plain `Loading...` state with an issue-detail skeleton
|
||||
- give comments, activity, attachments, and sub-issues their own skeleton/empty/loading states
|
||||
- preserve visible stale data during refetch instead of clearing sections
|
||||
- show explicit pending state for local actions that are already optimistic
|
||||
|
||||
Why first:
|
||||
|
||||
- it improves the user-facing feel immediately
|
||||
- it reduces the chance that later data changes still feel slow because the page flashes blank
|
||||
|
||||
## 5.2 Phase 2: Stop fetching the full company issues list for child issues
|
||||
|
||||
Add `parentId` to the `issuesApi.list(...)` filter type and switch `IssueDetail` to:
|
||||
|
||||
- fetch child issues only
|
||||
- stop loading the full company issue collection on page mount
|
||||
|
||||
This is the highest-confidence narrow win because the server path already exists.
|
||||
|
||||
## 5.3 Phase 3: Convert comments to a bounded + incremental model
|
||||
|
||||
Use the existing server support for:
|
||||
|
||||
- latest comment cursor from heartbeat context or issue bootstrap
|
||||
- incremental fetch with `after`
|
||||
- bounded initial fetch with `limit`
|
||||
|
||||
Suggested behavior:
|
||||
|
||||
- first load: fetch the latest N comments
|
||||
- offer `load earlier` for long threads
|
||||
- after posting or on live updates: append incrementally instead of invalidating the whole thread
|
||||
|
||||
This should address the same performance family as [PAP-254](/PAP/issues/PAP-254).
|
||||
|
||||
## 5.4 Phase 4: Reduce duplicate polling and invalidation
|
||||
|
||||
Tighten the runtime side of the page:
|
||||
|
||||
- collapse `liveRuns` and `activeRun` into one client source if possible
|
||||
- stop invalidating unrelated issue collections after mutations that only affect the current issue
|
||||
- merge server responses into cache where we already have enough information
|
||||
|
||||
Examples:
|
||||
|
||||
- posting a comment should not force a broad company issue list refetch unless list-visible metadata changed
|
||||
- attachment changes should not invalidate approvals or unrelated live-run queries
|
||||
|
||||
## 5.5 Phase 5: Consider an issue-detail bootstrap contract
|
||||
|
||||
If the page is still too chatty after the client fixes, add one tailored bootstrap surface for the issue detail page.
|
||||
|
||||
Potential bootstrap payload:
|
||||
|
||||
- issue core data
|
||||
- child issue summaries
|
||||
- latest comment cursor and recent comment page
|
||||
- live run summary
|
||||
- attachment summaries
|
||||
- approval summaries
|
||||
- any lightweight mention/selector metadata truly needed at first paint
|
||||
|
||||
This should happen after the obvious client overfetch fixes, not before.
|
||||
|
||||
## 6. Concrete Opportunities By Surface
|
||||
|
||||
## 6.1 Issue detail page
|
||||
|
||||
- narrow child issue fetch from full list to `parentId`
|
||||
- stage loading by section instead of all-or-nothing perception
|
||||
- bound initial comments payload
|
||||
- reduce duplicate live-run polling
|
||||
- replace broad invalidation with targeted cache writes
|
||||
|
||||
## 6.2 Issue properties panel
|
||||
|
||||
- reuse page-level agents/projects data where possible
|
||||
- fetch blockers lazily and narrowly
|
||||
- keep local optimistic field updates without broad page invalidation
|
||||
|
||||
## 6.3 Thread/comment UX
|
||||
|
||||
- append optimistic comments directly into the visible thread
|
||||
- keep queued/pending comment state stable during reconciliation
|
||||
- fetch only new comments after the last known cursor
|
||||
|
||||
## 6.4 Cross-app optimistic consistency
|
||||
|
||||
The same standards should apply to:
|
||||
|
||||
- issue archive/unarchive
|
||||
- issue property edits
|
||||
- create issue/sub-issue flows
|
||||
- comment posting
|
||||
- attachment/document actions where the local result is obvious
|
||||
|
||||
## 7. Suggested Execution Order
|
||||
|
||||
1. `PAP-1192`: issue-detail skeletons and staged loading
|
||||
2. add `parentId` support to `ui/src/api/issues.ts` and switch child-issue fetching to a narrow query
|
||||
3. move comments to bounded initial load plus incremental updates
|
||||
4. shrink invalidation and polling scope
|
||||
5. only then decide whether a new issue-detail bootstrap endpoint is still needed
|
||||
|
||||
## 8. Success Criteria
|
||||
|
||||
This inventory is successful if the follow-up implementation makes the issue page behave like this:
|
||||
|
||||
1. navigating to an issue shows a shaped skeleton immediately, not plain text
|
||||
2. the page no longer fetches the full company issue list just to render sub-issues
|
||||
3. long threads do not require full-thread fetches on every load or comment mutation
|
||||
4. local actions feel immediate and do not snap back because of broad invalidation
|
||||
5. the issue page feels faster even when absolute backend timings are already reasonable
|
||||
248
doc/plans/2026-04-07-pi-hooks-survey.md
Normal file
248
doc/plans/2026-04-07-pi-hooks-survey.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Pi Hook Survey
|
||||
|
||||
Status: investigation note
|
||||
Date: 2026-04-07
|
||||
|
||||
## Why this exists
|
||||
|
||||
We were asked to find the hook surfaces exposed by `pi` and `pi-mono`, then decide which ideas transfer cleanly into Paperclip.
|
||||
|
||||
This note is based on direct source inspection of:
|
||||
|
||||
- `badlogic/pi` default branch and `pi2` branch
|
||||
- `badlogic/pi-mono` `packages/coding-agent`
|
||||
- current Paperclip plugin and adapter surfaces in this repo
|
||||
|
||||
## Short answer
|
||||
|
||||
- Current `pi` does not expose a comparable extension hook API. What it exposes today is a JSON event stream from `pi-agent`.
|
||||
- `pi-mono` does expose a real extension hook system. It is broad, typed, and intentionally allows mutation of agent/runtime behavior.
|
||||
- Paperclip should copy only the safe subset:
|
||||
- typed event subscriptions
|
||||
- read-only run lifecycle events
|
||||
- explicit worker lifecycle hooks
|
||||
- plugin-to-plugin events
|
||||
- Paperclip should not copy the dangerous subset:
|
||||
- arbitrary mutation hooks on core control-plane decisions
|
||||
- project-local plugin loading
|
||||
- built-in tool shadowing by name collision
|
||||
|
||||
## What `pi` has today
|
||||
|
||||
Current `badlogic/pi` is primarily a GPU pod manager plus a lightweight agent runner. It does not expose a `pi.on(...)`-style extension API like `pi-mono`.
|
||||
|
||||
The closest thing to hooks is the `pi-agent --json` event stream:
|
||||
|
||||
- `session_start`
|
||||
- `user_message`
|
||||
- `assistant_start`
|
||||
- `assistant_message`
|
||||
- `thinking`
|
||||
- `tool_call`
|
||||
- `tool_result`
|
||||
- `token_usage`
|
||||
- `error`
|
||||
- `interrupted`
|
||||
|
||||
That makes `pi` useful as an event producer, but not as a host for third-party runtime interception.
|
||||
|
||||
## What `pi-mono` has
|
||||
|
||||
`pi-mono` exposes a real extension API through `packages/coding-agent/src/core/extensions/types.ts`.
|
||||
|
||||
### Extension event hooks
|
||||
|
||||
Verified `pi.on(...)` hook names:
|
||||
|
||||
- `resources_discover`
|
||||
- `session_start`
|
||||
- `session_before_switch`
|
||||
- `session_before_fork`
|
||||
- `session_before_compact`
|
||||
- `session_compact`
|
||||
- `session_shutdown`
|
||||
- `session_before_tree`
|
||||
- `session_tree`
|
||||
- `context`
|
||||
- `before_provider_request`
|
||||
- `before_agent_start`
|
||||
- `agent_start`
|
||||
- `agent_end`
|
||||
- `turn_start`
|
||||
- `turn_end`
|
||||
- `message_start`
|
||||
- `message_update`
|
||||
- `message_end`
|
||||
- `tool_execution_start`
|
||||
- `tool_execution_update`
|
||||
- `tool_execution_end`
|
||||
- `model_select`
|
||||
- `tool_call`
|
||||
- `tool_result`
|
||||
- `user_bash`
|
||||
- `input`
|
||||
|
||||
### Other extension surfaces
|
||||
|
||||
`pi-mono` extensions can also:
|
||||
|
||||
- `registerTool(...)`
|
||||
- `registerCommand(...)`
|
||||
- `registerShortcut(...)`
|
||||
- `registerFlag(...)`
|
||||
- `registerMessageRenderer(...)`
|
||||
- `registerProvider(...)`
|
||||
- `unregisterProvider(...)`
|
||||
- use an inter-extension event bus via `pi.events`
|
||||
|
||||
### Important behavior
|
||||
|
||||
`pi-mono` hooks are not just observers. Several can actively mutate behavior:
|
||||
|
||||
- `before_agent_start` can rewrite the effective system prompt and inject messages
|
||||
- `context` can replace the message set before an LLM call
|
||||
- `before_provider_request` can rewrite the serialized provider payload
|
||||
- `tool_call` can mutate tool inputs and block execution
|
||||
- `tool_result` can rewrite tool output
|
||||
- `user_bash` can replace shell execution entirely
|
||||
- `input` can transform or fully handle user input before normal processing
|
||||
|
||||
That is a good fit for a local coding harness. It is not automatically a good fit for a company control plane.
|
||||
|
||||
## What Paperclip already has
|
||||
|
||||
Paperclip already has several hook-like surfaces, but they are much narrower and safer:
|
||||
|
||||
- plugin worker lifecycle hooks such as `setup()` and `onHealth()`
|
||||
- declared webhook endpoints for plugins
|
||||
- scheduled jobs
|
||||
- a typed plugin event bus with filtering and plugin namespacing
|
||||
- adapter runtime hooks for logs/status/usage in the run pipeline
|
||||
|
||||
The plugin event bus is already pointed in the right direction:
|
||||
|
||||
- core domain events can be subscribed to
|
||||
- filters are applied server-side
|
||||
- plugin-emitted events are namespaced under `plugin.<pluginId>.*`
|
||||
- plugins do not override core behavior by name collision
|
||||
|
||||
## What transfers well to Paperclip
|
||||
|
||||
These ideas from `pi-mono` fit Paperclip with little conceptual risk:
|
||||
|
||||
### 1. Read-only run lifecycle subscriptions
|
||||
|
||||
Paperclip should continue exposing run and transcript events to plugins, for example:
|
||||
|
||||
- run started / finished
|
||||
- tool started / finished
|
||||
- usage reported
|
||||
- issue comment created
|
||||
|
||||
This matches Paperclip's control-plane posture: observe, react, automate.
|
||||
|
||||
### 2. Plugin-to-plugin events
|
||||
|
||||
Paperclip already has this. It is worth keeping and extending.
|
||||
|
||||
This is the clean replacement for many ad hoc hook chains.
|
||||
|
||||
### 3. Explicit worker lifecycle hooks
|
||||
|
||||
Paperclip already has `setup()` and `onHealth()`. That is the right shape.
|
||||
|
||||
If more lifecycle is needed, it should stay explicit and host-controlled.
|
||||
|
||||
### 4. Trusted adapter-level prompt/runtime middleware
|
||||
|
||||
Some `pi-mono` ideas do belong in Paperclip, but only inside trusted adapter/runtime code:
|
||||
|
||||
- prompt shaping before a run starts
|
||||
- provider request customization
|
||||
- tool execution wrappers for local coding adapters
|
||||
|
||||
This should be an adapter surface, not a general company plugin surface.
|
||||
|
||||
## What should not transfer directly
|
||||
|
||||
These `pi-mono` capabilities are a bad fit for Paperclip core:
|
||||
|
||||
### 1. Arbitrary mutation hooks on control-plane decisions
|
||||
|
||||
Paperclip should not let general plugins rewrite:
|
||||
|
||||
- issue checkout semantics
|
||||
- approval outcomes
|
||||
- budget enforcement
|
||||
- assignment rules
|
||||
- company scoping
|
||||
|
||||
Those are core invariants.
|
||||
|
||||
### 2. Tool shadowing by name collision
|
||||
|
||||
`pi-mono`'s low-friction override model is great for a personal coding harness.
|
||||
|
||||
Paperclip should keep plugin tools namespaced and non-shadowing.
|
||||
|
||||
### 3. Project-local plugin loading
|
||||
|
||||
Paperclip is an operator-controlled control plane. Repo-local plugin auto-loading would make behavior too implicit and too hard to govern.
|
||||
|
||||
### 4. UI-session-specific hooks as first-class product surface
|
||||
|
||||
Hooks like:
|
||||
|
||||
- `session_before_switch`
|
||||
- `session_before_fork`
|
||||
- `session_before_tree`
|
||||
- `model_select`
|
||||
- `input`
|
||||
- `user_bash`
|
||||
|
||||
are tied to `pi-mono` being an interactive terminal coding harness.
|
||||
|
||||
They do not map directly to Paperclip's board-and-issues model.
|
||||
|
||||
## Recommended Paperclip direction
|
||||
|
||||
If we want a "hooks" story inspired by `pi-mono`, it should split into two layers:
|
||||
|
||||
### Layer 1: safe control-plane plugins
|
||||
|
||||
Allowed surfaces:
|
||||
|
||||
- typed domain event subscriptions
|
||||
- jobs
|
||||
- webhooks
|
||||
- plugin-to-plugin events
|
||||
- UI slots and bridge actions
|
||||
- plugin-owned tools and data endpoints
|
||||
|
||||
Disallowed:
|
||||
|
||||
- mutation of core issue/approval/budget invariants
|
||||
|
||||
### Layer 2: trusted runtime middleware
|
||||
|
||||
For adapters and other trusted runtime packages only:
|
||||
|
||||
- prompt assembly hooks
|
||||
- provider payload hooks
|
||||
- tool execution wrappers
|
||||
- transcript rendering helpers
|
||||
|
||||
This is where the best `pi-mono` runtime ideas belong.
|
||||
|
||||
## Bottom line
|
||||
|
||||
If the question is "what hooks do `pi` and `pi-mono` have?":
|
||||
|
||||
- `pi`: JSON output events, not a general extension hook system
|
||||
- `pi-mono`: a broad extension hook API with 27 named event hooks plus tool/command/provider registration
|
||||
|
||||
If the question is "what works for Paperclip too?":
|
||||
|
||||
- yes: typed event subscriptions, worker lifecycle hooks, namespaced plugin events, read-only run lifecycle events
|
||||
- maybe, but trusted-only: prompt/provider/tool middleware around adapter execution
|
||||
- no: arbitrary mutation hooks on control-plane invariants, project-local plugin loading, tool shadowing
|
||||
238
doc/plans/2026-04-08-agent-browser-process-cleanup-plan.md
Normal file
238
doc/plans/2026-04-08-agent-browser-process-cleanup-plan.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# PAP-1231 Agent Browser Process Cleanup Plan
|
||||
|
||||
Status: Proposed
|
||||
Date: 2026-04-08
|
||||
Related issue: `PAP-1231`
|
||||
Audience: Engineering
|
||||
|
||||
## Goal
|
||||
|
||||
Explain why browser processes accumulate during local agent runs and define a cleanup plan that fixes the general process-ownership problem rather than treating `agent-browser` as a one-off.
|
||||
|
||||
## Short answer
|
||||
|
||||
Yes, there is a likely root cause in Paperclip's local execution model.
|
||||
|
||||
Today, heartbeat-run local adapters persist and manage only the top-level spawned PID. Their timeout/cancel path uses direct `child.kill()` semantics. That is weaker than the runtime-service path, which already tracks and terminates whole process groups.
|
||||
|
||||
If Codex, Claude, Cursor, or a skill launched through them starts Chrome or Chromium helpers, Paperclip can lose ownership of those descendants even when it still believes it handled the run correctly.
|
||||
|
||||
## Observed implementation facts
|
||||
|
||||
### 1. Heartbeat-run local adapters track only one PID
|
||||
|
||||
`packages/adapter-utils/src/server-utils.ts`
|
||||
|
||||
- `runChildProcess()` spawns the adapter command and records only `child.pid`
|
||||
- timeout handling sends `SIGTERM` and then `SIGKILL` to the direct child
|
||||
- there is no process-group creation or process-group kill path there today
|
||||
|
||||
`packages/db/src/schema/heartbeat_runs.ts`
|
||||
|
||||
- `heartbeat_runs` stores `process_pid`
|
||||
- there is no persisted `process_group_id`
|
||||
|
||||
`server/src/services/heartbeat.ts`
|
||||
|
||||
- cancellation logic uses the in-memory child handle and calls `child.kill()`
|
||||
- orphaned-run recovery checks whether the recorded direct PID is alive
|
||||
- the recovery model is built around one tracked process, not a descendant tree
|
||||
|
||||
### 2. Workspace runtime already uses stronger ownership
|
||||
|
||||
`server/src/services/workspace-runtime.ts`
|
||||
|
||||
- runtime services are spawned with `detached: process.platform !== "win32"`
|
||||
- the service record stores `processGroupId`
|
||||
- shutdown calls `terminateLocalService()` with group-aware killing
|
||||
|
||||
`server/src/services/local-service-supervisor.ts`
|
||||
|
||||
- `terminateLocalService()` prefers `process.kill(-processGroupId, signal)` on POSIX
|
||||
- it escalates from `SIGTERM` to `SIGKILL`
|
||||
|
||||
This is the clearest internal comparison point: Paperclip already has one local-process subsystem that treats process-group ownership as the right abstraction.
|
||||
|
||||
### 3. The current recovery path explains why leaks would be visible but hard to reason about
|
||||
|
||||
If the direct adapter process exits, hangs, or is cancelled after launching a browser subtree:
|
||||
|
||||
- Paperclip may think it cancelled the run because the parent process is gone
|
||||
- descendant Chrome helpers may still be running
|
||||
- orphan recovery has no persisted process-group identity to reconcile or reap later
|
||||
|
||||
That makes the failure look like an `agent-browser` problem when the more general bug is "executor descendants are not owned strongly enough."
|
||||
|
||||
## Why `agent-browser` makes the problem obvious
|
||||
|
||||
Inference:
|
||||
|
||||
- Chromium is intentionally multi-process
|
||||
- browser automation often leaves a browser process plus renderer, GPU, utility, and crashpad/helper children
|
||||
- skills that open browsers repeatedly amplify the symptom because each run can produce several descendant processes
|
||||
|
||||
So `agent-browser` is probably not the root cause. It is the workload that exposes the weak ownership model fastest.
|
||||
|
||||
## Success condition
|
||||
|
||||
This work is successful when Paperclip can:
|
||||
|
||||
1. start a local adapter run and own the full descendant tree it created
|
||||
2. cancel, timeout, or recover that run without leaving Chrome descendants behind on POSIX
|
||||
3. detect and clean up stale local descendants after server restarts
|
||||
4. expose enough metadata that operators can see which run owns which spawned process tree
|
||||
|
||||
## Non-goals
|
||||
|
||||
Do not:
|
||||
|
||||
- special-case `agent-browser` only
|
||||
- depend on manual `pkill chrome` cleanup as the primary fix
|
||||
- require every skill author to add bespoke browser teardown logic before Paperclip can clean up correctly
|
||||
- change remote/http adapter behavior as part of the first pass
|
||||
|
||||
## Proposed plan
|
||||
|
||||
### Phase 0: reproduce and instrument
|
||||
|
||||
Objective:
|
||||
|
||||
- make the leak measurable from Paperclip's side before changing execution semantics
|
||||
|
||||
Work:
|
||||
|
||||
- add a reproducible local test script or fixture that launches a child process which itself launches descendants and ignores normal parent exit
|
||||
- capture parent PID, descendant PIDs, and run ID in logs during local adapter execution
|
||||
- document current behavior separately for:
|
||||
- normal completion
|
||||
- timeout
|
||||
- explicit cancellation
|
||||
- server restart during run
|
||||
|
||||
Deliverable:
|
||||
|
||||
- one short repro note attached to the implementation issue or child issue
|
||||
|
||||
### Phase 1: give heartbeat-run local adapters process-group ownership
|
||||
|
||||
Objective:
|
||||
|
||||
- align adapter-run execution with the stronger runtime-service model
|
||||
|
||||
Work:
|
||||
|
||||
- update `runChildProcess()` to create a dedicated process group on POSIX
|
||||
- persist both:
|
||||
- direct PID
|
||||
- process-group ID
|
||||
- update the run cancellation and timeout paths to kill the group first, then escalate
|
||||
- keep direct-PID fallback behavior for platforms where group kill is not available
|
||||
|
||||
Likely touched surfaces:
|
||||
|
||||
- `packages/adapter-utils/src/server-utils.ts`
|
||||
- `packages/db/src/schema/heartbeat_runs.ts`
|
||||
- `packages/shared/src/types/heartbeat.ts`
|
||||
- `server/src/services/heartbeat.ts`
|
||||
|
||||
Important design choice:
|
||||
|
||||
- use the same ownership model for all local child-process adapters, not just Codex or Claude
|
||||
|
||||
### Phase 2: make restart recovery group-aware
|
||||
|
||||
Objective:
|
||||
|
||||
- prevent stale descendants from surviving server crashes or restarts indefinitely
|
||||
|
||||
Work:
|
||||
|
||||
- teach orphan reconciliation to inspect the persisted process-group ID, not only the direct PID
|
||||
- if the direct parent is gone but the group still exists, mark the run as detached-orphaned with clearer metadata
|
||||
- decide whether restart recovery should:
|
||||
- adopt the still-running group, or
|
||||
- terminate it as unrecoverable
|
||||
|
||||
Recommendation:
|
||||
|
||||
- for heartbeat runs, prefer terminating unrecoverable orphan groups rather than adopting them unless we can prove the adapter session remains safe and observable
|
||||
|
||||
Reason:
|
||||
|
||||
- runtime services are long-lived and adoptable
|
||||
- heartbeat runs are task executions with stricter audit and cancellation semantics
|
||||
|
||||
### Phase 3: add operator-visible cleanup tools
|
||||
|
||||
Objective:
|
||||
|
||||
- make the system diagnosable when ownership still fails
|
||||
|
||||
Work:
|
||||
|
||||
- surface the tracked process metadata in run details or debug endpoints
|
||||
- add a control-plane cleanup action or CLI utility for stale local run processes owned by Paperclip
|
||||
- scope cleanup by run/agent/company instead of broad browser-name matching
|
||||
|
||||
This should replace ad hoc scripts as the general-purpose escape hatch.
|
||||
|
||||
### Phase 4: cover platform and regression cases
|
||||
|
||||
Objective:
|
||||
|
||||
- keep the fix from regressing and define platform behavior explicitly
|
||||
|
||||
Tests to add:
|
||||
|
||||
- unit tests around process-group-aware cancellation in adapter execution utilities
|
||||
- heartbeat recovery tests for:
|
||||
- surviving descendant tree after parent loss
|
||||
- timeout cleanup
|
||||
- cancellation cleanup
|
||||
- platform-conditional behavior notes for Windows, where negative-PID group kill does not apply
|
||||
|
||||
## Recommended first implementation slice
|
||||
|
||||
The first shipping slice should be narrow:
|
||||
|
||||
1. introduce process-group ownership for local heartbeat-run adapters on POSIX
|
||||
2. persist group metadata on `heartbeat_runs`
|
||||
3. switch timeout/cancel paths from direct-child kill to group kill
|
||||
4. add one regression test that proves descendants die with the parent run
|
||||
|
||||
That should address the main Chrome accumulation path without taking on the full restart-recovery design in the same patch.
|
||||
|
||||
## Risks
|
||||
|
||||
### 1. Over-killing unrelated processes
|
||||
|
||||
If process-group boundaries are created incorrectly, cleanup could terminate more than the run owns.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- create a fresh process group only for the spawned adapter command
|
||||
- persist and target that exact group
|
||||
|
||||
### 2. Cross-platform differences
|
||||
|
||||
Windows does not support the POSIX negative-PID kill pattern used elsewhere in the repo.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- ship POSIX-first
|
||||
- keep direct-child fallback on Windows
|
||||
- document Windows as partial until job-object or equivalent handling is designed
|
||||
|
||||
### 3. Session recovery complexity
|
||||
|
||||
Adopting a still-running orphaned group may look attractive but can break observability if stdout/stderr pipes are already gone.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- default to deterministic cleanup for heartbeat runs unless adoption is explicitly proven safe
|
||||
|
||||
## Recommendation
|
||||
|
||||
Treat this as a Paperclip executor ownership bug, not an `agent-browser` bug.
|
||||
|
||||
`agent-browser` should remain a useful repro case, but the implementation should be shared across all local child-process adapters so any descendant process tree spawned by Codex, Claude, Cursor, Gemini, Pi, or OpenCode is owned and cleaned up consistently.
|
||||
261
doc/plans/2026-04-08-agent-os-follow-up-plan.md
Normal file
261
doc/plans/2026-04-08-agent-os-follow-up-plan.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# PAP-1229 Agent OS Follow-up Plan
|
||||
|
||||
Date: 2026-04-08
|
||||
Related issue: `PAP-1229`
|
||||
Companion analysis: `doc/plans/2026-04-08-agent-os-technical-report.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Turn the `agent-os` research into a low-risk Paperclip execution plan that preserves Paperclip's control-plane model while testing the few runtime ideas that appear worth adopting.
|
||||
|
||||
## Decision summary
|
||||
|
||||
Paperclip should not absorb `agent-os` as a product model or orchestration layer.
|
||||
|
||||
Paperclip should evaluate `agent-os` in three narrow areas:
|
||||
|
||||
1. optional agent runtime for selected local adapters
|
||||
2. capability-based runtime permission vocabulary
|
||||
3. snapshot-backed disposable execution roots
|
||||
|
||||
Everything else should stay out of scope unless those three experiments produce strong evidence.
|
||||
|
||||
## Success condition
|
||||
|
||||
This work is successful when Paperclip has:
|
||||
|
||||
- a clear yes/no answer on whether `agent-os` is worth supporting as an execution substrate
|
||||
- a concrete adapter/runtime experiment with measurable results
|
||||
- a proposed runtime capability model that fits current Paperclip adapters
|
||||
- a clear decision on whether snapshot-backed execution roots are worth integrating
|
||||
|
||||
## Non-goals
|
||||
|
||||
Do not:
|
||||
|
||||
- replace Paperclip heartbeats, issues, comments, approvals, or budgets with `agent-os` primitives
|
||||
- introduce Rust/sidecar requirements for all local execution paths
|
||||
- migrate all adapters at once
|
||||
- add runtime workflow/queue abstractions to Paperclip core
|
||||
|
||||
## Existing Paperclip integration points
|
||||
|
||||
The plan should stay anchored to these existing surfaces:
|
||||
|
||||
- `packages/adapter-utils/src/types.ts`
|
||||
- adapter contract, runtime service reporting, session metadata, and capability normalization targets
|
||||
- `server/src/services/heartbeat.ts`
|
||||
- execution entry point, log capture, issue comment summaries, and cost reporting
|
||||
- `server/src/services/execution-workspaces.ts`
|
||||
- current workspace lifecycle and git-oriented cleanup/readiness model
|
||||
- `server/src/services/plugin-loader.ts`
|
||||
- typed host capability boundary and extension loading patterns
|
||||
- local adapter implementations in `packages/adapters/*/src/server/`
|
||||
- current execution behavior to compare against an `agent-os`-backed path
|
||||
|
||||
## Phase plan
|
||||
|
||||
### Phase 0: constraints and experiment design
|
||||
|
||||
Objective:
|
||||
|
||||
- make the evaluation falsifiable before writing integration code
|
||||
|
||||
Deliverables:
|
||||
|
||||
- short experiment brief added to this document or a child issue
|
||||
- chosen first runtime target: `pi_local` or `opencode_local`
|
||||
- baseline metrics definition
|
||||
|
||||
Questions to lock down:
|
||||
|
||||
- what exact developer experience should improve
|
||||
- what security/isolation property we expect to gain
|
||||
- what failure modes are unacceptable
|
||||
- whether the prototype is adapter-only or a deeper internal runtime abstraction spike
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- a single first target chosen
|
||||
- measurable comparison criteria agreed on
|
||||
|
||||
Recommended metrics:
|
||||
|
||||
- cold start latency
|
||||
- session resume reliability across heartbeats
|
||||
- transcript/log quality
|
||||
- implementation complexity
|
||||
- operational complexity on local dev machines
|
||||
|
||||
### Phase 1: `agentos_local` spike
|
||||
|
||||
Objective:
|
||||
|
||||
- prove that Paperclip can drive one local agent through an `agent-os` runtime without breaking heartbeat semantics
|
||||
|
||||
Suggested scope:
|
||||
|
||||
- implement a new experimental adapter, `agentos_local`, or a feature-flagged runtime path under one existing adapter
|
||||
- start with `pi_local` or `opencode_local`
|
||||
- keep Paperclip's existing heartbeat, issue, workspace, and comment flow authoritative
|
||||
|
||||
Minimum implementation shape:
|
||||
|
||||
- adapter accepts model/runtime config
|
||||
- `server/src/services/heartbeat.ts` still owns run lifecycle
|
||||
- execution result still maps into existing `AdapterExecutionResult`
|
||||
- session state still fits current `sessionParams` / `sessionDisplayId` flow
|
||||
|
||||
What to verify:
|
||||
|
||||
- checkout and heartbeat flow still work end to end
|
||||
- resume across multiple heartbeats works
|
||||
- logs/transcripts remain readable in the UI
|
||||
- failure paths surface cleanly in issue comments and run logs
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- one agent type can run reliably through the new path
|
||||
- documented comparison against the existing local adapter path
|
||||
- explicit recommendation: continue, pause, or abandon
|
||||
|
||||
### Phase 2: capability-based runtime permissions
|
||||
|
||||
Objective:
|
||||
|
||||
- introduce a Paperclip-native capability vocabulary without coupling the product to `agent-os`
|
||||
|
||||
Suggested scope:
|
||||
|
||||
- extend adapter config schema vocabulary for runtime permissions
|
||||
- prototype normalized capabilities such as:
|
||||
- `fs.read`
|
||||
- `fs.write`
|
||||
- `network.fetch`
|
||||
- `network.listen`
|
||||
- `process.spawn`
|
||||
- `env.read`
|
||||
|
||||
Integration targets:
|
||||
|
||||
- `packages/adapter-utils/src/types.ts`
|
||||
- adapter config-schema support
|
||||
- server-side runtime config validation
|
||||
- future board-facing UI for permissions, if needed
|
||||
|
||||
What to avoid:
|
||||
|
||||
- building a full human policy UI before the vocabulary is proven useful
|
||||
- forcing every adapter to implement capability enforcement immediately
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- documented capability schema
|
||||
- one adapter path using it meaningfully
|
||||
- clear compatibility story for non-`agent-os` adapters
|
||||
|
||||
### Phase 3: snapshot-backed execution root experiment
|
||||
|
||||
Objective:
|
||||
|
||||
- determine whether a layered/snapshotted root model improves some Paperclip workloads
|
||||
|
||||
Suggested scope:
|
||||
|
||||
- evaluate it only for disposable or non-repo-heavy tasks first
|
||||
- keep git worktree-based repo editing as the default for codebase tasks
|
||||
|
||||
Promising use cases:
|
||||
|
||||
- routine-style runs
|
||||
- ephemeral preview/test environments
|
||||
- isolated document/artifact generation
|
||||
- tasks that do not need full git history or branch semantics
|
||||
|
||||
Integration targets:
|
||||
|
||||
- `server/src/services/execution-workspaces.ts`
|
||||
- workspace realization paths called from `server/src/services/heartbeat.ts`
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- clear statement on which workload classes benefit
|
||||
- clear statement on which workloads should stay on worktrees
|
||||
- go/no-go decision for broader implementation
|
||||
|
||||
### Phase 4: typed host tool evaluation
|
||||
|
||||
Objective:
|
||||
|
||||
- identify where Paperclip should prefer explicit typed tools over ambient shell access
|
||||
|
||||
Suggested scope:
|
||||
|
||||
- compare `agent-os` host-toolkit ideas with existing plugin and runtime-service surfaces
|
||||
- choose 1-2 sensitive operations that should become typed tools
|
||||
|
||||
Good candidates:
|
||||
|
||||
- git metadata/status inspection
|
||||
- runtime service inspection
|
||||
- deployment/preview status retrieval
|
||||
- generated artifact publishing
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- one concrete proposal for typed-tool adoption in Paperclip
|
||||
- clear statement on whether this belongs in plugins, adapters, or core services
|
||||
|
||||
## Recommended sequencing
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Phase 0
|
||||
2. Phase 1
|
||||
3. Phase 2
|
||||
4. Phase 3
|
||||
5. Phase 4
|
||||
|
||||
Reasoning:
|
||||
|
||||
- Phase 1 is the fastest way to invalidate or validate the entire `agent-os` direction
|
||||
- Phase 2 is valuable even if Phase 1 is abandoned
|
||||
- Phase 3 should wait until there is confidence that the runtime approach is operationally worthwhile
|
||||
- Phase 4 is useful independently but should be informed by what Phase 1 and Phase 2 expose
|
||||
|
||||
## Risks
|
||||
|
||||
### Technical risk
|
||||
|
||||
- `agent-os` introduces Rust sidecar and packaging complexity that may outweigh runtime benefits
|
||||
|
||||
### Product risk
|
||||
|
||||
- runtime experimentation could blur the boundary between Paperclip as control plane and Paperclip as execution platform
|
||||
|
||||
### Integration risk
|
||||
|
||||
- session semantics, log formatting, and failure behavior may degrade relative to current local adapters
|
||||
|
||||
### Scope risk
|
||||
|
||||
- a small runtime spike could expand into an adapter-system rewrite if not kept tightly bounded
|
||||
|
||||
## Guardrails
|
||||
|
||||
To keep this effort controlled:
|
||||
|
||||
- keep all experiments behind a clearly experimental adapter or feature flag
|
||||
- do not change issue/comment/approval/budget semantics to suit the runtime
|
||||
- measure against current local adapters instead of judging in isolation
|
||||
- stop after Phase 1 if the operational burden is already clearly too high
|
||||
|
||||
## Proposed next action
|
||||
|
||||
The next concrete action should be a small implementation spike issue:
|
||||
|
||||
- title: `Prototype experimental agentos_local runtime for one local adapter`
|
||||
- target adapter: `opencode_local` unless `pi_local` is materially easier
|
||||
- expected output: code spike, short verification notes, and a continue/stop recommendation
|
||||
|
||||
If leadership wants planning only and no spike yet, this document is the handoff artifact for that decision.
|
||||
397
doc/plans/2026-04-08-agent-os-technical-report.md
Normal file
397
doc/plans/2026-04-08-agent-os-technical-report.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# Agent OS Technical Report for Paperclip
|
||||
|
||||
Date: 2026-04-08
|
||||
Analyzed upstream: `rivet-dev/agent-os` at commit `0063cdccd1dcb1c8e211670cd05482d70d26a5c4` (`0063cdc`), dated 2026-04-06
|
||||
|
||||
## Executive summary
|
||||
|
||||
`agent-os` is not a competitor to Paperclip's core product. It is an execution substrate: an embedded, VM-like runtime for agents, tools, filesystems, and session orchestration. Paperclip is a control plane: company scoping, task hierarchy, approvals, budgets, activity logs, workspaces, and governance.
|
||||
|
||||
The strongest takeaway is not "copy agent-os wholesale." The strongest takeaway is that Paperclip could selectively use its runtime ideas to improve local agent execution safety, reproducibility, and portability while keeping all company/task/governance logic in Paperclip.
|
||||
|
||||
My recommendation is:
|
||||
|
||||
1. Do not merge agent-os concepts into the Paperclip core product model.
|
||||
2. Do evaluate an optional `agentos_local` execution adapter or internal runtime experiment.
|
||||
3. Borrow a few design patterns aggressively:
|
||||
- layered/snapshotted execution filesystems
|
||||
- explicit capability-based runtime permissions
|
||||
- a better host-tools bridge for controlled tool execution
|
||||
- a normalized session capability model for agent adapters
|
||||
4. Do not import its workflow/cron/queue abstractions into Paperclip core until they are reconciled with Paperclip's issue/comment/governance model.
|
||||
|
||||
## What agent-os actually is
|
||||
|
||||
From the repo layout and implementation, `agent-os` is a mixed TypeScript/Rust system that provides:
|
||||
|
||||
- an `AgentOs` TypeScript API for creating isolated agent VMs
|
||||
- a Rust kernel/sidecar that virtualizes filesystem, processes, PTYs, pipes, permissions, and networking
|
||||
- an ACP-based session model for agent runtimes such as Pi, OpenCode, and Claude-style adapters
|
||||
- a registry of WASM command packages and mount plugins
|
||||
- optional host toolkits, cron scheduling, and filesystem mounts
|
||||
|
||||
The repo is substantial already:
|
||||
|
||||
- monorepo with `packages/`, `crates/`, and `registry/`
|
||||
- roughly 1,200 files just across `packages/`, `crates/`, and `registry/`
|
||||
- mixed implementation model: TypeScript public API plus Rust kernel/sidecar internals
|
||||
|
||||
## Architecture notes
|
||||
|
||||
### 1. Public runtime surface
|
||||
|
||||
The main API lives in `packages/core/src/agent-os.ts` and exports an `AgentOs` class with methods such as:
|
||||
|
||||
- `create()`
|
||||
- `createSession()`
|
||||
- `prompt()`
|
||||
- `exec()`
|
||||
- `spawn()`
|
||||
- `snapshotRootFilesystem()`
|
||||
- cron scheduling helpers
|
||||
|
||||
This is an execution API, not a coordination API.
|
||||
|
||||
### 2. Virtualized kernel model
|
||||
|
||||
The kernel is implemented in Rust under `crates/kernel/src/`. It models:
|
||||
|
||||
- virtual filesystem
|
||||
- process table
|
||||
- PTYs and pipes
|
||||
- resource accounting
|
||||
- permissioned filesystem access
|
||||
- network permission checks
|
||||
|
||||
That gives `agent-os` a much stronger isolation story than Paperclip's current "launch a host CLI in a workspace" local adapter approach.
|
||||
|
||||
### 3. Layered filesystem and snapshots
|
||||
|
||||
The filesystem design is one of the most reusable ideas. `agent-os` uses:
|
||||
|
||||
- a bundled base filesystem
|
||||
- a writable overlay
|
||||
- optional mounted filesystems
|
||||
- snapshot export/import for reusing root states
|
||||
|
||||
This is cleaner than treating every execution workspace as a mutable checkout plus ad hoc cleanup. It enables reproducible starting states and cheap isolation.
|
||||
|
||||
### 4. Capability-based permissions
|
||||
|
||||
The kernel-level permission vocabulary is strong and concrete:
|
||||
|
||||
- filesystem operations
|
||||
- network operations
|
||||
- child-process execution
|
||||
- environment access
|
||||
|
||||
The Rust kernel defaults are deny-oriented, but the high-level JS API currently serializes permissive defaults unless the caller provides a policy. That is an important nuance: the primitive is security-minded, but the product surface is still convenience-first.
|
||||
|
||||
### 5. Host-tools bridge
|
||||
|
||||
`agent-os` exposes host-side tools via a toolkit abstraction (`hostTool`, `toolKit`) and a local RPC bridge. This is a strong pattern because it gives the agent explicit, typed tools rather than ambient shell access to everything on the host.
|
||||
|
||||
### 6. ACP session abstraction
|
||||
|
||||
The session model is more uniform than most agent wrappers. It includes:
|
||||
|
||||
- capabilities
|
||||
- mode/config options
|
||||
- permission requests
|
||||
- sequenced session events
|
||||
- JSON-RPC transport through ACP adapters
|
||||
|
||||
This is directly relevant to Paperclip because our adapter layer still normalizes each CLI agent in a fairly bespoke way.
|
||||
|
||||
## Paperclip anchor points
|
||||
|
||||
The most relevant current Paperclip surfaces for any future `agent-os` integration are:
|
||||
|
||||
- `packages/adapter-utils/src/types.ts`
|
||||
- shared adapter contract, session metadata, runtime service reporting, environment tests, and optional `detectModel()`
|
||||
- `server/src/services/heartbeat.ts`
|
||||
- heartbeat execution, adapter invocation, cost capture, workspace realization, and issue-comment summaries
|
||||
- `server/src/services/execution-workspaces.ts`
|
||||
- execution workspace lifecycle and git readiness/cleanup logic
|
||||
- `server/src/services/plugin-loader.ts`
|
||||
- dynamic plugin activation, host capability boundaries, and runtime extension loading
|
||||
- local adapters such as `packages/adapters/codex-local/src/server/execute.ts` and peers
|
||||
- current host-CLI execution model that an `agent-os` runtime experiment would complement or replace for selected agents
|
||||
|
||||
## What Paperclip can learn from it
|
||||
|
||||
### 1. A safer local execution substrate
|
||||
|
||||
Paperclip's local adapters currently run host CLIs in managed workspaces and rely on adapter-specific behavior plus process-level controls. That is pragmatic, but weakly isolated.
|
||||
|
||||
`agent-os` shows a path toward:
|
||||
|
||||
- running local agent tooling in a constrained runtime
|
||||
- applying explicit network/filesystem/env policies
|
||||
- reducing accidental host leakage
|
||||
- making adapter behavior more portable across machines
|
||||
|
||||
Best use in Paperclip:
|
||||
|
||||
- as an optional runtime beneath local adapters
|
||||
- or as a new adapter family for agents that can run inside ACP-compatible `agent-os` sessions
|
||||
|
||||
This fits Paperclip because it improves execution safety without changing the control-plane model.
|
||||
|
||||
### 2. Snapshotted execution roots instead of only mutable workspaces
|
||||
|
||||
Paperclip already has strong execution-workspace concepts, but they are repo/worktree-centric. `agent-os` adds a stronger "start from known lower layers, write into a disposable upper layer" model.
|
||||
|
||||
That could improve:
|
||||
|
||||
- reproducible issue starts
|
||||
- disposable task sandboxes
|
||||
- faster reset/cleanup
|
||||
- "resume from snapshot" behavior for recurring routines
|
||||
- safe preview environments for risky agent operations
|
||||
|
||||
This is especially interesting for tasks that do not need a full git worktree.
|
||||
|
||||
### 3. A capability vocabulary for runtime governance
|
||||
|
||||
Paperclip has governance at the company/task level:
|
||||
|
||||
- approvals
|
||||
- budgets
|
||||
- activity logs
|
||||
- actor permissions
|
||||
- company scoping
|
||||
|
||||
It has less structure at the runtime capability level. `agent-os` offers a clear vocabulary that Paperclip could adopt even without adopting the runtime itself:
|
||||
|
||||
- `fs.read`, `fs.write`, `fs.mount_sensitive`
|
||||
- `network.fetch`, `network.http`, `network.listen`, `network.dns`
|
||||
- child process execution
|
||||
- env access
|
||||
|
||||
That vocabulary would improve:
|
||||
|
||||
- adapter configuration schemas
|
||||
- policy UIs
|
||||
- execution review surfaces
|
||||
- future approval gates for governed actions
|
||||
|
||||
### 4. Typed host tools instead of shelling out for everything
|
||||
|
||||
Paperclip's plugin system and adapters already have the beginnings of a controlled extension surface. `agent-os` reinforces the value of exposing capabilities as typed tools rather than raw shell access.
|
||||
|
||||
Concrete Paperclip uses:
|
||||
|
||||
- board-approved toolkits for sensitive operations
|
||||
- company-scoped service tools
|
||||
- plugin-defined tools with explicit schemas
|
||||
- safer execution for common actions like git metadata inspection, preview lookups, deployment status checks, or document generation
|
||||
|
||||
This aligns well with Paperclip's governance story.
|
||||
|
||||
### 5. Better adapter normalization around sessions and capabilities
|
||||
|
||||
Paperclip's adapter contract already supports execution results, session params, environment tests, skill syncing, quota windows, and optional `detectModel()`. But much of the per-agent behavior is still adapter-specific.
|
||||
|
||||
`agent-os` suggests a cleaner normalization target:
|
||||
|
||||
- a standard capability map
|
||||
- a consistent event stream model
|
||||
- explicit mode/config surfaces
|
||||
- explicit permission request semantics
|
||||
|
||||
Paperclip does not need ACP everywhere, but it would benefit from a more formal internal session capability model inspired by this.
|
||||
|
||||
### 6. On-demand heavy sandbox escalation
|
||||
|
||||
One of the best architectural choices in `agent-os` is that it does not pretend every workload fits the lightweight runtime. It has a sandbox extension for workloads that need a fuller environment.
|
||||
|
||||
Paperclip can adopt that philosophy directly:
|
||||
|
||||
- lightweight execution by default
|
||||
- escalate to full worktree / container / remote sandbox only when needed
|
||||
- keep the escalation explicit in the issue/run model
|
||||
|
||||
That is better than forcing all tasks into the heaviest environment up front.
|
||||
|
||||
## What does not fit Paperclip well
|
||||
|
||||
### 1. Its built-in orchestration primitives overlap the wrong layer
|
||||
|
||||
`agent-os` includes cron/session/workflow style primitives inside the runtime package. Paperclip already has higher-level orchestration concepts:
|
||||
|
||||
- issues/comments
|
||||
- heartbeat runs
|
||||
- approvals
|
||||
- company/org structure
|
||||
- execution workspaces
|
||||
- budget enforcement
|
||||
|
||||
If Paperclip copied `agent-os` cron/workflow/queue ideas directly into core, we would likely duplicate orchestration across two layers. That would blur ownership and make debugging harder.
|
||||
|
||||
Paperclip should keep orchestration authoritative at the control-plane layer.
|
||||
|
||||
### 2. It is not company-scoped or governance-native
|
||||
|
||||
`agent-os` is runtime-first, not company-first. It has no native concepts for:
|
||||
|
||||
- company boundaries
|
||||
- board/operator actor types
|
||||
- audit logs for business actions
|
||||
- issue hierarchy
|
||||
- approval routing
|
||||
- budget hard-stop behavior
|
||||
|
||||
Those are Paperclip's differentiators. They should not be displaced by runtime abstractions.
|
||||
|
||||
### 3. It introduces meaningful implementation complexity
|
||||
|
||||
Adopting `agent-os` deeply would add:
|
||||
|
||||
- Rust build/runtime complexity
|
||||
- sidecar lifecycle management
|
||||
- new failure modes across JS/Rust boundaries
|
||||
- more packaging and platform compatibility work
|
||||
- another abstraction layer for debugging already-complex local adapters
|
||||
|
||||
This is justified only if we want stronger local isolation or portability. It is not justified as a general refactor.
|
||||
|
||||
### 4. Its security model is not a drop-in governance solution
|
||||
|
||||
The permission model is good, but it is low-level. Paperclip would still need to answer:
|
||||
|
||||
- who can authorize a capability
|
||||
- how approval decisions are logged
|
||||
- how policies are scoped by company/project/issue/agent
|
||||
- how runtime permissions interact with budgets and task status
|
||||
|
||||
In other words, `agent-os` can supply enforcement primitives, not the control policy system itself.
|
||||
|
||||
### 5. The agent compatibility story is still selective
|
||||
|
||||
The repo is explicit that some runtimes are planned, partial, or still being adapted. In practice this means:
|
||||
|
||||
- good ideas for ACP-native or compatible agents
|
||||
- less certainty for every CLI agent we support today
|
||||
- real integration work for Codex/Cursor/Gemini-style Paperclip adapters
|
||||
|
||||
So the main near-term value is not universal replacement. It is selective use where compatibility is strong.
|
||||
|
||||
## Concrete recommendations for Paperclip
|
||||
|
||||
### Recommendation A: prototype an optional `agentos_local` adapter
|
||||
|
||||
This is the highest-value experiment.
|
||||
|
||||
Goal:
|
||||
|
||||
- run one supported agent type inside `agent-os`
|
||||
- keep Paperclip heartbeat/task/workspace/budget logic unchanged
|
||||
- evaluate startup time, isolation, transcript quality, and operational complexity
|
||||
|
||||
Good first target:
|
||||
|
||||
- `pi_local` or `opencode_local`
|
||||
|
||||
Why not start with Codex:
|
||||
|
||||
- Paperclip's Codex adapter is already important and carries repo-specific behavior
|
||||
- `agent-os`'s Codex story is present in the registry/docs, but the safest path is to validate the runtime on a less central adapter first
|
||||
|
||||
Success criteria:
|
||||
|
||||
- heartbeat can invoke the adapter reliably
|
||||
- session resume works across heartbeats
|
||||
- Paperclip still records logs, summaries, cost metadata, and issue comments normally
|
||||
- runtime permissions can be configured without breaking common tasks
|
||||
|
||||
### Recommendation B: adopt capability vocabulary into adapter configs
|
||||
|
||||
Even without using `agent-os`, Paperclip should consider standardizing adapter/runtime permissions around a vocabulary like:
|
||||
|
||||
- filesystem
|
||||
- network
|
||||
- subprocess/tool execution
|
||||
- environment access
|
||||
|
||||
This would improve:
|
||||
|
||||
- schema-driven adapter UIs
|
||||
- future approvals
|
||||
- observability
|
||||
- policy portability across adapters
|
||||
|
||||
### Recommendation C: explore snapshot-backed execution workspaces
|
||||
|
||||
Paperclip should evaluate whether some execution workspaces can be backed by:
|
||||
|
||||
- a reusable lower snapshot
|
||||
- a disposable upper layer
|
||||
- optional mounts for project data or artifacts
|
||||
|
||||
This is most valuable for:
|
||||
|
||||
- non-repo tasks
|
||||
- repeatable routines
|
||||
- preview/test environments
|
||||
- isolation-heavy local execution
|
||||
|
||||
It is less urgent for full repo editing flows that already benefit from git worktrees.
|
||||
|
||||
### Recommendation D: strengthen typed tool surfaces
|
||||
|
||||
Paperclip plugins and adapters should continue moving toward explicit typed tools over ad hoc shell access. `agent-os` confirms that this is the right direction.
|
||||
|
||||
This is a good fit for:
|
||||
|
||||
- plugin tools
|
||||
- workspace runtime services
|
||||
- governed operations that need approval or auditability
|
||||
|
||||
### Recommendation E: do not import runtime-level workflows into Paperclip core
|
||||
|
||||
Paperclip should not copy `agent-os` cron/workflow/queue concepts into core orchestration yet.
|
||||
|
||||
If we want them later, they must map cleanly onto:
|
||||
|
||||
- issues
|
||||
- comments
|
||||
- heartbeats
|
||||
- approvals
|
||||
- budgets
|
||||
- activity logs
|
||||
|
||||
Without that mapping, they would create a second orchestration system inside the product.
|
||||
|
||||
## A practical integration map
|
||||
|
||||
### Best near-term fits
|
||||
|
||||
- optional local adapter runtime
|
||||
- runtime capability schema
|
||||
- typed host-tool ideas for plugins/adapters
|
||||
- snapshot ideas for disposable execution roots
|
||||
|
||||
### Medium-term fits
|
||||
|
||||
- stronger session capability normalization across adapters
|
||||
- policy-aware runtime permission UI
|
||||
- selective ACP-inspired event normalization
|
||||
|
||||
### Poor fits right now
|
||||
|
||||
- moving Paperclip orchestration into agent-os workflows
|
||||
- replacing company/task/governance models with runtime constructs
|
||||
- making Rust sidecars a mandatory dependency for all local execution
|
||||
|
||||
## Bottom line
|
||||
|
||||
`agent-os` is useful to Paperclip as an execution technology reference, not as a product model.
|
||||
|
||||
Paperclip should treat it the same way it treats sandboxes or agent CLIs:
|
||||
|
||||
- execution substrate underneath the control plane
|
||||
- optional where the tradeoff is worth it
|
||||
- never the source of truth for company/task/governance state
|
||||
|
||||
If we do one thing from this report, it should be a narrowly scoped `agentos_local` experiment plus a design pass on capability-based runtime permissions. Those two ideas have the best upside and the lowest architectural risk.
|
||||
@@ -249,7 +249,7 @@ Runs local `claude` CLI directly.
|
||||
"cwd": "/absolute/or/relative/path",
|
||||
"promptTemplate": "You are agent {{agent.id}} ...",
|
||||
"model": "optional-model-id",
|
||||
"maxTurnsPerRun": 300,
|
||||
"maxTurnsPerRun": 1000,
|
||||
"dangerouslySkipPermissions": true,
|
||||
"env": {"KEY": "VALUE"},
|
||||
"extraArgs": [],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
paperclip:
|
||||
build:
|
||||
context: .
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "${PAPERCLIP_PORT:-3100}:3100"
|
||||
@@ -15,4 +15,4 @@ services:
|
||||
PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}"
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
|
||||
volumes:
|
||||
- "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip"
|
||||
- "${PAPERCLIP_DATA_DIR:-../data/docker-paperclip}:/paperclip"
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
review:
|
||||
build:
|
||||
context: .
|
||||
context: ..
|
||||
dockerfile: docker/untrusted-review/Dockerfile
|
||||
init: true
|
||||
tty: true
|
||||
@@ -16,7 +16,9 @@ services:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
server:
|
||||
build: .
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3100:3100"
|
||||
environment:
|
||||
20
docker/quadlet/paperclip-db.container
Normal file
20
docker/quadlet/paperclip-db.container
Normal file
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=PostgreSQL for Paperclip
|
||||
|
||||
[Container]
|
||||
Image=docker.io/library/postgres:17-alpine
|
||||
ContainerName=paperclip-db
|
||||
Pod=paperclip.pod
|
||||
Volume=paperclip-pgdata:/var/lib/postgresql/data
|
||||
EnvironmentFile=%h/.config/containers/systemd/paperclip.env
|
||||
HealthCmd=pg_isready -U $POSTGRES_USER -d $POSTGRES_DB -h localhost || exit 1
|
||||
HealthInterval=15s
|
||||
HealthTimeout=5s
|
||||
HealthRetries=5
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
TimeoutStartSec=60
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
23
docker/quadlet/paperclip.container
Normal file
23
docker/quadlet/paperclip.container
Normal file
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Paperclip AI Agent Orchestrator
|
||||
Requires=paperclip-db.service
|
||||
After=paperclip-db.service
|
||||
|
||||
[Container]
|
||||
Image=paperclip-local
|
||||
ContainerName=paperclip
|
||||
Pod=paperclip.pod
|
||||
Volume=%h/.local/share/paperclip:/paperclip:Z
|
||||
Environment=HOST=0.0.0.0
|
||||
Environment=PAPERCLIP_HOME=/paperclip
|
||||
Environment=PAPERCLIP_DEPLOYMENT_MODE=authenticated
|
||||
Environment=PAPERCLIP_DEPLOYMENT_EXPOSURE=private
|
||||
Environment=PAPERCLIP_PUBLIC_URL=http://localhost:3100
|
||||
EnvironmentFile=%h/.config/containers/systemd/paperclip.env
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
TimeoutStartSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
3
docker/quadlet/paperclip.pod
Normal file
3
docker/quadlet/paperclip.pod
Normal file
@@ -0,0 +1,3 @@
|
||||
[Pod]
|
||||
PodName=paperclip
|
||||
PublishPort=3100:3100
|
||||
287
docs/adapters/adapter-ui-parser.md
Normal file
287
docs/adapters/adapter-ui-parser.md
Normal file
@@ -0,0 +1,287 @@
|
||||
---
|
||||
title: Adapter UI Parser Contract
|
||||
summary: Ship a custom run-log parser so the Paperclip UI renders your adapter's output correctly
|
||||
---
|
||||
|
||||
When Paperclip runs an agent, stdout is streamed to the UI in real time. The UI needs a **parser** to convert raw stdout lines into structured transcript entries (tool calls, tool results, assistant messages, system events). Without a custom parser, the UI falls back to a generic shell parser that treats every non-system line as `assistant` output — tool commands leak as plain text, durations are lost, and errors are invisible.
|
||||
|
||||
## The Problem
|
||||
|
||||
Most agent CLIs emit structured stdout with tool calls, progress indicators, and multi-line output. For example:
|
||||
|
||||
```
|
||||
[hermes] Session resumed: abc123
|
||||
┊ 💬 Thinking about how to approach this...
|
||||
┊ $ ls /home/user/project
|
||||
┊ [done] $ ls /home/user/project — /src /README.md 0.3s
|
||||
┊ 💬 I see the project structure. Let me read the README.
|
||||
┊ read /home/user/project/README.md
|
||||
┊ [done] read — Project Overview: A CLI tool for... 1.2s
|
||||
The project is a CLI tool. Here's what I found:
|
||||
- It uses TypeScript
|
||||
- Tests are in /tests
|
||||
```
|
||||
|
||||
Without a parser, the UI shows all of this as raw `assistant` text — the tool calls and results are indistinguishable from the agent's actual response.
|
||||
|
||||
With a parser, the UI renders:
|
||||
|
||||
- `Thinking about how to approach this...` as a collapsible thinking block
|
||||
- `$ ls /home/user/project` as a tool call card (collapsed)
|
||||
- `0.3s` duration as a tool result card
|
||||
- `The project is a CLI tool...` as the assistant's response
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌──────────────────┐ package.json ┌──────────────────┐
|
||||
│ Adapter Package │─── exports["./ui-parser"] ──→│ dist/ui-parser.js │
|
||||
│ (npm / local) │ │ (zero imports) │
|
||||
└──────────────────┘ └────────┬─────────┘
|
||||
│ plugin-loader reads at startup
|
||||
▼
|
||||
┌──────────────────┐ GET /api/:type/ui-parser.js ┌──────────────────┐
|
||||
│ Paperclip Server │◄────────────────────────────────│ uiParserCache │
|
||||
│ (in-memory) │ └──────────────────┘
|
||||
└────────┬─────────┘
|
||||
│ serves JS to browser
|
||||
▼
|
||||
┌──────────────────┐ fetch() + eval ┌──────────────────┐
|
||||
│ Paperclip UI │─────────────────────→│ parseStdoutLine │
|
||||
│ (dynamic loader) │ registers parser │ (per-adapter) │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
1. **Build time** — You compile `src/ui-parser.ts` to `dist/ui-parser.js` (zero runtime imports)
|
||||
2. **Server startup** — Plugin loader reads the file and caches it in memory
|
||||
3. **UI load** — When the user opens a run, the UI fetches the parser from `GET /api/:type/ui-parser.js`
|
||||
4. **Runtime** — The fetched module is eval'd and registered. All subsequent lines use the real parser
|
||||
|
||||
## Contract: package.json
|
||||
|
||||
### 1. `paperclip.adapterUiParser` — contract version
|
||||
|
||||
```json
|
||||
{
|
||||
"paperclip": {
|
||||
"adapterUiParser": "1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The Paperclip host checks this field. If the major version is unsupported, the host logs a warning and falls back to the generic parser instead of executing potentially incompatible code.
|
||||
|
||||
| Host expects | Adapter declares | Result |
|
||||
|---|---|---|
|
||||
| `1.x` | `1.0.0` | Parser loaded |
|
||||
| `1.x` | `2.0.0` | Warning logged, generic parser used |
|
||||
| `1.x` | (missing) | Parser loaded (grace period — future versions may require it) |
|
||||
|
||||
### 2. `exports["./ui-parser"]` — file path
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": "./dist/server/index.js",
|
||||
"./ui-parser": "./dist/ui-parser.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contract: Module Exports
|
||||
|
||||
Your `dist/ui-parser.js` must export **at least one** of:
|
||||
|
||||
### `parseStdoutLine(line: string, ts: string): TranscriptEntry[]`
|
||||
|
||||
Static parser. Called for each line of adapter stdout.
|
||||
|
||||
```ts
|
||||
export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
if (line.startsWith("[my-agent]")) {
|
||||
return [{ kind: "system", ts, text: line }];
|
||||
}
|
||||
return [{ kind: "assistant", ts, text: line }];
|
||||
}
|
||||
```
|
||||
|
||||
### `createStdoutParser(): { parseLine(line, ts): TranscriptEntry[]; reset(): void }`
|
||||
|
||||
Stateful parser factory. Preferred if your parser needs to track multi-line continuation, command nesting, or other cross-call state.
|
||||
|
||||
```ts
|
||||
let counter = 0;
|
||||
|
||||
export function createStdoutParser() {
|
||||
let suppressContinuation = false;
|
||||
|
||||
function parseLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
if (suppressContinuation) {
|
||||
if (/^[\d.]+s$/.test(trimmed)) {
|
||||
suppressContinuation = false;
|
||||
return [];
|
||||
}
|
||||
return []; // swallow continuation lines
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[tool-done]")) {
|
||||
const id = `tool-${++counter}`;
|
||||
suppressContinuation = true;
|
||||
return [
|
||||
{ kind: "tool_call", ts, name: "shell", input: {}, toolUseId: id },
|
||||
{ kind: "tool_result", ts, toolUseId: id, content: trimmed, isError: false },
|
||||
];
|
||||
}
|
||||
|
||||
return [{ kind: "assistant", ts, text: trimmed }];
|
||||
}
|
||||
|
||||
function reset() {
|
||||
suppressContinuation = false;
|
||||
}
|
||||
|
||||
return { parseLine, reset };
|
||||
}
|
||||
```
|
||||
|
||||
If both are exported, `createStdoutParser` takes priority.
|
||||
|
||||
## Contract: TranscriptEntry
|
||||
|
||||
Each entry must match one of these discriminated union shapes:
|
||||
|
||||
```ts
|
||||
// Assistant message
|
||||
{ kind: "assistant"; ts: string; text: string; delta?: boolean }
|
||||
|
||||
// Thinking / reasoning
|
||||
{ kind: "thinking"; ts: string; text: string; delta?: boolean }
|
||||
|
||||
// User message (rare — usually from agent-initiated prompts)
|
||||
{ kind: "user"; ts: string; text: string }
|
||||
|
||||
// Tool invocation
|
||||
{ kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
||||
|
||||
// Tool result
|
||||
{ kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||
|
||||
// System / adapter messages
|
||||
{ kind: "system"; ts: string; text: string }
|
||||
|
||||
// Stderr / errors
|
||||
{ kind: "stderr"; ts: string; text: string }
|
||||
|
||||
// Raw stdout (fallback)
|
||||
{ kind: "stdout"; ts: string; text: string }
|
||||
```
|
||||
|
||||
### Linking tool calls to results
|
||||
|
||||
Use `toolUseId` to pair `tool_call` and `tool_result` entries. The UI renders them as collapsible cards.
|
||||
|
||||
```ts
|
||||
const id = `my-tool-${++counter}`;
|
||||
return [
|
||||
{ kind: "tool_call", ts, name: "read", input: { path: "/src/main.ts" }, toolUseId: id },
|
||||
{ kind: "tool_result", ts, toolUseId: id, content: "const main = () => {...}", isError: false },
|
||||
];
|
||||
```
|
||||
|
||||
### Error handling
|
||||
|
||||
Set `isError: true` on tool results to show a red indicator:
|
||||
|
||||
```ts
|
||||
{ kind: "tool_result", ts, toolUseId: id, content: "ENOENT: no such file", isError: true }
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
1. **Zero runtime imports.** Your file is loaded via `URL.createObjectURL` + dynamic `import()` in the browser. No `import`, no `require`, no top-level `await`.
|
||||
|
||||
2. **No DOM / Node.js APIs.** Runs in a browser sandbox. Use only vanilla JS (ES2020+).
|
||||
|
||||
3. **No side effects.** Module-level code must not modify globals, access `window`, or perform I/O. Only declare and export functions.
|
||||
|
||||
4. **Deterministic.** Given the same `(line, ts)` input, the same output must be produced. This matters for log replay.
|
||||
|
||||
5. **Error-tolerant.** Never throw. Return `[{ kind: "stdout", ts, text: line }]` for any line you can't parse, rather than crashing the transcript.
|
||||
|
||||
6. **File size.** Keep under 50 KB. This is served per-request and eval'd in the browser.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Event | What happens |
|
||||
|---|---|
|
||||
| Server starts | Plugin loader reads `exports["./ui-parser"]`, reads the file, caches in memory |
|
||||
| UI opens run | `getUIAdapter(type)` called. If no built-in parser, kicks off async `fetch(/api/:type/ui-parser.js)` |
|
||||
| First lines arrive | Generic process parser handles them immediately (no blocking). Dynamic parser loads in background |
|
||||
| Parser loads | `registerUIAdapter()` called. All subsequent line parsing uses the real parser |
|
||||
| Parser fails (404, eval error) | Warning logged to console. Generic parser continues. Failed type is cached — no retries |
|
||||
| Server restart | In-memory cache is repopulated from adapter packages |
|
||||
|
||||
## Error Behavior
|
||||
|
||||
| Failure | What happens |
|
||||
|---|---|
|
||||
| Module syntax error (import fails) | Caught, logged, falls back to generic parser. No retries. |
|
||||
| Returns wrong shape | Individual entries with missing fields are silently ignored by the transcript builder. |
|
||||
| Throws at runtime | Caught per-line. That line falls back to generic. Parser stays registered for future lines. |
|
||||
| 404 (no ui-parser export) | Type added to failed-loads set. Generic parser from first call onward. |
|
||||
| Contract version mismatch | Server logs warning, skips loading. Generic parser used. |
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
# Compile TypeScript to JavaScript
|
||||
tsc src/ui-parser.ts --outDir dist --target ES2020 --module ES2020 --declaration false
|
||||
```
|
||||
|
||||
Your `tsconfig.json` can handle this automatically — just make sure `ui-parser.ts` is included in the build and outputs to `dist/ui-parser.js`.
|
||||
|
||||
## Testing
|
||||
|
||||
Test your parser locally by running it against sample stdout:
|
||||
|
||||
```ts
|
||||
// test-parser.ts
|
||||
import { createStdoutParser } from "./dist/ui-parser.js";
|
||||
|
||||
const parser = createStdoutParser();
|
||||
const sampleLines = [
|
||||
"[my-agent] Starting session abc123",
|
||||
"Thinking about the task...",
|
||||
"$ ls /home/user/project",
|
||||
"[done] $ ls — /src /README.md 0.3s",
|
||||
"I'll read the README now.",
|
||||
"Error: file not found",
|
||||
];
|
||||
|
||||
for (const line of sampleLines) {
|
||||
const entries = parser.parseLine(line, new Date().toISOString());
|
||||
for (const entry of entries) {
|
||||
console.log(` ${entry.kind}:`, entry.text ?? entry.name ?? entry.content);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run with: `npx tsx test-parser.ts`
|
||||
|
||||
## Skipping the UI Parser
|
||||
|
||||
If your adapter's stdout is simple (no tool markers, no special formatting), you can skip the UI parser entirely. The generic `process` parser will handle it — every non-system line becomes `assistant` output. This is fine for:
|
||||
|
||||
- Agents that output plain text responses
|
||||
- Custom scripts that just print results
|
||||
- Simple CLIs without structured output
|
||||
|
||||
To skip it, simply don't include `exports["./ui-parser"]` in your `package.json`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [External Adapters](/adapters/external-adapters) — full guide to building adapter packages
|
||||
- [Creating an Adapter](/adapters/creating-an-adapter) — adapter internals and built-in integration
|
||||
@@ -21,7 +21,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) |
|
||||
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) |
|
||||
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible |
|
||||
|
||||
## Prompt Templates
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ The `codex_local` adapter runs OpenAI's Codex CLI locally. It supports session p
|
||||
| `env` | object | No | Environment variables (supports secret refs) |
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `fastMode` | boolean | No | Enables Codex Fast mode. Currently supported on `gpt-5.4` only and burns credits faster |
|
||||
| `dangerouslyBypassApprovalsAndSandbox` | boolean | No | Skip safety checks (dev only) |
|
||||
|
||||
## Session Persistence
|
||||
@@ -30,8 +31,22 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
|
||||
|
||||
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
||||
|
||||
## Fast Mode
|
||||
|
||||
When `fastMode` is enabled, Paperclip adds Codex config overrides equivalent to:
|
||||
|
||||
```sh
|
||||
-c 'service_tier="fast"' -c 'features.fast_mode=true'
|
||||
```
|
||||
|
||||
Paperclip currently applies that only when the selected model is `gpt-5.4`. On other models, the toggle is preserved in config but ignored at execution time to avoid unsupported runs.
|
||||
|
||||
## Managed `CODEX_HOME`
|
||||
|
||||
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
|
||||
|
||||
## Manual Local CLI
|
||||
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -9,23 +9,40 @@ Build a custom adapter to connect Paperclip to any agent runtime.
|
||||
If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
|
||||
</Tip>
|
||||
|
||||
## Two Paths
|
||||
|
||||
| | Built-in | External Plugin |
|
||||
|---|---|---|
|
||||
| Source | Inside `paperclip-fork` | Separate npm package |
|
||||
| Distribution | Ships with Paperclip | Independent npm publish |
|
||||
| UI parser | Static import | Dynamic load from API |
|
||||
| Registration | Edit 3 registries | Auto-loaded at startup |
|
||||
| Best for | Core adapters, contributors | Third-party adapters, internal tools |
|
||||
|
||||
For most cases, **build an external adapter plugin**. It's cleaner, independently versioned, and doesn't require modifying Paperclip's source. See [External Adapters](/adapters/external-adapters) for the full guide.
|
||||
|
||||
The rest of this page covers the shared internals that both paths use.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
packages/adapters/<name>/
|
||||
packages/adapters/<name>/ # built-in
|
||||
── or ──
|
||||
my-adapter/ # external plugin
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/
|
||||
index.ts # Shared metadata
|
||||
server/
|
||||
index.ts # Server exports
|
||||
index.ts # Server exports (createServerAdapter)
|
||||
execute.ts # Core execution logic
|
||||
parse.ts # Output parsing
|
||||
test.ts # Environment diagnostics
|
||||
ui/
|
||||
index.ts # UI exports
|
||||
parse-stdout.ts # Transcript parser
|
||||
index.ts # UI exports (built-in only)
|
||||
parse-stdout.ts # Transcript parser (built-in only)
|
||||
build-config.ts # Config builder
|
||||
ui-parser.ts # Self-contained UI parser (external — see [UI Parser Contract](/adapters/adapter-ui-parser))
|
||||
cli/
|
||||
index.ts # CLI exports
|
||||
format-event.ts # Terminal formatter
|
||||
@@ -46,6 +63,9 @@ Use when: ...
|
||||
Don't use when: ...
|
||||
Core fields: ...
|
||||
`;
|
||||
|
||||
// Required for external adapters (plugin-loader convention)
|
||||
export { createServerAdapter } from "./server/index.js";
|
||||
```
|
||||
|
||||
## Step 2: Server Execute
|
||||
@@ -54,7 +74,7 @@ Core fields: ...
|
||||
|
||||
Key responsibilities:
|
||||
|
||||
1. Read config using safe helpers (`asString`, `asNumber`, etc.)
|
||||
1. Read config using safe helpers (`asString`, `asNumber`, etc.) from `@paperclipai/adapter-utils/server-utils`
|
||||
2. Build environment with `buildPaperclipEnv(agent)` plus context vars
|
||||
3. Resolve session state from `runtime.sessionParams`
|
||||
4. Render prompt with `renderTemplate(template, data)`
|
||||
@@ -62,27 +82,102 @@ Key responsibilities:
|
||||
6. Parse output for usage, costs, session state, errors
|
||||
7. Handle unknown session errors (retry fresh, set `clearSession: true`)
|
||||
|
||||
### Available Helpers
|
||||
|
||||
| Helper | Source | Purpose |
|
||||
|--------|--------|---------|
|
||||
| `runChildProcess(cmd, opts)` | `@paperclipai/adapter-utils/server-utils` | Spawn with timeout, grace, streaming |
|
||||
| `buildPaperclipEnv(agent)` | `@paperclipai/adapter-utils/server-utils` | Inject `PAPERCLIP_*` env vars |
|
||||
| `renderTemplate(tpl, data)` | `@paperclipai/adapter-utils/server-utils` | `{{variable}}` substitution |
|
||||
| `asString(v)` | `@paperclipai/adapter-utils` | Safe config value extraction |
|
||||
| `asNumber(v)` | `@paperclipai/adapter-utils` | Safe number extraction |
|
||||
|
||||
### AdapterExecutionContext
|
||||
|
||||
```ts
|
||||
interface AdapterExecutionContext {
|
||||
runId: string;
|
||||
agent: { id: string; companyId: string; name: string; adapterConfig: unknown };
|
||||
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
|
||||
config: Record<string, unknown>; // agent's adapterConfig
|
||||
context: Record<string, unknown>; // task, wake reason, etc.
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### AdapterExecutionResult
|
||||
|
||||
```ts
|
||||
interface AdapterExecutionResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
errorMessage?: string | null;
|
||||
usage?: { inputTokens: number; outputTokens: number };
|
||||
sessionParams?: Record<string, unknown> | null; // persist across heartbeats
|
||||
sessionDisplayId?: string | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
costUsd?: number | null;
|
||||
clearSession?: boolean; // set true to force fresh session on next wake
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Environment Test
|
||||
|
||||
`src/server/test.ts` validates the adapter config before running.
|
||||
|
||||
Return structured diagnostics:
|
||||
|
||||
- `error` for invalid/unusable setup
|
||||
- `warn` for non-blocking issues
|
||||
- `info` for successful checks
|
||||
| Level | Meaning | Effect |
|
||||
|-------|---------|--------|
|
||||
| `error` | Invalid or unusable setup | Blocks execution |
|
||||
| `warn` | Non-blocking issue | Shown with yellow indicator |
|
||||
| `info` | Successful check | Shown in test results |
|
||||
|
||||
## Step 4: UI Module
|
||||
```ts
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: "pass", // "pass" | "warn" | "fail"
|
||||
checks: [
|
||||
{ level: "info", message: "CLI v1.2.0 detected", code: "cli_detected" },
|
||||
{ level: "warn", message: "No API key found", hint: "Set ANTHROPIC_API_KEY", code: "no_key" },
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: UI Module (Built-in Only)
|
||||
|
||||
For built-in adapters registered in Paperclip's source:
|
||||
|
||||
- `parse-stdout.ts` — converts stdout lines to `TranscriptEntry[]` for the run viewer
|
||||
- `build-config.ts` — converts form values to `adapterConfig` JSON
|
||||
- Config fields React component in `ui/src/adapters/<name>/config-fields.tsx`
|
||||
|
||||
For external adapters, use a self-contained `ui-parser.ts` instead. See the [UI Parser Contract](/adapters/adapter-ui-parser).
|
||||
|
||||
## Step 5: CLI Module
|
||||
|
||||
`format-event.ts` — pretty-prints stdout for `paperclipai run --watch` using `picocolors`.
|
||||
|
||||
## Step 6: Register
|
||||
```ts
|
||||
export function formatStdoutEvent(line: string, debug: boolean): void {
|
||||
if (line.startsWith("[tool-done]")) {
|
||||
console.log(chalk.green(` ✓ ${line}`));
|
||||
} else {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Register (Built-in Only)
|
||||
|
||||
Add the adapter to all three registries:
|
||||
|
||||
@@ -90,6 +185,24 @@ Add the adapter to all three registries:
|
||||
2. `ui/src/adapters/registry.ts`
|
||||
3. `cli/src/adapters/registry.ts`
|
||||
|
||||
For external adapters, registration is automatic — the plugin loader handles it.
|
||||
|
||||
## Session Persistence
|
||||
|
||||
If your agent runtime supports conversation continuity across heartbeats:
|
||||
|
||||
1. Return `sessionParams` from `execute()` (e.g., `{ sessionId: "abc123" }`)
|
||||
2. Read `runtime.sessionParams` on the next wake to resume
|
||||
3. Optionally implement a `sessionCodec` for validation and display
|
||||
|
||||
```ts
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw) { /* validate raw session data */ },
|
||||
serialize(params) { /* serialize for storage */ },
|
||||
getDisplayId(params) { /* human-readable session label */ },
|
||||
};
|
||||
```
|
||||
|
||||
## Skills Injection
|
||||
|
||||
Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory:
|
||||
@@ -105,3 +218,10 @@ Make Paperclip skills discoverable to your agent runtime without writing to the
|
||||
- Inject secrets via environment variables, not prompts
|
||||
- Configure network access controls if the runtime supports them
|
||||
- Always enforce timeout and grace period
|
||||
- The UI parser module runs in a browser sandbox — zero runtime imports, no side effects
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [External Adapters](/adapters/external-adapters) — build a standalone adapter plugin
|
||||
- [UI Parser Contract](/adapters/adapter-ui-parser) — ship a custom run-log parser
|
||||
- [How Agents Work](/guides/agent-developer/how-agents-work) — the heartbeat lifecycle
|
||||
|
||||
392
docs/adapters/external-adapters.md
Normal file
392
docs/adapters/external-adapters.md
Normal file
@@ -0,0 +1,392 @@
|
||||
---
|
||||
title: External Adapters
|
||||
summary: Build, package, and distribute adapters as plugins without modifying Paperclip source
|
||||
---
|
||||
|
||||
Paperclip supports external adapter plugins that can be installed from npm packages or local directories. External adapters work exactly like built-in adapters — they execute agents, parse output, and render transcripts — but they live in their own package and don't require changes to Paperclip's source code.
|
||||
|
||||
## Built-in vs External
|
||||
|
||||
| | Built-in | External |
|
||||
|---|---|---|
|
||||
| Source location | Inside `paperclip-fork/packages/adapters/` | Separate npm package or local directory |
|
||||
| Registration | Hardcoded in three registries | Loaded at startup via plugin system |
|
||||
| UI parser | Static import at build time | Dynamically loaded from API (see [UI Parser](/adapters/adapter-ui-parser)) |
|
||||
| Distribution | Ships with Paperclip | Published to npm or linked via `file:` |
|
||||
| Updates | Requires Paperclip release | Independent versioning |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Minimal Package Structure
|
||||
|
||||
```
|
||||
my-adapter/
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/
|
||||
index.ts # Shared metadata (type, label, models)
|
||||
server/
|
||||
index.ts # createServerAdapter() factory
|
||||
execute.ts # Core execution logic
|
||||
parse.ts # Output parsing
|
||||
test.ts # Environment diagnostics
|
||||
ui-parser.ts # Self-contained UI transcript parser
|
||||
```
|
||||
|
||||
### package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-paperclip-adapter",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"paperclip": {
|
||||
"adapterUiParser": "1.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./server": "./dist/server/index.js",
|
||||
"./ui-parser": "./dist/ui-parser.js"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "^2026.325.0",
|
||||
"picocolors": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key fields:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `exports["."]` | Entry point — must export `createServerAdapter` |
|
||||
| `exports["./ui-parser"]` | Self-contained UI parser module (optional but recommended) |
|
||||
| `paperclip.adapterUiParser` | Contract version for the UI parser (`"1.0.0"`) |
|
||||
| `files` | Limits what gets published — only `dist/` |
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
## Server Module
|
||||
|
||||
The plugin loader calls `createServerAdapter()` from your package root. This function must return a `ServerAdapterModule`.
|
||||
|
||||
### src/index.ts
|
||||
|
||||
```ts
|
||||
export const type = "my_adapter"; // snake_case, globally unique
|
||||
export const label = "My Agent (local)";
|
||||
|
||||
export const models = [
|
||||
{ id: "model-a", label: "Model A" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# my_adapter configuration
|
||||
Use when: ...
|
||||
Don't use when: ...
|
||||
`;
|
||||
|
||||
// Required by plugin-loader convention
|
||||
export { createServerAdapter } from "./server/index.js";
|
||||
```
|
||||
|
||||
### src/server/index.ts
|
||||
|
||||
```ts
|
||||
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { type, models, agentConfigurationDoc } from "../index.js";
|
||||
import { execute } from "./execute.js";
|
||||
import { testEnvironment } from "./test.js";
|
||||
|
||||
export function createServerAdapter(): ServerAdapterModule {
|
||||
return {
|
||||
type,
|
||||
execute,
|
||||
testEnvironment,
|
||||
models,
|
||||
agentConfigurationDoc,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### src/server/execute.ts
|
||||
|
||||
The core execution function. Receives an `AdapterExecutionContext` and returns an `AdapterExecutionResult`.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
import {
|
||||
runChildProcess,
|
||||
buildPaperclipEnv,
|
||||
renderTemplate,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
export async function execute(
|
||||
ctx: AdapterExecutionContext,
|
||||
): Promise<AdapterExecutionResult> {
|
||||
const { config, agent, runtime, context, onLog, onMeta } = ctx;
|
||||
|
||||
// 1. Read config with safe helpers
|
||||
const cwd = String(config.cwd ?? "/tmp");
|
||||
const command = String(config.command ?? "my-agent");
|
||||
const timeoutSec = Number(config.timeoutSec ?? 300);
|
||||
|
||||
// 2. Build environment with Paperclip vars injected
|
||||
const env = buildPaperclipEnv(agent);
|
||||
|
||||
// 3. Render prompt template
|
||||
const prompt = config.promptTemplate
|
||||
? renderTemplate(String(config.promptTemplate), {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
companyId: agent.companyId,
|
||||
runId: ctx.runId,
|
||||
taskId: context.taskId ?? "",
|
||||
taskTitle: context.taskTitle ?? "",
|
||||
})
|
||||
: "Continue your work.";
|
||||
|
||||
// 4. Spawn process
|
||||
const result = await runChildProcess(command, {
|
||||
args: [prompt],
|
||||
cwd,
|
||||
env,
|
||||
timeout: timeoutSec * 1000,
|
||||
graceMs: 10_000,
|
||||
onStdout: (chunk) => onLog("stdout", chunk),
|
||||
onStderr: (chunk) => onLog("stderr", chunk),
|
||||
});
|
||||
|
||||
// 5. Return structured result
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
// Include session state for persistence
|
||||
sessionParams: { /* ... */ },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Available Helpers from `@paperclipai/adapter-utils`
|
||||
|
||||
| Helper | Purpose |
|
||||
|--------|---------|
|
||||
| `runChildProcess(command, opts)` | Spawn a child process with timeout, grace period, and streaming callbacks |
|
||||
| `buildPaperclipEnv(agent)` | Inject `PAPERCLIP_*` environment variables |
|
||||
| `renderTemplate(template, data)` | `{{variable}}` substitution in prompt templates |
|
||||
| `asString(v)`, `asNumber(v)`, `asBoolean(v)` | Safe config value extraction |
|
||||
|
||||
### src/server/test.ts
|
||||
|
||||
Validates the adapter configuration before running. Returns structured diagnostics.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks = [];
|
||||
|
||||
// Example: check CLI is installed
|
||||
checks.push({
|
||||
level: "info",
|
||||
message: "My Agent CLI v1.2.0 detected",
|
||||
code: "cli_detected",
|
||||
});
|
||||
|
||||
// Example: check working directory
|
||||
const cwd = String(ctx.config.cwd ?? "");
|
||||
if (!cwd.startsWith("/")) {
|
||||
checks.push({
|
||||
level: "error",
|
||||
message: `Working directory must be absolute: "${cwd}"`,
|
||||
hint: "Use /home/user/project or /workspace",
|
||||
code: "invalid_cwd",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: checks.some(c => c.level === "error") ? "fail" : "pass",
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Check levels:
|
||||
|
||||
| Level | Meaning | Effect |
|
||||
|-------|---------|--------|
|
||||
| `info` | Informational | Shown in test results |
|
||||
| `warn` | Non-blocking issue | Shown with yellow indicator |
|
||||
| `error` | Blocks execution | Prevents agent from running |
|
||||
|
||||
## Installation
|
||||
|
||||
### From npm
|
||||
|
||||
```sh
|
||||
# Via the Paperclip UI
|
||||
# Settings → Adapters → Install from npm → "my-paperclip-adapter"
|
||||
|
||||
# Or via API
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName": "my-paperclip-adapter"}'
|
||||
```
|
||||
|
||||
### From local directory
|
||||
|
||||
```sh
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"localPath": "/home/user/my-adapter"}'
|
||||
```
|
||||
|
||||
Local adapters are symlinked into Paperclip's adapter directory. Changes to the source are picked up on server restart.
|
||||
|
||||
### Via adapter-plugins.json
|
||||
|
||||
For development, you can also edit `~/.paperclip/adapter-plugins.json` directly:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"packageName": "my-paperclip-adapter",
|
||||
"localPath": "/home/user/my-adapter",
|
||||
"type": "my_adapter",
|
||||
"installedAt": "2026-03-30T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Optional: Session Persistence
|
||||
|
||||
If your agent runtime supports sessions (conversation continuity across heartbeats), implement a session codec:
|
||||
|
||||
```ts
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw) {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
return r.sessionId ? { sessionId: String(r.sessionId) } : null;
|
||||
},
|
||||
serialize(params) {
|
||||
return params?.sessionId ? { sessionId: String(params.sessionId) } : null;
|
||||
},
|
||||
getDisplayId(params) {
|
||||
return params?.sessionId ? String(params.sessionId) : null;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Include it in `createServerAdapter()`:
|
||||
|
||||
```ts
|
||||
return { type, execute, testEnvironment, sessionCodec, /* ... */ };
|
||||
```
|
||||
|
||||
## Optional: Skills Sync
|
||||
|
||||
If your agent runtime supports skills/plugins, implement `listSkills` and `syncSkills`:
|
||||
|
||||
```ts
|
||||
return {
|
||||
type,
|
||||
execute,
|
||||
testEnvironment,
|
||||
async listSkills(ctx) {
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: [],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
};
|
||||
},
|
||||
async syncSkills(ctx, desiredSkills) {
|
||||
// Install desired skills into the runtime
|
||||
return { /* same shape as listSkills */ };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Optional: Model Detection
|
||||
|
||||
If your runtime has a local config file that specifies the default model:
|
||||
|
||||
```ts
|
||||
async function detectModel() {
|
||||
// Read ~/.my-agent/config.yaml or similar
|
||||
return {
|
||||
model: "anthropic/claude-sonnet-4",
|
||||
provider: "anthropic",
|
||||
source: "~/.my-agent/config.yaml",
|
||||
candidates: ["anthropic/claude-sonnet-4", "openai/gpt-4o"],
|
||||
};
|
||||
}
|
||||
|
||||
return { type, execute, testEnvironment, detectModel: () => detectModel() };
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm publish
|
||||
```
|
||||
|
||||
Other Paperclip users can then install your adapter by package name from the UI or API.
|
||||
|
||||
## Security
|
||||
|
||||
- Treat agent output as untrusted — parse defensively, never `eval()` agent output
|
||||
- Inject secrets via environment variables, not in prompts
|
||||
- Configure network access controls if the runtime supports them
|
||||
- Always enforce timeout and grace period — don't let agents run forever
|
||||
- The UI parser module runs in a browser sandbox — it must have zero runtime imports and no side effects
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [UI Parser Contract](/adapters/adapter-ui-parser) — add a custom run-log parser so the UI renders your adapter's output correctly
|
||||
- [Creating an Adapter](/adapters/creating-an-adapter) — full walkthrough of adapter internals
|
||||
- [How Agents Work](/guides/agent-developer/how-agents-work) — understand the heartbeat lifecycle your adapter serves
|
||||
@@ -20,42 +20,69 @@ When a heartbeat fires, Paperclip:
|
||||
|---------|----------|-------------|
|
||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally |
|
||||
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) |
|
||||
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
||||
| Cursor | `cursor` | Runs Cursor in background mode |
|
||||
| Pi Local | `pi_local` | Runs an embedded Pi agent locally |
|
||||
| Hermes Local | `hermes_local` | Runs Hermes CLI locally (`hermes-paperclip-adapter`) |
|
||||
| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint |
|
||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||
|
||||
### External (plugin) adapters
|
||||
|
||||
These adapters ship as standalone npm packages and are installed via the plugin system:
|
||||
|
||||
| Adapter | Package | Type Key | Description |
|
||||
|---------|---------|----------|-------------|
|
||||
| Droid Local | `@henkey/droid-paperclip-adapter` | `droid_local` | Runs Factory Droid locally |
|
||||
|
||||
## External Adapters
|
||||
|
||||
You can build and distribute adapters as standalone packages — no changes to Paperclip's source code required. External adapters are loaded at startup via the plugin system.
|
||||
|
||||
```sh
|
||||
# Install from npm via API
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-d '{"packageName": "my-paperclip-adapter"}'
|
||||
|
||||
# Or link from a local directory
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-d '{"localPath": "/home/user/my-adapter"}'
|
||||
```
|
||||
|
||||
See [External Adapters](/adapters/external-adapters) for the full guide.
|
||||
|
||||
## Adapter Architecture
|
||||
|
||||
Each adapter is a package with three modules:
|
||||
Each adapter is a package with modules consumed by three registries:
|
||||
|
||||
```
|
||||
packages/adapters/<name>/
|
||||
my-adapter/
|
||||
src/
|
||||
index.ts # Shared metadata (type, label, models)
|
||||
server/
|
||||
execute.ts # Core execution logic
|
||||
parse.ts # Output parsing
|
||||
test.ts # Environment diagnostics
|
||||
ui/
|
||||
parse-stdout.ts # Stdout -> transcript entries for run viewer
|
||||
build-config.ts # Form values -> adapterConfig JSON
|
||||
ui-parser.ts # Self-contained UI transcript parser (for external adapters)
|
||||
cli/
|
||||
format-event.ts # Terminal output for `paperclipai run --watch`
|
||||
```
|
||||
|
||||
Three registries consume these modules:
|
||||
|
||||
| Registry | What it does |
|
||||
|----------|-------------|
|
||||
| **Server** | Executes agents, captures results |
|
||||
| **UI** | Renders run transcripts, provides config forms |
|
||||
| **CLI** | Formats terminal output for live watching |
|
||||
| Registry | What it does | Source |
|
||||
|----------|-------------|--------|
|
||||
| **Server** | Executes agents, captures results | `createServerAdapter()` from package root |
|
||||
| **UI** | Renders run transcripts, provides config forms | `ui-parser.js` (dynamic) or static import (built-in) |
|
||||
| **CLI** | Formats terminal output for live watching | Static import |
|
||||
|
||||
## Choosing an Adapter
|
||||
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local`
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin
|
||||
- **Need to run a script or command?** Use `process`
|
||||
- **Need to call an external service?** Use `http`
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) or [build an external adapter plugin](/adapters/external-adapters)
|
||||
|
||||
## UI Parser Contract
|
||||
|
||||
External adapters can ship a self-contained UI parser that tells the Paperclip web UI how to render their stdout. Without it, the UI uses a generic shell parser. See the [UI Parser Contract](/adapters/adapter-ui-parser) for details.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Agent Runtime Guide
|
||||
|
||||
Status: User-facing guide
|
||||
Last updated: 2026-02-17
|
||||
Status: User-facing guide
|
||||
Last updated: 2026-03-26
|
||||
Audience: Operators setting up and running agents in Paperclip
|
||||
|
||||
## 1. What this system does
|
||||
@@ -32,14 +32,23 @@ If an agent is already running, new wakeups are merged (coalesced) instead of la
|
||||
|
||||
## 3.1 Adapter choice
|
||||
|
||||
Common choices:
|
||||
Built-in adapters:
|
||||
|
||||
- `claude_local`: runs your local `claude` CLI
|
||||
- `codex_local`: runs your local `codex` CLI
|
||||
- `opencode_local`: runs your local `opencode` CLI
|
||||
- `cursor`: runs Cursor in background mode
|
||||
- `pi_local`: runs an embedded Pi agent locally
|
||||
- `hermes_local`: runs your local `hermes` CLI (`hermes-paperclip-adapter`)
|
||||
- `openclaw_gateway`: connects to an OpenClaw gateway endpoint
|
||||
- `process`: generic shell command adapter
|
||||
- `http`: calls an external HTTP endpoint
|
||||
|
||||
For `claude_local` and `codex_local`, Paperclip assumes the CLI is already installed and authenticated on the host machine.
|
||||
External plugin adapters (install via the adapter manager or API):
|
||||
|
||||
- `droid_local`: runs your local Factory Droid CLI (`@henkey/droid-paperclip-adapter`)
|
||||
|
||||
For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `droid_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine.
|
||||
|
||||
## 3.2 Runtime behavior
|
||||
|
||||
@@ -69,6 +78,8 @@ You can set:
|
||||
|
||||
Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run context values.
|
||||
|
||||
> **Note:** `bootstrapPromptTemplate` is deprecated and should not be used for new agents. Existing configs that use it will continue to work but should be migrated to the managed instructions bundle system.
|
||||
|
||||
## 4. Session resume behavior
|
||||
|
||||
Paperclip stores session IDs for resumable adapters.
|
||||
@@ -133,7 +144,7 @@ If the connection drops, the UI reconnects automatically.
|
||||
|
||||
If runs fail repeatedly:
|
||||
|
||||
1. Check adapter command availability (`claude`/`codex` installed and logged in).
|
||||
1. Check adapter command availability (e.g. `claude`/`codex`/`opencode`/`hermes` installed and logged in).
|
||||
2. Verify `cwd` exists and is accessible.
|
||||
3. Inspect run error + stderr excerpt, then full log.
|
||||
4. Confirm timeout is not too low.
|
||||
@@ -166,9 +177,9 @@ Start with least privilege where possible, and avoid exposing secrets in broad r
|
||||
|
||||
## 10. Minimal setup checklist
|
||||
|
||||
1. Choose adapter (`claude_local` or `codex_local`).
|
||||
2. Set `cwd` to the target workspace.
|
||||
3. Add bootstrap + normal prompt templates.
|
||||
1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). External plugins like `droid_local` are also available via the adapter manager.
|
||||
2. Set `cwd` to the target workspace (for local adapters).
|
||||
3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle.
|
||||
4. Configure heartbeat policy (timer and/or assignment wakeups).
|
||||
5. Trigger a manual wakeup.
|
||||
6. Confirm run succeeds and session/token usage is recorded.
|
||||
|
||||
@@ -73,7 +73,7 @@ POST /api/issues/{issueId}/checkout
|
||||
Headers: X-Paperclip-Run-Id: {runId}
|
||||
{
|
||||
"agentId": "{yourAgentId}",
|
||||
"expectedStatuses": ["todo", "backlog", "blocked"]
|
||||
"expectedStatuses": ["todo", "backlog", "blocked", "in_review"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ Interactive first-time setup:
|
||||
pnpm paperclipai onboard
|
||||
```
|
||||
|
||||
If Paperclip is already configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to change settings on an existing install.
|
||||
|
||||
First prompt:
|
||||
|
||||
1. `Quickstart` (recommended): local defaults (embedded database, no LLM provider, local disk storage, default secrets)
|
||||
@@ -50,6 +52,8 @@ Non-interactive defaults + immediate start (opens browser on server listen):
|
||||
pnpm paperclipai onboard --yes
|
||||
```
|
||||
|
||||
On an existing install, `--yes` now preserves the current config and just starts Paperclip with that setup.
|
||||
|
||||
## `paperclipai doctor`
|
||||
|
||||
Health checks with optional auto-repair:
|
||||
@@ -85,6 +89,8 @@ Show resolved environment configuration:
|
||||
pnpm paperclipai env
|
||||
```
|
||||
|
||||
This now includes bind-oriented deployment settings such as `PAPERCLIP_BIND` and `PAPERCLIP_BIND_HOST` when configured.
|
||||
|
||||
## `paperclipai allowed-hostname`
|
||||
|
||||
Allow a private hostname for authenticated/private mode:
|
||||
|
||||
@@ -3,13 +3,14 @@ title: Deployment Modes
|
||||
summary: local_trusted vs authenticated (private/public)
|
||||
---
|
||||
|
||||
Paperclip supports two runtime modes with different security profiles.
|
||||
Paperclip supports two runtime modes with different security profiles. Reachability is configured separately with `bind`.
|
||||
|
||||
## `local_trusted`
|
||||
|
||||
The default mode. Optimized for single-operator local use.
|
||||
|
||||
- **Host binding**: loopback only (localhost)
|
||||
- **Bind**: `loopback`
|
||||
- **Authentication**: no login required
|
||||
- **Use case**: local development, solo experimentation
|
||||
- **Board identity**: auto-created local board user
|
||||
@@ -31,6 +32,7 @@ For private network access (Tailscale, VPN, LAN).
|
||||
- **Authentication**: login required via Better Auth
|
||||
- **URL handling**: auto base URL mode (lower friction)
|
||||
- **Host trust**: private-host trust policy required
|
||||
- **Bind**: choose `loopback`, `lan`, `tailnet`, or `custom`
|
||||
|
||||
```sh
|
||||
pnpm paperclipai onboard
|
||||
@@ -50,6 +52,7 @@ For internet-facing deployment.
|
||||
- **Authentication**: login required
|
||||
- **URL**: explicit public URL required
|
||||
- **Security**: stricter deployment checks in doctor
|
||||
- **Bind**: usually `loopback` behind a reverse proxy; `lan/custom` is advanced
|
||||
|
||||
```sh
|
||||
pnpm paperclipai onboard
|
||||
@@ -81,5 +84,5 @@ pnpm paperclipai configure --section server
|
||||
Runtime override via environment variable:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated pnpm paperclipai run
|
||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated PAPERCLIP_BIND=lan pnpm paperclipai run
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ Run Paperclip in Docker without installing Node or pnpm locally.
|
||||
## Compose Quickstart (Recommended)
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.quickstart.yml up --build
|
||||
docker compose -f docker/docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
Open [http://localhost:3100](http://localhost:3100).
|
||||
@@ -21,10 +21,12 @@ Defaults:
|
||||
Override with environment variables:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc \
|
||||
docker compose -f docker-compose.quickstart.yml up --build
|
||||
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=../data/pc \
|
||||
docker compose -f docker/docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
**Note:** `PAPERCLIP_DATA_DIR` is resolved relative to the compose file (`docker/`), so `../data/pc` maps to `data/pc` in the project root.
|
||||
|
||||
## Manual Docker Build
|
||||
|
||||
```sh
|
||||
|
||||
@@ -10,11 +10,14 @@ All environment variables that Paperclip uses for server configuration.
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `3100` | Server port |
|
||||
| `HOST` | `127.0.0.1` | Server host binding |
|
||||
| `PAPERCLIP_BIND` | `loopback` | Reachability preset: `loopback`, `lan`, `tailnet`, or `custom` |
|
||||
| `PAPERCLIP_BIND_HOST` | (unset) | Required when `PAPERCLIP_BIND=custom` |
|
||||
| `HOST` | `127.0.0.1` | Legacy host override; prefer `PAPERCLIP_BIND` for new setups |
|
||||
| `DATABASE_URL` | (embedded) | PostgreSQL connection string |
|
||||
| `PAPERCLIP_HOME` | `~/.paperclip` | Base directory for all Paperclip data |
|
||||
| `PAPERCLIP_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) |
|
||||
| `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override |
|
||||
| `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` |
|
||||
|
||||
## Secrets
|
||||
|
||||
|
||||
@@ -38,19 +38,26 @@ This does:
|
||||
2. Runs `paperclipai doctor` with repair enabled
|
||||
3. Starts the server when checks pass
|
||||
|
||||
## Tailscale/Private Auth Dev Mode
|
||||
## Bind Presets In Dev
|
||||
|
||||
To run in `authenticated/private` mode for network access:
|
||||
Default `pnpm dev` stays in `local_trusted` with loopback-only binding.
|
||||
|
||||
To open Paperclip to a private network with login enabled:
|
||||
|
||||
```sh
|
||||
pnpm dev --bind lan
|
||||
```
|
||||
|
||||
For Tailscale-only binding on a detected tailnet address:
|
||||
|
||||
```sh
|
||||
pnpm dev --bind tailnet
|
||||
```
|
||||
|
||||
Legacy aliases still work and map to the older broad private-network behavior:
|
||||
|
||||
```sh
|
||||
pnpm dev --tailscale-auth
|
||||
```
|
||||
|
||||
This binds the server to `0.0.0.0` for private-network access.
|
||||
|
||||
Alias:
|
||||
|
||||
```sh
|
||||
pnpm dev --authenticated-private
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Tailscale Private Access
|
||||
summary: Run Paperclip with Tailscale-friendly host binding and connect from other devices
|
||||
summary: Run Paperclip with Tailscale-friendly bind presets and connect from other devices
|
||||
---
|
||||
|
||||
Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) instead of only `localhost`.
|
||||
@@ -8,20 +8,25 @@ Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN)
|
||||
## 1. Start Paperclip in private authenticated mode
|
||||
|
||||
```sh
|
||||
pnpm dev --tailscale-auth
|
||||
pnpm dev --bind tailnet
|
||||
```
|
||||
|
||||
This configures:
|
||||
Recommended behavior:
|
||||
|
||||
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
|
||||
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
|
||||
- `PAPERCLIP_AUTH_BASE_URL_MODE=auto`
|
||||
- `HOST=0.0.0.0` (bind on all interfaces)
|
||||
- `PAPERCLIP_BIND=tailnet`
|
||||
|
||||
Equivalent flag:
|
||||
If you want the old broad private-network behavior instead, use:
|
||||
|
||||
```sh
|
||||
pnpm dev --bind lan
|
||||
```
|
||||
|
||||
Legacy aliases still map to `authenticated/private + bind=lan`:
|
||||
|
||||
pnpm dev --authenticated-private
|
||||
pnpm dev --tailscale-auth
|
||||
```
|
||||
|
||||
## 2. Find your reachable Tailscale address
|
||||
@@ -73,5 +78,5 @@ Expected result:
|
||||
## Troubleshooting
|
||||
|
||||
- Login or redirect errors on a private hostname: add it with `paperclipai allowed-hostname`.
|
||||
- App only works on `localhost`: make sure you started with `--tailscale-auth` (or set `HOST=0.0.0.0` in private mode).
|
||||
- App only works on `localhost`: make sure you started with `--bind lan` or `--bind tailnet` instead of plain `pnpm dev`.
|
||||
- Can connect locally but not remotely: verify both devices are on the same Tailscale network and port `3100` is reachable.
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
"guides/board-operator/managing-agents",
|
||||
"guides/board-operator/org-structure",
|
||||
"guides/board-operator/managing-tasks",
|
||||
"guides/board-operator/execution-workspaces-and-runtime-services",
|
||||
"guides/board-operator/delegation",
|
||||
"guides/board-operator/approvals",
|
||||
"guides/board-operator/costs-and-budgets",
|
||||
"guides/board-operator/activity-log",
|
||||
@@ -96,6 +98,8 @@
|
||||
"adapters/codex-local",
|
||||
"adapters/process",
|
||||
"adapters/http",
|
||||
"adapters/external-adapters",
|
||||
"adapters/adapter-ui-parser",
|
||||
"adapters/creating-an-adapter"
|
||||
]
|
||||
}
|
||||
|
||||
193
docs/feedback-voting.md
Normal file
193
docs/feedback-voting.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Feedback Voting — Local Data Guide
|
||||
|
||||
When you rate an agent's response with **Helpful** (thumbs up) or **Needs work** (thumbs down), Paperclip saves your vote locally alongside your running instance. This guide covers what gets stored, how to access it, and how to export it.
|
||||
|
||||
## How voting works
|
||||
|
||||
1. Click **Helpful** or **Needs work** on any agent comment or document revision.
|
||||
2. If you click **Needs work**, an optional text prompt appears: _"What could have been better?"_ You can type a reason or dismiss it.
|
||||
3. A consent dialog asks whether to keep the vote local or share it. Your choice is remembered for future votes.
|
||||
|
||||
### What gets stored
|
||||
|
||||
Each vote creates two local records:
|
||||
|
||||
| Record | What it contains |
|
||||
|--------|-----------------|
|
||||
| **Vote** | Your vote (up/down), optional reason text, sharing preference, consent version, timestamp |
|
||||
| **Trace bundle** | Full context snapshot: the voted-on comment/revision text, issue title, agent info, your vote, and reason — everything needed to understand the feedback in isolation |
|
||||
|
||||
All data lives in your local Paperclip database. Nothing leaves your machine unless you explicitly choose to share.
|
||||
|
||||
When a vote is marked for sharing, Paperclip immediately tries to upload the trace bundle through the Telemetry Backend. The upload is compressed in transit so full trace bundles stay under gateway size limits. If that immediate push fails, the trace is left in a retriable failed state for later flush attempts. The app server never uploads raw feedback trace bundles directly to object storage.
|
||||
|
||||
## Viewing your votes
|
||||
|
||||
### Quick report (terminal)
|
||||
|
||||
```bash
|
||||
pnpm paperclipai feedback report
|
||||
```
|
||||
|
||||
Shows a color-coded summary: vote counts, per-trace details with reasons, and export statuses.
|
||||
|
||||
```bash
|
||||
# Installed CLI
|
||||
paperclipai feedback report
|
||||
|
||||
# Point to a different server or company
|
||||
pnpm paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
|
||||
|
||||
# Include raw payload dumps in the report
|
||||
pnpm paperclipai feedback report --payloads
|
||||
```
|
||||
|
||||
### API endpoints
|
||||
|
||||
All endpoints require board-user access (automatic in local dev).
|
||||
|
||||
**List votes for an issue:**
|
||||
```bash
|
||||
curl http://127.0.0.1:3102/api/issues/<issueId>/feedback-votes
|
||||
```
|
||||
|
||||
**List trace bundles for an issue (with full payloads):**
|
||||
```bash
|
||||
curl 'http://127.0.0.1:3102/api/issues/<issueId>/feedback-traces?includePayload=true'
|
||||
```
|
||||
|
||||
**List all traces company-wide:**
|
||||
```bash
|
||||
curl 'http://127.0.0.1:3102/api/companies/<companyId>/feedback-traces?includePayload=true'
|
||||
```
|
||||
|
||||
**Get a single trace envelope record:**
|
||||
```bash
|
||||
curl http://127.0.0.1:3102/api/feedback-traces/<traceId>
|
||||
```
|
||||
|
||||
**Get the full export bundle for a trace:**
|
||||
```bash
|
||||
curl http://127.0.0.1:3102/api/feedback-traces/<traceId>/bundle
|
||||
```
|
||||
|
||||
#### Filtering
|
||||
|
||||
The trace endpoints accept query parameters:
|
||||
|
||||
| Parameter | Values | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `vote` | `up`, `down` | Filter by vote direction |
|
||||
| `status` | `local_only`, `pending`, `sent`, `failed` | Filter by export status |
|
||||
| `targetType` | `issue_comment`, `issue_document_revision` | Filter by what was voted on |
|
||||
| `sharedOnly` | `true` | Only show votes the user chose to share |
|
||||
| `includePayload` | `true` | Include the full context snapshot |
|
||||
| `from` / `to` | ISO date | Date range filter |
|
||||
|
||||
## Exporting your data
|
||||
|
||||
### Export to files + zip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai feedback export
|
||||
```
|
||||
|
||||
Creates a timestamped directory with:
|
||||
|
||||
```
|
||||
feedback-export-20260331T120000Z/
|
||||
index.json # manifest with summary stats
|
||||
votes/
|
||||
PAP-123-a1b2c3d4.json # vote metadata (one per vote)
|
||||
traces/
|
||||
PAP-123-e5f6g7h8.json # Paperclip feedback envelope (one per trace)
|
||||
full-traces/
|
||||
PAP-123-e5f6g7h8/
|
||||
bundle.json # full export manifest for the trace
|
||||
...raw adapter files # codex / claude / opencode session artifacts when available
|
||||
feedback-export-20260331T120000Z.zip
|
||||
```
|
||||
|
||||
Exports are full by default. `traces/` keeps the Paperclip envelope, while `full-traces/` contains the richer per-trace bundle plus any recoverable adapter-native files.
|
||||
|
||||
```bash
|
||||
# Custom server and output directory
|
||||
pnpm paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
|
||||
```
|
||||
|
||||
### Reading an exported trace
|
||||
|
||||
Open any file in `traces/` to see:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "trace-uuid",
|
||||
"vote": "down",
|
||||
"issueIdentifier": "PAP-123",
|
||||
"issueTitle": "Fix login timeout",
|
||||
"targetType": "issue_comment",
|
||||
"targetSummary": {
|
||||
"label": "Comment",
|
||||
"excerpt": "The first 80 chars of the comment that was voted on..."
|
||||
},
|
||||
"payloadSnapshot": {
|
||||
"vote": {
|
||||
"value": "down",
|
||||
"reason": "Did not address the root cause"
|
||||
},
|
||||
"target": {
|
||||
"body": "Full text of the agent comment..."
|
||||
},
|
||||
"issue": {
|
||||
"identifier": "PAP-123",
|
||||
"title": "Fix login timeout"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Open `full-traces/<issue>-<trace>/bundle.json` to see the expanded export metadata, including capture notes, adapter type, integrity metadata, and the inventory of raw files written alongside it.
|
||||
|
||||
Each entry in `bundle.json.files[]` includes the actual captured file payload under `contents`, not just a pathname. For text artifacts this is stored as UTF-8 text; binary artifacts use base64 plus an `encoding` marker.
|
||||
|
||||
Built-in local adapters now export their native session artifacts more directly:
|
||||
|
||||
- `codex_local`: `adapter/codex/session.jsonl`
|
||||
- `claude_local`: `adapter/claude/session.jsonl`, plus any `adapter/claude/session/...` sidecar files and `adapter/claude/debug.txt` when present
|
||||
- `opencode_local`: `adapter/opencode/session.json`, `adapter/opencode/messages/*.json`, and `adapter/opencode/parts/<messageId>/*.json`, with optional `project.json`, `todo.json`, and `session-diff.json`
|
||||
|
||||
## Sharing preferences
|
||||
|
||||
The first time you vote, a consent dialog asks:
|
||||
|
||||
- **Keep local** — vote is stored locally only (`sharedWithLabs: false`)
|
||||
- **Share this vote** — vote is marked for sharing (`sharedWithLabs: true`)
|
||||
|
||||
Your preference is saved per-company. You can change it any time via the feedback settings. Votes marked "keep local" are never queued for export.
|
||||
|
||||
## Data lifecycle
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `local_only` | Vote stored locally, not marked for sharing |
|
||||
| `pending` | Marked for sharing, saved locally, and waiting for the immediate upload attempt |
|
||||
| `sent` | Successfully transmitted |
|
||||
| `failed` | Transmission attempted but failed (for example the backend is unreachable or not configured); later flushes retry once a backend is available |
|
||||
|
||||
Your local database always retains the full vote and trace data regardless of sharing status.
|
||||
|
||||
## Remote sync
|
||||
|
||||
Votes you choose to share are sent to the Telemetry Backend immediately from the vote request. The server also keeps a background flush worker so failed traces can retry later. The Telemetry Backend validates the request, then persists the bundle into its configured object storage.
|
||||
|
||||
- App server responsibility: build the bundle, POST it to Telemetry Backend, update trace status
|
||||
- Telemetry Backend responsibility: authenticate the request, validate payload shape, compress/store the bundle, return the final object key
|
||||
- Retry behavior: failed uploads move to `failed` with an error message in `failureReason`, and the worker retries them on later ticks
|
||||
- Default endpoint: when no feedback export backend URL is configured, Paperclip falls back to `https://telemetry.paperclip.ing`
|
||||
- Important nuance: the uploaded object is a snapshot of the full bundle at vote time. If you fetch a local bundle later and the underlying adapter session file has continued to grow, the local regenerated bundle may be larger than the already-uploaded snapshot for that same trace.
|
||||
|
||||
Exported objects use a deterministic key pattern so they are easy to inspect:
|
||||
|
||||
```text
|
||||
feedback-traces/<companyId>/YYYY/MM/DD/<exportId-or-traceId>.json
|
||||
```
|
||||
@@ -31,14 +31,14 @@ Close linked issues if the approval resolves them, or comment on why they remain
|
||||
### Step 3: Get Assignments
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/issues?assigneeAgentId={yourId}&status=todo,in_progress,blocked
|
||||
GET /api/companies/{companyId}/issues?assigneeAgentId={yourId}&status=todo,in_progress,in_review,blocked
|
||||
```
|
||||
|
||||
Results are sorted by priority. This is your inbox.
|
||||
|
||||
### Step 4: Pick Work
|
||||
|
||||
- Work on `in_progress` tasks first, then `todo`
|
||||
- Work on `in_progress` tasks first, then `in_review` when you were woken by a comment on it, then `todo`
|
||||
- Skip `blocked` unless you can unblock it
|
||||
- If `PAPERCLIP_TASK_ID` is set and assigned to you, prioritize it
|
||||
- If woken by a comment mention, read that comment thread first
|
||||
@@ -50,7 +50,7 @@ Before doing any work, you must checkout the task:
|
||||
```
|
||||
POST /api/issues/{issueId}/checkout
|
||||
Headers: X-Paperclip-Run-Id: {runId}
|
||||
{ "agentId": "{yourId}", "expectedStatuses": ["todo", "backlog", "blocked"] }
|
||||
{ "agentId": "{yourId}", "expectedStatuses": ["todo", "backlog", "blocked", "in_review"] }
|
||||
```
|
||||
|
||||
If already checked out by you, this succeeds. If another agent owns it: `409 Conflict` — stop and pick a different task. **Never retry a 409.**
|
||||
|
||||
@@ -11,7 +11,7 @@ Before doing any work on a task, checkout is required:
|
||||
|
||||
```
|
||||
POST /api/issues/{issueId}/checkout
|
||||
{ "agentId": "{yourId}", "expectedStatuses": ["todo", "backlog", "blocked"] }
|
||||
{ "agentId": "{yourId}", "expectedStatuses": ["todo", "backlog", "blocked", "in_review"] }
|
||||
```
|
||||
|
||||
This is an atomic operation. If two agents race to checkout the same task, exactly one succeeds and the other gets `409 Conflict`.
|
||||
@@ -82,8 +82,8 @@ This releases your ownership. Leave a comment explaining why.
|
||||
|
||||
```
|
||||
GET /api/agents/me
|
||||
GET /api/companies/company-1/issues?assigneeAgentId=agent-42&status=todo,in_progress,blocked
|
||||
# -> [{ id: "issue-101", status: "in_progress" }, { id: "issue-99", status: "todo" }]
|
||||
GET /api/companies/company-1/issues?assigneeAgentId=agent-42&status=todo,in_progress,in_review,blocked
|
||||
# -> [{ id: "issue-101", status: "in_progress" }, { id: "issue-100", status: "in_review" }, { id: "issue-99", status: "todo" }]
|
||||
|
||||
# Continue in_progress work
|
||||
GET /api/issues/issue-101
|
||||
@@ -96,7 +96,7 @@ PATCH /api/issues/issue-101
|
||||
|
||||
# Pick up next task
|
||||
POST /api/issues/issue-99/checkout
|
||||
{ "agentId": "agent-42", "expectedStatuses": ["todo"] }
|
||||
{ "agentId": "agent-42", "expectedStatuses": ["todo", "backlog", "blocked", "in_review"] }
|
||||
|
||||
# Partial progress
|
||||
PATCH /api/issues/issue-99
|
||||
|
||||
122
docs/guides/board-operator/delegation.md
Normal file
122
docs/guides/board-operator/delegation.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
title: How Delegation Works
|
||||
summary: How the CEO breaks down goals into tasks and assigns them to agents
|
||||
---
|
||||
|
||||
Delegation is one of Paperclip's most powerful features. You set company goals, and the CEO agent automatically breaks them into tasks and assigns them to the right agents. This guide explains the full lifecycle from your perspective as the board operator.
|
||||
|
||||
## The Delegation Lifecycle
|
||||
|
||||
When you create a company goal, the CEO doesn't just acknowledge it — it builds a plan and mobilizes the team:
|
||||
|
||||
```
|
||||
You set a company goal
|
||||
→ CEO wakes up on heartbeat
|
||||
→ CEO proposes a strategy (creates an approval for you)
|
||||
→ You approve the strategy
|
||||
→ CEO breaks goals into tasks and assigns them to reports
|
||||
→ Reports wake up (heartbeat triggered by assignment)
|
||||
→ Reports execute work and update task status
|
||||
→ CEO monitors progress, unblocks, and escalates
|
||||
→ You see results in the dashboard and activity log
|
||||
```
|
||||
|
||||
Each step is traceable. Every task links back to the goal through a parent hierarchy, so you can always see why work is happening.
|
||||
|
||||
## What You Need to Do
|
||||
|
||||
Your role is strategic oversight, not task management. Here's what the delegation model expects from you:
|
||||
|
||||
1. **Set clear company goals.** The CEO works from these. Specific, measurable goals produce better delegation. "Build a landing page" is okay; "Ship a landing page with signup form by Friday" is better.
|
||||
|
||||
2. **Approve the CEO's strategy.** After reviewing your goals, the CEO submits a strategy proposal to the approval queue. Review it, then approve, reject, or request revisions.
|
||||
|
||||
3. **Approve hire requests.** When the CEO needs more capacity (e.g., a frontend engineer to build the landing page), it submits a hire request. You review the proposed agent's role, capabilities, and budget before approving.
|
||||
|
||||
4. **Monitor progress.** Use the dashboard and activity log to track how work is flowing. Check task status, agent activity, and completion rates.
|
||||
|
||||
5. **Intervene only when things stall.** If progress stops, check these in order:
|
||||
- Is an approval pending in your queue?
|
||||
- Is an agent paused or in an error state?
|
||||
- Is the CEO's budget exhausted (above 80%, it focuses on critical tasks only)?
|
||||
|
||||
## What the CEO Does Automatically
|
||||
|
||||
You do **not** need to tell the CEO to engage specific agents. After you approve its strategy, the CEO:
|
||||
|
||||
- **Breaks goals into concrete tasks** with clear descriptions, priorities, and acceptance criteria
|
||||
- **Assigns tasks to the right agent** based on role and capabilities (e.g., engineering tasks go to the CTO or engineers, marketing tasks go to the CMO)
|
||||
- **Creates subtasks** when work needs to be decomposed further
|
||||
- **Hires new agents** when the team lacks capacity for a goal (subject to your approval)
|
||||
- **Monitors progress** on each heartbeat, checking task status and unblocking reports
|
||||
- **Escalates to you** when it encounters something it can't resolve — budget issues, blocked approvals, or strategic ambiguity
|
||||
|
||||
## Common Delegation Patterns
|
||||
|
||||
### Flat Hierarchy (Small Teams)
|
||||
|
||||
For small companies with 3-5 agents, the CEO delegates directly to each report:
|
||||
|
||||
```
|
||||
CEO
|
||||
├── CTO (engineering tasks)
|
||||
├── CMO (marketing tasks)
|
||||
└── Designer (design tasks)
|
||||
```
|
||||
|
||||
The CEO assigns tasks directly. Each agent works independently and reports status back.
|
||||
|
||||
### Three-Level Hierarchy (Larger Teams)
|
||||
|
||||
For larger organizations, managers delegate further down the chain:
|
||||
|
||||
```
|
||||
CEO
|
||||
├── CTO
|
||||
│ ├── Backend Engineer
|
||||
│ └── Frontend Engineer
|
||||
└── CMO
|
||||
└── Content Writer
|
||||
```
|
||||
|
||||
The CEO assigns high-level tasks to the CTO and CMO. They break those into subtasks and assign them to their own reports. You only interact with the CEO — the rest happens automatically.
|
||||
|
||||
### Hire-on-Demand
|
||||
|
||||
The CEO can start as the only agent and hire as work requires:
|
||||
|
||||
1. You set a goal that needs engineering work
|
||||
2. The CEO proposes a strategy that includes hiring a CTO
|
||||
3. You approve the hire
|
||||
4. The CEO assigns engineering tasks to the new CTO
|
||||
5. As scope grows, the CTO may request to hire engineers
|
||||
|
||||
This pattern lets you start small and scale the team based on actual work, not upfront planning.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Why isn't the CEO delegating?"
|
||||
|
||||
If you've set a goal but nothing is happening, check these common causes:
|
||||
|
||||
| Check | What to look for |
|
||||
|-------|-----------------|
|
||||
| **Approval queue** | The CEO may have submitted a strategy or hire request that's waiting for your approval. This is the most common reason. |
|
||||
| **Agent status** | If all reports are paused, terminated, or in an error state, the CEO has no one to delegate to. Check the Agents page. |
|
||||
| **Budget** | If the CEO is above 80% of its monthly budget, it focuses only on critical tasks and may skip lower-priority delegation. |
|
||||
| **Goals** | If no company goals are set, the CEO has nothing to work from. Create a goal first. |
|
||||
| **Heartbeat** | Is the CEO's heartbeat enabled and running? Check the agent detail page for recent heartbeat history. |
|
||||
| **Agent instructions** | The CEO's delegation behavior is driven by its `AGENTS.md` instructions file. Open the CEO agent's detail page and verify that its instructions path is set and that the file includes delegation directives (subtask creation, hiring, assignment). If AGENTS.md is missing or doesn't mention delegation, the CEO won't know to break down goals and assign work. |
|
||||
|
||||
### "Do I have to tell the CEO to engage engineering and marketing?"
|
||||
|
||||
**No.** The CEO will delegate automatically after you approve its strategy. It knows the org chart and assigns tasks based on each agent's role and capabilities. You set the goal and approve the plan — the CEO handles task breakdown and assignment.
|
||||
|
||||
### "A task seems stuck"
|
||||
|
||||
If a specific task isn't progressing:
|
||||
|
||||
1. Check the task's comment thread — the assigned agent may have posted a blocker
|
||||
2. Check if the task is in `blocked` status — read the blocker comment to understand why
|
||||
3. Check the assigned agent's status — it may be paused or over budget
|
||||
4. If the agent is stuck, you can reassign the task or add a comment with guidance
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: Execution Workspaces And Runtime Services
|
||||
summary: How project runtime configuration, execution workspaces, and issue runs fit together
|
||||
---
|
||||
|
||||
This guide documents the intended runtime model for projects, execution workspaces, and issue runs in Paperclip.
|
||||
|
||||
Paperclip now presents this as a workspace-command model:
|
||||
|
||||
- `Services` are long-running commands that stay supervised.
|
||||
- `Jobs` are one-shot commands that run once and exit.
|
||||
- Raw runtime JSON is still available for advanced config, but it is no longer the primary mental model.
|
||||
|
||||
## Project runtime configuration
|
||||
|
||||
You can define how to run a project on the project workspace itself.
|
||||
|
||||
- Project workspace runtime config describes the services and jobs available for that project checkout.
|
||||
- This is the default runtime configuration that child execution workspaces may inherit.
|
||||
- Defining the config does not start anything by itself.
|
||||
|
||||
## Manual runtime control
|
||||
|
||||
Workspace commands are manually controlled from the UI.
|
||||
|
||||
- Project workspace services are started and stopped from the project workspace UI, and project jobs can be run on demand there.
|
||||
- Execution workspace services are started and stopped from the execution workspace UI, and execution-workspace jobs can be run on demand there.
|
||||
- Paperclip does not automatically start or stop these workspace services as part of issue execution.
|
||||
- Paperclip also does not automatically restart workspace services on server boot.
|
||||
|
||||
## Execution workspace inheritance
|
||||
|
||||
Execution workspaces isolate code and runtime state from the project primary workspace.
|
||||
|
||||
- An isolated execution workspace has its own checkout path, branch, and local runtime instance.
|
||||
- The runtime configuration may inherit from the linked project workspace by default.
|
||||
- The execution workspace may override that runtime configuration with its own workspace-specific settings.
|
||||
- The inherited configuration answers "which commands exist and how to run them", but any running service process is still specific to that execution workspace.
|
||||
|
||||
## Issues and execution workspaces
|
||||
|
||||
Issues are attached to execution workspace behavior, not to automatic runtime management.
|
||||
|
||||
- An issue may create a new execution workspace when you choose an isolated workspace mode.
|
||||
- An issue may reuse an existing execution workspace when you choose reuse.
|
||||
- Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services.
|
||||
- Assigning or running an issue does not automatically start or stop workspace services for that workspace.
|
||||
|
||||
## Execution workspace lifecycle
|
||||
|
||||
Execution workspaces are durable until a human closes them.
|
||||
|
||||
- The UI can archive an execution workspace.
|
||||
- Closing an execution workspace stops its runtime services and cleans up its workspace artifacts when allowed.
|
||||
- Shared workspaces that point at the project primary checkout are treated more conservatively during cleanup than disposable isolated workspaces.
|
||||
|
||||
## Resolved workspace logic during heartbeat runs
|
||||
|
||||
Heartbeat still resolves a workspace for the run, but that is about code location and session continuity, not runtime-service control.
|
||||
|
||||
1. Heartbeat resolves a base workspace for the run.
|
||||
2. Paperclip realizes the effective execution workspace, including creating or reusing a worktree when needed.
|
||||
3. Paperclip persists execution-workspace metadata such as paths, refs, and provisioning settings.
|
||||
4. Heartbeat passes the resolved code workspace to the agent run.
|
||||
5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services.
|
||||
|
||||
## Current implementation guarantees
|
||||
|
||||
With the current implementation:
|
||||
|
||||
- Project workspace command config is the fallback for execution workspace UI controls.
|
||||
- Execution workspace runtime overrides are stored on the execution workspace.
|
||||
- Heartbeat runs do not auto-start workspace services.
|
||||
- Server startup does not auto-restart workspace services.
|
||||
@@ -29,7 +29,7 @@ Create agents from the Agents page. Each agent requires:
|
||||
|
||||
Common adapter choices:
|
||||
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
|
||||
- `openclaw` / `http` for webhook-based external agents
|
||||
- `openclaw_gateway` / `http` for webhook-based external agents
|
||||
- `process` for generic local command execution
|
||||
|
||||
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
|
||||
|
||||
269
docs/guides/execution-policy.md
Normal file
269
docs/guides/execution-policy.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Execution Policy: Review & Approval Workflows
|
||||
|
||||
Paperclip's execution policy system ensures tasks are completed with the right level of oversight. Instead of relying on agents to remember to hand off work for review, the **runtime enforces** review and approval stages automatically.
|
||||
|
||||
## Overview
|
||||
|
||||
An execution policy is an optional structured object on any issue that defines what must happen after the executor finishes their work. It supports three layers of enforcement:
|
||||
|
||||
| Layer | Purpose | Scope |
|
||||
|---|---|---|
|
||||
| **Comment required** | Every agent run must post a comment back to the issue | Runtime invariant (always on) |
|
||||
| **Review stage** | A reviewer checks quality/correctness and can request changes | Per-issue, optional |
|
||||
| **Approval stage** | A manager/stakeholder gives final sign-off | Per-issue, optional |
|
||||
|
||||
These layers compose. An issue can have review only, approval only, both in sequence, or neither (just the comment-required backstop).
|
||||
|
||||
## Data Model
|
||||
|
||||
### Execution Policy (issue field: `executionPolicy`)
|
||||
|
||||
```ts
|
||||
interface IssueExecutionPolicy {
|
||||
mode: "normal" | "auto";
|
||||
commentRequired: boolean; // always true, enforced by runtime
|
||||
stages: IssueExecutionStage[]; // ordered list of review/approval stages
|
||||
}
|
||||
|
||||
interface IssueExecutionStage {
|
||||
id: string; // auto-generated UUID
|
||||
type: "review" | "approval"; // stage kind
|
||||
approvalsNeeded: 1; // multi-approval is not supported yet
|
||||
participants: IssueExecutionStageParticipant[];
|
||||
}
|
||||
|
||||
interface IssueExecutionStageParticipant {
|
||||
id: string;
|
||||
type: "agent" | "user";
|
||||
agentId?: string | null; // set when type is "agent"
|
||||
userId?: string | null; // set when type is "user"
|
||||
}
|
||||
```
|
||||
|
||||
Participants can be either agents or board users. Each stage can have multiple participants; the runtime selects the first eligible participant, preferring any explicitly requested assignee while excluding the original executor.
|
||||
|
||||
### Execution State (issue field: `executionState`)
|
||||
|
||||
Tracks where the issue currently sits in its policy workflow:
|
||||
|
||||
```ts
|
||||
interface IssueExecutionState {
|
||||
status: "idle" | "pending" | "changes_requested" | "completed";
|
||||
currentStageId: string | null;
|
||||
currentStageIndex: number | null;
|
||||
currentStageType: "review" | "approval" | null;
|
||||
currentParticipant: IssueExecutionStagePrincipal | null;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
completedStageIds: string[];
|
||||
lastDecisionId: string | null;
|
||||
lastDecisionOutcome: "approved" | "changes_requested" | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Execution Decisions (table: `issue_execution_decisions`)
|
||||
|
||||
An audit trail of every review/approval action:
|
||||
|
||||
```ts
|
||||
interface IssueExecutionDecision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
stageId: string;
|
||||
stageType: "review" | "approval";
|
||||
actorAgentId: string | null;
|
||||
actorUserId: string | null;
|
||||
outcome: "approved" | "changes_requested";
|
||||
body: string; // required comment explaining the decision
|
||||
createdByRunId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Happy Path: Review + Approval
|
||||
|
||||
```
|
||||
┌──────────┐ executor ┌───────────┐ reviewer ┌───────────┐ approver ┌──────┐
|
||||
│ todo │───completes───▶│ in_review │───approves───▶│ in_review │───approves───▶│ done │
|
||||
│ (Coder) │ work │ (QA) │ │ (CTO) │ │ │
|
||||
└──────────┘ └───────────┘ └───────────┘ └──────┘
|
||||
```
|
||||
|
||||
1. **Issue created** with `executionPolicy` specifying a review stage (e.g., QA) and an approval stage (e.g., CTO).
|
||||
2. **Executor works** on the issue in `in_progress` status.
|
||||
3. **Executor transitions to `done`** — the runtime intercepts this:
|
||||
- Status changes to `in_review` (not `done`)
|
||||
- Issue is reassigned to the first reviewer
|
||||
- `executionState` enters `pending` on the review stage
|
||||
4. **Reviewer reviews** and transitions to `done` with a comment:
|
||||
- A decision record is created: `{ outcome: "approved" }`
|
||||
- Issue stays `in_review`, reassigned to the approver
|
||||
- `executionState` advances to the approval stage
|
||||
5. **Approver approves** and transitions to `done` with a comment:
|
||||
- A decision record is created: `{ outcome: "approved" }`
|
||||
- `executionState.status` becomes `completed`
|
||||
- Issue reaches actual `done` status
|
||||
|
||||
### Changes Requested Flow
|
||||
|
||||
```
|
||||
┌───────────┐ reviewer requests ┌─────────────┐ executor ┌───────────┐
|
||||
│ in_review │───changes────────────▶│ in_progress │───resubmits──▶│ in_review │
|
||||
│ (QA) │ │ (Coder) │ │ (QA) │
|
||||
└───────────┘ └──────────────┘ └───────────┘
|
||||
```
|
||||
|
||||
1. **Reviewer requests changes** by transitioning to any status other than `done` (typically `in_progress`), with a comment explaining what needs to change.
|
||||
2. Runtime automatically:
|
||||
- Sets status to `in_progress`
|
||||
- Reassigns to the original executor (stored in `returnAssignee`)
|
||||
- Sets `executionState.status` to `changes_requested`
|
||||
3. **Executor makes changes** and transitions to `done` again.
|
||||
4. Runtime routes back to the **same review stage** (not the beginning), with the same reviewer.
|
||||
5. This loop continues until the reviewer approves.
|
||||
|
||||
### Policy Variants
|
||||
|
||||
**Review only** (no approval stage):
|
||||
```json
|
||||
{
|
||||
"stages": [
|
||||
{ "type": "review", "participants": [{ "type": "agent", "agentId": "qa-agent-id" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
Executor finishes → reviewer approves → done.
|
||||
|
||||
**Approval only** (no review stage):
|
||||
```json
|
||||
{
|
||||
"stages": [
|
||||
{ "type": "approval", "participants": [{ "type": "user", "userId": "manager-user-id" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
Executor finishes → approver signs off → done.
|
||||
|
||||
**Multiple reviewers/approvers:**
|
||||
Each stage supports multiple participants. The runtime selects one to act, excluding the original executor to prevent self-review.
|
||||
|
||||
## Comment Required Backstop
|
||||
|
||||
Independent of review stages, every issue-bound agent run must leave a comment. This is enforced at the runtime level:
|
||||
|
||||
1. **Run completes** — runtime checks if the agent posted a comment for this run.
|
||||
2. **If no comment**: `issueCommentStatus` is set to `retry_queued`, and the agent is woken once more with reason `missing_issue_comment`.
|
||||
3. **If still no comment after retry**: `issueCommentStatus` is set to `retry_exhausted`. No further retries. The failure is recorded.
|
||||
4. **If comment posted**: `issueCommentStatus` is set to `satisfied` and linked to the comment ID.
|
||||
|
||||
This prevents silent completions where an agent finishes work but leaves no trace of what happened.
|
||||
|
||||
### Run-level tracking fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `issueCommentStatus` | `satisfied`, `retry_queued`, or `retry_exhausted` |
|
||||
| `issueCommentSatisfiedByCommentId` | Links to the comment that fulfilled the requirement |
|
||||
| `issueCommentRetryQueuedAt` | Timestamp when the retry wake was scheduled |
|
||||
|
||||
## Access Control
|
||||
|
||||
- Only the **active reviewer/approver** (the `currentParticipant` in execution state) can advance or reject the current stage.
|
||||
- Non-participants who attempt to transition the issue receive a `422 Unprocessable Entity` error.
|
||||
- Both approvals and change requests **require a comment** — empty or whitespace-only comments are rejected.
|
||||
|
||||
## API Usage
|
||||
|
||||
### Setting an execution policy on issue creation
|
||||
|
||||
```bash
|
||||
POST /api/companies/{companyId}/issues
|
||||
{
|
||||
"title": "Implement feature X",
|
||||
"assigneeAgentId": "coder-agent-id",
|
||||
"executionPolicy": {
|
||||
"mode": "normal",
|
||||
"commentRequired": true,
|
||||
"stages": [
|
||||
{
|
||||
"type": "review",
|
||||
"participants": [
|
||||
{ "type": "agent", "agentId": "qa-agent-id" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "approval",
|
||||
"participants": [
|
||||
{ "type": "user", "userId": "cto-user-id" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stage IDs and participant IDs are auto-generated if omitted. Duplicate participants within a stage are automatically deduplicated. Stages with no valid participants are removed. If no valid stages remain, the policy is set to `null`.
|
||||
|
||||
### Updating execution policy on an existing issue
|
||||
|
||||
```bash
|
||||
PATCH /api/issues/{issueId}
|
||||
{
|
||||
"executionPolicy": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
If the policy is removed (`null`) while a review is in progress, the execution state is cleared and the issue is returned to the original executor.
|
||||
|
||||
### Advancing a stage (reviewer/approver approves)
|
||||
|
||||
The active reviewer or approver transitions the issue to `done` with a comment:
|
||||
|
||||
```bash
|
||||
PATCH /api/issues/{issueId}
|
||||
{
|
||||
"status": "done",
|
||||
"comment": "Reviewed — implementation looks correct, tests pass."
|
||||
}
|
||||
```
|
||||
|
||||
The runtime determines whether this completes the workflow or advances to the next stage.
|
||||
|
||||
### Requesting changes
|
||||
|
||||
The active reviewer transitions to any non-`done` status with a comment:
|
||||
|
||||
```bash
|
||||
PATCH /api/issues/{issueId}
|
||||
{
|
||||
"status": "in_progress",
|
||||
"comment": "Button alignment is off on mobile. Please fix the flex container."
|
||||
}
|
||||
```
|
||||
|
||||
The runtime reassigns to the original executor automatically.
|
||||
|
||||
## UI
|
||||
|
||||
### New Issue Dialog
|
||||
|
||||
When creating a new issue, **Reviewer** and **Approver** buttons appear alongside the assignee selector. Clicking either opens a participant picker with:
|
||||
- "No reviewer" / "No approver" (to clear)
|
||||
- "Me" (current user)
|
||||
- Full list of agents and board users
|
||||
|
||||
Selections build the `executionPolicy.stages` array automatically.
|
||||
|
||||
### Issue Properties Pane
|
||||
|
||||
For existing issues, the properties panel shows editable **Reviewer** and **Approver** fields. Multiple participants can be added per stage. Changes persist to the issue's `executionPolicy` via the API.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Runtime-enforced, not prompt-dependent.** Agents don't need to remember to hand off work. The runtime intercepts status transitions and routes accordingly.
|
||||
2. **Iterative, not terminal.** Review is a loop (request changes → revise → re-review), not a one-shot gate. The system returns to the same stage on re-submission.
|
||||
3. **Flexible roles.** Participants can be agents or users. Not every organization has "QA" — the reviewer/approver pattern is generic enough for peer review, manager sign-off, compliance checks, or any multi-party workflow.
|
||||
4. **Auditable.** Every decision is recorded with actor, outcome, comment, and run ID. The full review history is queryable per issue.
|
||||
5. **Single execution invariant preserved.** Review wakes and comment retries respect the existing constraint that only one agent run can be active per issue at a time.
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Core Concepts
|
||||
summary: Companies, agents, issues, heartbeats, and governance
|
||||
summary: Companies, agents, issues, delegation, heartbeats, and governance
|
||||
---
|
||||
|
||||
Paperclip organizes autonomous AI work around five key concepts.
|
||||
Paperclip organizes autonomous AI work around six key concepts.
|
||||
|
||||
## Company
|
||||
|
||||
@@ -50,6 +50,17 @@ Terminal states: `done`, `cancelled`.
|
||||
|
||||
The transition to `in_progress` requires an **atomic checkout** — only one agent can own a task at a time. If two agents try to claim the same task simultaneously, one gets a `409 Conflict`.
|
||||
|
||||
## Delegation
|
||||
|
||||
The CEO is the primary delegator. When you set company goals, the CEO:
|
||||
|
||||
1. Creates a strategy and submits it for your approval
|
||||
2. Breaks approved goals into tasks
|
||||
3. Assigns tasks to agents based on their role and capabilities
|
||||
4. Hires new agents when needed (subject to your approval)
|
||||
|
||||
You don't need to manually assign every task — set the goals and let the CEO organize the work. You approve key decisions (strategy, hiring) and monitor progress. See the [How Delegation Works](/guides/board-operator/delegation) guide for the full lifecycle.
|
||||
|
||||
## Heartbeats
|
||||
|
||||
Agents don't run continuously. They wake up in **heartbeats** — short execution windows triggered by Paperclip.
|
||||
|
||||
@@ -13,6 +13,8 @@ npx paperclipai onboard --yes
|
||||
|
||||
This walks you through setup, configures your environment, and gets Paperclip running.
|
||||
|
||||
If you already have a Paperclip install, rerunning `onboard` keeps your current config and data paths intact. Use `paperclipai configure` if you want to edit settings.
|
||||
|
||||
To start Paperclip again later:
|
||||
|
||||
```sh
|
||||
|
||||
32
package.json
32
package.json
@@ -3,15 +3,18 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-runner.mjs watch",
|
||||
"dev:watch": "node scripts/dev-runner.mjs watch",
|
||||
"dev:once": "node scripts/dev-runner.mjs dev",
|
||||
"preflight:workspace-links": "node cli/node_modules/tsx/dist/cli.mjs scripts/ensure-workspace-package-links.ts",
|
||||
"dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
|
||||
"dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
|
||||
"dev:once": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts dev",
|
||||
"dev:list": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts list",
|
||||
"dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop",
|
||||
"dev:server": "pnpm --filter @paperclipai/server dev",
|
||||
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
||||
"build": "pnpm -r build",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"build": "pnpm run preflight:workspace-links && pnpm -r build",
|
||||
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
|
||||
"test": "pnpm run preflight:workspace-links && vitest",
|
||||
"test:run": "pnpm run preflight:workspace-links && vitest run",
|
||||
"db:generate": "pnpm --filter @paperclipai/db generate",
|
||||
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
||||
"secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts",
|
||||
@@ -32,11 +35,12 @@
|
||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
|
||||
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
|
||||
"test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts",
|
||||
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
|
||||
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed",
|
||||
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
@@ -44,5 +48,13 @@
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4"
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": ">=4.59.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user