Compare commits
225
Commits
master
...
925100fb82
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925100fb82 | ||
|
|
a664e72488 | ||
|
|
21317123a4 | ||
|
|
409245cb52 | ||
|
|
1aeb6fdb35 | ||
|
|
816fa96e07 | ||
|
|
8fbe4218c6 | ||
|
|
00c8df0fc9 | ||
|
|
c52c7ead19 | ||
|
|
d1e8a827c2 | ||
|
|
12d96fb03d | ||
|
|
171a191873 | ||
|
|
47dabd8793 | ||
|
|
2bb661f1cf | ||
|
|
04e296a4ef | ||
|
|
4bba227af5 | ||
|
|
5cc78d63c6 | ||
|
|
323f5dc09c | ||
|
|
fb97edfe95 | ||
|
|
070f62ef12 | ||
|
|
981749aa3d | ||
|
|
e01b46b30a | ||
|
|
a1b659c45d | ||
|
|
37a012ef92 | ||
|
|
4927e8a843 | ||
|
|
025d6ddb47 | ||
|
|
01a4dfd5d3 | ||
|
|
1d7158a0bf | ||
|
|
9de2afbfc6 | ||
|
|
9013754a3a | ||
|
|
21eea0b104 | ||
|
|
15e8d7365c | ||
|
|
88e3bf7065 | ||
|
|
2765138bf3 | ||
|
|
6b20ceac46 | ||
|
|
d748274905 | ||
|
|
e1578217d5 | ||
|
|
3481682cb4 | ||
|
|
996b7f2468 | ||
|
|
879993b9b1 | ||
|
|
6604154e3f | ||
|
|
a9ad42a970 | ||
|
|
8b3d1302c6 | ||
|
|
ac9269d6ce | ||
|
|
8ffb716817 | ||
|
|
23f671fa48 | ||
|
|
d7cdcde443 | ||
|
|
95a81faf63 | ||
|
|
bb8bb6d099 | ||
|
|
310801a29b | ||
|
|
7d09b20445 | ||
|
|
456a06f194 | ||
|
|
62eaefb1fa | ||
|
|
10264b4019 | ||
|
|
ab9765d91d | ||
|
|
e7f4c6864f | ||
|
|
bde1dea2a5 | ||
|
|
13a021c480 | ||
|
|
a7f09fad98 | ||
|
|
10eaf4a5fb | ||
|
|
bcada300e3 | ||
|
|
9756174676 | ||
|
|
6dd8461a46 | ||
|
|
12cc2eb0e9 | ||
|
|
38dd717aa6 | ||
|
|
6640c902de | ||
|
|
583c343d08 | ||
|
|
dfc48f7a05 | ||
|
|
cd9d854595 | ||
|
|
766cbd17e5 | ||
|
|
6df95bf981 | ||
|
|
6398ca0893 | ||
|
|
62372a48cc | ||
|
|
733632509a | ||
|
|
e1c13ec314 | ||
|
|
928ff0eabe | ||
|
|
406559b13d | ||
|
|
20c16aa6fd | ||
|
|
44ba5fd6d4 | ||
|
|
745c6adbf2 | ||
|
|
a9aa09636f | ||
|
|
6945986dd1 | ||
|
|
80c1f48f0e | ||
|
|
31e18205f0 | ||
|
|
e84a9d3f9b | ||
|
|
133feb8c76 | ||
|
|
c5fd9c01e5 | ||
|
|
a7bf5ceac3 | ||
|
|
74139aeb7e | ||
|
|
0cae4fd05c | ||
|
|
4b3b4fda61 | ||
|
|
adb684a6bf | ||
|
|
8493472983 | ||
|
|
862eeb7add | ||
|
|
4d9b211d69 | ||
|
|
ebb272324c | ||
|
|
c0290512b3 | ||
|
|
4ec56fe41e | ||
|
|
f8a7c46cf9 | ||
|
|
2bab8a9bb6 | ||
|
|
89e6a6215a | ||
|
|
88be87e03e | ||
|
|
22867faa9c | ||
|
|
16c0fc704d | ||
|
|
32fdd076bf | ||
|
|
402ae0d466 | ||
|
|
acb3c6d68b | ||
|
|
40ac83e632 | ||
|
|
58da395941 | ||
|
|
0e3ef94c9e | ||
|
|
1f68dfc2b5 | ||
|
|
84977a464c | ||
|
|
62ada5eaa4 | ||
|
|
f5ff0b7c13 | ||
|
|
3337cafcdf | ||
|
|
e87784118b | ||
|
|
40fada28ea | ||
|
|
58cc94d4b7 | ||
|
|
ccabea59c9 | ||
|
|
8cc1dc042d | ||
|
|
183c37446e | ||
|
|
7aa06afc45 | ||
|
|
1515a2fb86 | ||
|
|
ec798c58d7 | ||
|
|
e365189276 | ||
|
|
116d610ad0 | ||
|
|
75c570962d | ||
|
|
cae8ac1799 | ||
|
|
917cc222a3 | ||
|
|
7edc588202 | ||
|
|
c83461508b | ||
|
|
651d64f34d | ||
|
|
b98d4b59f5 | ||
|
|
374449e663 | ||
|
|
4c876a201b | ||
|
|
21b3dd1da1 | ||
|
|
5ca0ea9228 | ||
|
|
d5c3a68a37 | ||
|
|
2b33b9158d | ||
|
|
b31642e284 | ||
|
|
3a7a3307ef | ||
|
|
0496cd907b | ||
|
|
975b4fa700 | ||
|
|
060f280fdf | ||
|
|
7aaf189247 | ||
|
|
df6d99c07d | ||
|
|
63306cf017 | ||
|
|
08be5e85e4 | ||
|
|
9843510e1f | ||
|
|
83bda3dfb2 | ||
|
|
0ab15aa227 | ||
|
|
29c2fb8e06 | ||
|
|
4ebc465e8d | ||
|
|
4b132a21e9 | ||
|
|
b644971d45 | ||
|
|
df34533765 | ||
|
|
6f4efb36bb | ||
|
|
108d5b14d7 | ||
|
|
f633b86b35 | ||
|
|
1873e18f8e | ||
|
|
3cdcbb47bf | ||
|
|
aaa9c7987c | ||
|
|
048007a042 | ||
|
|
cb35b40b9d | ||
|
|
3b71fe03b4 | ||
|
|
471db64bcc | ||
|
|
5e2234763c | ||
|
|
1d140be715 | ||
|
|
ffb2a34ae5 | ||
|
|
ccf7de1a55 | ||
|
|
ccf3c80d29 | ||
|
|
3a3c89e0b4 | ||
|
|
17c629136a | ||
|
|
52a5c4141f | ||
|
|
c9ba27c333 | ||
|
|
46f6e2c58b | ||
|
|
d1f47e5a22 | ||
|
|
65de94bad3 | ||
|
|
4e935c6203 | ||
|
|
eac07cf5a8 | ||
|
|
260259d461 | ||
|
|
a9fb092834 | ||
|
|
3a21a68792 | ||
|
|
9d572d18bc | ||
|
|
f7852e8034 | ||
|
|
8c075de147 | ||
|
|
864367f4f5 | ||
|
|
33db2ea7f4 | ||
|
|
8396d09891 | ||
|
|
bf7171924d | ||
|
|
097c363fbc | ||
|
|
7dd8809e38 | ||
|
|
1749757036 | ||
|
|
5857e6121c | ||
|
|
a41147916b | ||
|
|
cabe38db1d | ||
|
|
f079479160 | ||
|
|
87e160a01a | ||
|
|
f94d829bf8 | ||
|
|
9a05bfa0c3 | ||
|
|
c1d46859a3 | ||
|
|
87ecbcb113 | ||
|
|
1fb2949561 | ||
|
|
11be777fc0 | ||
|
|
ff94161fc0 | ||
|
|
c2ab9a950f | ||
|
|
8e26a0f5a8 | ||
|
|
d1c15ee295 | ||
|
|
00a96234c6 | ||
|
|
fc3b663510 | ||
|
|
e4e045d059 | ||
|
|
436feaf33d | ||
|
|
29f450b962 | ||
|
|
db343893c8 | ||
|
|
554906ec02 | ||
|
|
18c37f4842 | ||
|
|
83382b824a | ||
|
|
6203316aa1 | ||
|
|
d57b4d1d5e | ||
|
|
379ae214fc | ||
|
|
163a403636 | ||
|
|
53edaadc3a | ||
|
|
3c2664c3ce | ||
|
|
d9048954a5 | ||
|
|
4f84dfd73f |
@@ -0,0 +1,3 @@
|
||||
[build]
|
||||
build-dir = "{cargo-cache-home}/build/{workspace-path-hash}"
|
||||
incremental = false
|
||||
Generated
+554
-6
@@ -26,6 +26,16 @@ dependencies = [
|
||||
"pom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
@@ -37,6 +47,20 @@ dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agen"
|
||||
version = "0.2.1"
|
||||
@@ -326,6 +350,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -338,6 +368,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "base64urlsafedata"
|
||||
version = "0.5.5"
|
||||
@@ -349,6 +385,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt-pbkdf"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
|
||||
dependencies = [
|
||||
"blowfish",
|
||||
"pbkdf2",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
@@ -403,6 +450,16 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blowfish"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -435,6 +492,12 @@ version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -495,6 +558,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -563,8 +637,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
name = "client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -575,7 +650,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"uuid",
|
||||
"workdir",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
@@ -654,6 +728,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
@@ -937,6 +1017,18 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -966,6 +1058,41 @@ dependencies = [
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1056,6 +1183,16 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "9.0.0"
|
||||
@@ -1114,7 +1251,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1124,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.1",
|
||||
]
|
||||
|
||||
@@ -1175,12 +1314,66 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -1284,6 +1477,22 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -1526,6 +1735,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1568,6 +1778,16 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.33.0"
|
||||
@@ -1636,6 +1856,17 @@ dependencies = [
|
||||
"memmap2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@@ -1724,6 +1955,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.26.0"
|
||||
@@ -2212,6 +2452,9 @@ name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
@@ -2386,6 +2629,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"agen",
|
||||
"arc-swap",
|
||||
"decodal",
|
||||
"protocol",
|
||||
"secrets",
|
||||
"serde",
|
||||
@@ -2672,6 +2916,22 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -2698,6 +2958,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2705,6 +2975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2765,6 +3036,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.76"
|
||||
@@ -2818,6 +3095,44 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p384"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p521"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"rand_core 0.6.4",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2847,6 +3162,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdf-extract"
|
||||
version = "0.10.0"
|
||||
@@ -2864,6 +3188,15 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -3009,6 +3342,27 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -3021,6 +3375,29 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pom"
|
||||
version = "1.1.0"
|
||||
@@ -3101,6 +3478,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -3121,6 +3507,7 @@ dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
@@ -3525,6 +3912,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3539,6 +3936,27 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sha2 0.10.9",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.37.0"
|
||||
@@ -3745,6 +4163,20 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrets"
|
||||
version = "0.1.0"
|
||||
@@ -3970,10 +4402,12 @@ dependencies = [
|
||||
"agen",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"fs4",
|
||||
"futures",
|
||||
"protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -4060,6 +4494,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -4103,12 +4547,99 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-cipher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"cbc",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"poly1305",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-encoding"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"pem-rfc7468",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-key"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
|
||||
dependencies = [
|
||||
"bcrypt-pbkdf",
|
||||
"ed25519-dalek",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
"rand_core 0.6.4",
|
||||
"rsa",
|
||||
"sec1",
|
||||
"sha2 0.10.9",
|
||||
"signature",
|
||||
"ssh-cipher",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "standalone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agen",
|
||||
"async-trait",
|
||||
"client",
|
||||
"fs4",
|
||||
"futures",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"worker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@@ -4798,7 +5329,6 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"client",
|
||||
"crossterm 0.28.1",
|
||||
"fs4",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"pulldown-cmark",
|
||||
@@ -4807,13 +5337,14 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"standalone",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"tokio",
|
||||
"toml",
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
"worker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4934,6 +5465,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
@@ -6054,6 +6595,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6101,6 +6643,7 @@ dependencies = [
|
||||
"wasmtime",
|
||||
"wat",
|
||||
"workdir",
|
||||
"workspace-api",
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
@@ -6131,9 +6674,12 @@ dependencies = [
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml",
|
||||
"tower",
|
||||
"url",
|
||||
"uuid",
|
||||
"workdir",
|
||||
"worker",
|
||||
"workspace-api",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6143,7 +6689,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ts-rs",
|
||||
"workdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6257,11 +6802,13 @@ dependencies = [
|
||||
"project-record",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.11.0",
|
||||
"ssh-key",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
@@ -6278,6 +6825,7 @@ dependencies = [
|
||||
"worker",
|
||||
"worker-runtime",
|
||||
"workspace-api",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/agen",
|
||||
"crates/agen-macros",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/secrets",
|
||||
"crates/manifest",
|
||||
"crates/mcp",
|
||||
@@ -36,6 +37,7 @@ default-members = [
|
||||
"crates/agen",
|
||||
"crates/agen-macros",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/secrets",
|
||||
"crates/manifest",
|
||||
"crates/mcp",
|
||||
@@ -87,6 +89,7 @@ protocol = { path = "crates/protocol" }
|
||||
session-metrics = { path = "crates/session-metrics" }
|
||||
session-analytics = { path = "crates/session-analytics" }
|
||||
session-store = { path = "crates/session-store" }
|
||||
standalone = { path = "crates/standalone" }
|
||||
secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
config-source = { path = "crates/config-source" }
|
||||
@@ -115,6 +118,7 @@ tar = "0.4"
|
||||
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
||||
ring = "0.17.14"
|
||||
sha2 = "0.11"
|
||||
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
|
||||
tempfile = "3.27"
|
||||
thiserror = "2.0"
|
||||
tokio = "1.52"
|
||||
@@ -124,4 +128,5 @@ toml = "1.1"
|
||||
tracing = "0.1"
|
||||
url = "2.5"
|
||||
uuid = "1.23"
|
||||
zeroize = "1"
|
||||
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
||||
- "8787"
|
||||
volumes:
|
||||
- server-data:/server-data
|
||||
- ./docker/workspace:/workspace:ro
|
||||
- /etc/yoi/server.toml:/server-config/server.toml:ro
|
||||
|
||||
webui:
|
||||
image: yoi-webui:latest
|
||||
|
||||
@@ -21,20 +21,21 @@ agen = { version = "0.2.1", features = ["codex"] }
|
||||
|
||||
## Quick start
|
||||
|
||||
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
|
||||
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), keep conversation history in your application, then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
|
||||
|
||||
```no_run
|
||||
use agen::{Engine, EngineError};
|
||||
use agen::{Engine, EngineError, History};
|
||||
use agen::llm_client::LlmClient;
|
||||
|
||||
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
|
||||
let mut history = History::new();
|
||||
let output = Engine::new(client)
|
||||
.system_prompt("You are a concise assistant.")
|
||||
.run("Explain typed state in one sentence.")
|
||||
.await?;
|
||||
.run(&mut history, "Explain typed state in one sentence.")
|
||||
.await;
|
||||
|
||||
let mut engine = output.engine;
|
||||
let _result = engine.run("Give a Rust example.").await?;
|
||||
let _result = engine.run(&mut history, "Give a Rust example.").await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use agen::{Engine, EngineResult};
|
||||
use agen::{Engine, EngineRunExit, StopReason};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
||||
let engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
println!("🚀 Starting Engine...");
|
||||
println!("💡 Will cancel after 2 seconds\n");
|
||||
@@ -45,16 +46,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
println!("📡 Sending request to LLM...");
|
||||
|
||||
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
|
||||
Ok(out) => match out.result {
|
||||
EngineResult::Finished => println!("✅ Task completed normally"),
|
||||
EngineResult::Paused => println!("⏸️ Task paused"),
|
||||
EngineResult::LimitReached => println!("🔒 Turn limit reached"),
|
||||
EngineResult::Yielded => println!("↩️ Task yielded"),
|
||||
},
|
||||
Err(e) => {
|
||||
println!("❌ Task error: {}", e);
|
||||
let output = engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await;
|
||||
match output.result {
|
||||
EngineRunExit::Finished => println!("✅ Task completed normally"),
|
||||
EngineRunExit::Paused => println!("⏸️ Task paused"),
|
||||
EngineRunExit::Yielded => println!("↩️ Task yielded"),
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
||||
println!("🔒 Turn limit reached")
|
||||
}
|
||||
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
|
||||
}
|
||||
|
||||
println!("\n✨ Demo complete!");
|
||||
|
||||
@@ -39,7 +39,7 @@ use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use agen::{
|
||||
Engine,
|
||||
Engine, EngineRunExit, StopReason,
|
||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||
llm_client::{
|
||||
LlmClient,
|
||||
@@ -451,6 +451,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Create Engine
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
@@ -476,12 +477,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// One-shot mode
|
||||
if let Some(prompt) = args.prompt {
|
||||
match engine.run(&prompt).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let output = engine.run(&mut history, &prompt).await;
|
||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
|
||||
eprintln!("\n❌ Error: {error}");
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
@@ -500,13 +498,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut locked = match engine.run(first_input).await {
|
||||
Ok(out) => out.engine,
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let output = engine.run(&mut history, first_input).await;
|
||||
let mut locked = output.engine;
|
||||
|
||||
loop {
|
||||
print!("\n👤 You: ");
|
||||
@@ -525,11 +518,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
break;
|
||||
}
|
||||
|
||||
match locked.run(input).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
}
|
||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) =
|
||||
locked.run(&mut history, input).await
|
||||
{
|
||||
eprintln!("\n❌ Error: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+857
-254
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
//! Typed conversation history containers.
|
||||
//!
|
||||
//! Agen keeps provider-visible [`Item`](crate::Item) values separate from any
|
||||
//! host-domain provenance. The host chooses the annotation type `A`, while Agen
|
||||
//! preserves each item and annotation as one entry for clone/truncate/restore
|
||||
//! style history operations.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Item;
|
||||
|
||||
/// One conversation-history entry with host-owned annotation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HistoryEntry<A = ()> {
|
||||
/// Provider/model-visible conversation item.
|
||||
pub item: Item,
|
||||
/// Host-domain metadata kept with the item and never projected to providers.
|
||||
pub annotation: A,
|
||||
}
|
||||
|
||||
impl<A> HistoryEntry<A> {
|
||||
/// Build an entry from an item and its annotation.
|
||||
pub fn new(item: Item, annotation: A) -> Self {
|
||||
Self { item, annotation }
|
||||
}
|
||||
|
||||
/// Split the entry into its item and annotation.
|
||||
pub fn into_parts(self) -> (Item, A) {
|
||||
(self.item, self.annotation)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryEntry<()> {
|
||||
/// Build a unit-annotated entry.
|
||||
pub fn from_item(item: Item) -> Self {
|
||||
Self {
|
||||
item,
|
||||
annotation: (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversation history with one annotation per item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct History<A = ()> {
|
||||
entries: Vec<HistoryEntry<A>>,
|
||||
}
|
||||
|
||||
impl<A> History<A> {
|
||||
/// Create an empty history.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build history from already annotated entries, preserving order.
|
||||
pub fn from_entries(entries: Vec<HistoryEntry<A>>) -> Self {
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
/// Replace all entries as one restore/rebuild operation and return the old entries.
|
||||
pub fn replace_entries(&mut self, entries: Vec<HistoryEntry<A>>) -> Vec<HistoryEntry<A>> {
|
||||
std::mem::replace(&mut self.entries, entries)
|
||||
}
|
||||
|
||||
/// Borrow annotated entries.
|
||||
pub fn entries(&self) -> &[HistoryEntry<A>] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Mutably borrow annotated entries for host-owned rebuild operations.
|
||||
pub fn entries_mut(&mut self) -> &mut [HistoryEntry<A>] {
|
||||
&mut self.entries
|
||||
}
|
||||
|
||||
/// Consume the history into annotated entries.
|
||||
pub fn into_entries(self) -> Vec<HistoryEntry<A>> {
|
||||
self.entries
|
||||
}
|
||||
|
||||
/// Number of entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Whether the history is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Iterate over annotated entries.
|
||||
pub fn iter(&self) -> impl ExactSizeIterator<Item = &HistoryEntry<A>> {
|
||||
self.entries.iter()
|
||||
}
|
||||
|
||||
/// Iterate over provider-visible items only.
|
||||
pub fn items(&self) -> impl ExactSizeIterator<Item = &Item> {
|
||||
self.entries.iter().map(|entry| &entry.item)
|
||||
}
|
||||
|
||||
/// Clone provider-visible items into a request-local projection.
|
||||
pub fn items_cloned(&self) -> Vec<Item> {
|
||||
self.items().cloned().collect()
|
||||
}
|
||||
|
||||
/// Append an already annotated entry.
|
||||
pub fn push_entry(&mut self, entry: HistoryEntry<A>) {
|
||||
self.entries.push(entry);
|
||||
}
|
||||
|
||||
/// Append many already annotated entries.
|
||||
pub fn extend_entries(&mut self, entries: impl IntoIterator<Item = HistoryEntry<A>>) {
|
||||
self.entries.extend(entries);
|
||||
}
|
||||
|
||||
/// Commit one item through a trusted annotation callback before it becomes live.
|
||||
///
|
||||
/// The callback may durably persist the item and returns the annotation that
|
||||
/// must be stored with it. If the callback fails, the history is left unchanged.
|
||||
pub fn append_with(
|
||||
&mut self,
|
||||
item: Item,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<(), String> {
|
||||
let annotation = annotate(&item)?;
|
||||
self.entries.push(HistoryEntry { item, annotation });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit items through a trusted annotation callback before they become live.
|
||||
///
|
||||
/// Items before a failure remain appended; the failing item and later items do
|
||||
/// not enter history. This mirrors append-only durable logs where each accepted
|
||||
/// item is already committed before the next item is attempted.
|
||||
pub fn extend_with(
|
||||
&mut self,
|
||||
items: impl IntoIterator<Item = Item>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<(), String> {
|
||||
for item in items {
|
||||
self.append_with(item, annotate)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Truncate entries, preserving item+annotation pairing for retained entries.
|
||||
pub fn truncate(&mut self, len: usize) {
|
||||
self.entries.truncate(len);
|
||||
}
|
||||
|
||||
/// Clear all entries.
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl History<()> {
|
||||
/// Build unit-annotated history from provider-visible items.
|
||||
pub fn from_items(items: Vec<Item>) -> Self {
|
||||
Self {
|
||||
entries: items.into_iter().map(HistoryEntry::from_item).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace history from provider-visible items using unit annotations.
|
||||
pub fn replace_items(&mut self, items: Vec<Item>) -> Vec<HistoryEntry<()>> {
|
||||
self.replace_entries(items.into_iter().map(HistoryEntry::from_item).collect())
|
||||
}
|
||||
|
||||
/// Append one item with unit annotation.
|
||||
pub fn push(&mut self, item: Item) {
|
||||
self.entries.push(HistoryEntry::from_item(item));
|
||||
}
|
||||
|
||||
/// Append items with unit annotations.
|
||||
pub fn extend_items(&mut self, items: impl IntoIterator<Item = Item>) {
|
||||
self.entries
|
||||
.extend(items.into_iter().map(HistoryEntry::from_item));
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> IntoIterator for History<A> {
|
||||
type Item = HistoryEntry<A>;
|
||||
type IntoIter = std::vec::IntoIter<HistoryEntry<A>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.entries.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, A> IntoIterator for &'a History<A> {
|
||||
type Item = &'a HistoryEntry<A>;
|
||||
type IntoIter = std::slice::Iter<'a, HistoryEntry<A>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.entries.iter()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod engine;
|
||||
mod handler;
|
||||
mod history;
|
||||
mod message;
|
||||
|
||||
pub(crate) mod callback;
|
||||
@@ -20,13 +21,18 @@ pub mod usage_record;
|
||||
pub use agen_macros::{description, tool, tool_registry};
|
||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||
pub use engine::{
|
||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice,
|
||||
ToolRegistryError,
|
||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
||||
LlmRetryNotice, StopReason, ToolRegistryError,
|
||||
};
|
||||
pub use handler::ToolUseBlockStart;
|
||||
pub use history::{History, HistoryEntry};
|
||||
pub use interceptor::Interceptor;
|
||||
pub use message::{ContentPart, Item, Message, Role};
|
||||
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
|
||||
pub use tool::{
|
||||
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
|
||||
ToolExecutionTerminal, ToolExecutionTerminalFuture, ToolOutputLimits, ToolResult,
|
||||
ToolResultDisposition,
|
||||
};
|
||||
pub use usage_record::UsageRecord;
|
||||
|
||||
/// Implementation dependencies used by code generated from `agen` macros.
|
||||
|
||||
@@ -18,6 +18,9 @@ pub enum ClientError {
|
||||
message: String,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// The provider rejected the request because it exceeded the model context window.
|
||||
/// Classified only from a structured provider error code, never message text.
|
||||
ContextWindowExceeded,
|
||||
/// A request lifecycle phase exceeded its hard timeout.
|
||||
Timeout {
|
||||
phase: &'static str,
|
||||
@@ -48,6 +51,7 @@ impl fmt::Display for ClientError {
|
||||
}
|
||||
write!(f, ": {}", message)
|
||||
}
|
||||
ClientError::ContextWindowExceeded => write!(f, "Model context window reached"),
|
||||
ClientError::Timeout { phase, timeout } => {
|
||||
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
||||
}
|
||||
@@ -112,7 +116,10 @@ pub fn is_retryable(error: &ClientError) -> bool {
|
||||
ClientError::Api { status: None, .. } => false,
|
||||
ClientError::Timeout { .. } => true,
|
||||
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
||||
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
|
||||
ClientError::ContextWindowExceeded
|
||||
| ClientError::Json(_)
|
||||
| ClientError::Sse(_)
|
||||
| ClientError::Config(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -431,13 +431,7 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
|
||||
}
|
||||
|
||||
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api { code, message, .. } => {
|
||||
code.as_deref() == Some("context_length_exceeded")
|
||||
|| message.contains("context_length_exceeded")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
matches!(error, ClientError::ContextWindowExceeded)
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
@@ -487,6 +481,9 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&text)
|
||||
.to_string();
|
||||
if code.as_deref() == Some("context_length_exceeded") {
|
||||
return ClientError::ContextWindowExceeded;
|
||||
}
|
||||
ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use crate::tool::Attachment;
|
||||
use crate::tool::{Attachment, ToolResultDisposition};
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -121,6 +121,9 @@ pub enum Item {
|
||||
/// Detailed output (removed by pruning when old enough)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
/// Typed terminal state used for replay and recovery.
|
||||
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||
disposition: ToolResultDisposition,
|
||||
/// Whether the tool result represents an execution error.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
@@ -261,7 +264,17 @@ impl Item {
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
|
||||
Self::tool_result_item_with_disposition_and_attachments(
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
if is_error {
|
||||
ToolResultDisposition::Error
|
||||
} else {
|
||||
ToolResultDisposition::Success
|
||||
},
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a tool result item with durable, prunable structured attachments.
|
||||
@@ -272,11 +285,33 @@ impl Item {
|
||||
is_error: bool,
|
||||
attachments: Vec<Attachment>,
|
||||
) -> Self {
|
||||
Self::tool_result_item_with_disposition_and_attachments(
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
if is_error {
|
||||
ToolResultDisposition::Error
|
||||
} else {
|
||||
ToolResultDisposition::Success
|
||||
},
|
||||
attachments,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn tool_result_item_with_disposition_and_attachments(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: Option<String>,
|
||||
disposition: ToolResultDisposition,
|
||||
attachments: Vec<Attachment>,
|
||||
) -> Self {
|
||||
let is_error = !disposition.is_success();
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content,
|
||||
disposition,
|
||||
is_error,
|
||||
attachments,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ mod private {
|
||||
/// - Editing message history (add, delete, clear)
|
||||
/// - Registering tools and hooks
|
||||
///
|
||||
/// Can transition to [`Locked`] state via `Engine::lock()`.
|
||||
/// Can transition to [`Locked`] state via `Engine::lock(&history)`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
|
||||
+227
-2
@@ -3,7 +3,14 @@
|
||||
//! Traits for defining tools callable by LLM.
|
||||
//! Usually auto-implemented using the `#[tool]` macro.
|
||||
|
||||
use std::{collections::HashMap, fmt, sync::Arc};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
@@ -23,6 +30,12 @@ pub enum ToolError {
|
||||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
/// Cooperative cancellation completed with bounded terminal output.
|
||||
#[error("Tool execution cancelled")]
|
||||
Cancelled(ToolOutput),
|
||||
/// Execution was interrupted with a confirmed bounded terminal output.
|
||||
#[error("Tool execution interrupted")]
|
||||
Interrupted(ToolOutput),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -158,6 +171,28 @@ pub enum Attachment {
|
||||
Image(ImageAttachment),
|
||||
}
|
||||
|
||||
/// Terminal disposition of one started tool call.
|
||||
///
|
||||
/// `Cancelled` means the tool confirmed cancellation. `OutcomeUnknown` means
|
||||
/// execution stopped without confirmation, so neither completion nor side
|
||||
/// effects may be inferred.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolResultDisposition {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
Interrupted,
|
||||
Cancelled,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
impl ToolResultDisposition {
|
||||
pub const fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Success)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool execution result.
|
||||
///
|
||||
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
||||
@@ -322,6 +357,12 @@ impl ToolExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies one live execution attempt without making the batch id a durable
|
||||
/// replay or idempotency authority.
|
||||
pub fn execution_id(&self) -> String {
|
||||
format!("{}:{}", self.batch_id, self.call_id)
|
||||
}
|
||||
|
||||
/// Context for direct, non-engine calls in unit tests and low-level callers.
|
||||
pub fn direct() -> Self {
|
||||
Self::new("direct", "direct", 0)
|
||||
@@ -334,6 +375,142 @@ impl Default for ToolExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider-confirmed terminal result of one started tool execution.
|
||||
///
|
||||
/// `OutcomeUnknown` is reserved for an execution task that had to be force-closed
|
||||
/// or failed before the provider could confirm its terminal result.
|
||||
#[derive(Debug)]
|
||||
pub enum ToolExecutionTerminal {
|
||||
Confirmed(Result<ToolOutput, ToolError>),
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
/// The completion future paired with a [`ToolExecutionHandle`]. Dropping this
|
||||
/// future does not drop the provider execution: the spawned execution remains
|
||||
/// owned by its handle until it completes or is explicitly force-closed.
|
||||
pub struct ToolExecutionTerminalFuture {
|
||||
task: tokio::task::JoinHandle<Result<ToolOutput, ToolError>>,
|
||||
}
|
||||
|
||||
impl Future for ToolExecutionTerminalFuture {
|
||||
type Output = ToolExecutionTerminal;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
match Pin::new(&mut self.task).poll(cx) {
|
||||
Poll::Ready(Ok(result)) => Poll::Ready(ToolExecutionTerminal::Confirmed(result)),
|
||||
Poll::Ready(Err(_)) => Poll::Ready(ToolExecutionTerminal::OutcomeUnknown),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live ownership and control for one started tool execution.
|
||||
///
|
||||
/// Execution, cancellation, and terminal confirmation remain provider-owned:
|
||||
/// this handle starts `Tool::execute`, delegates cooperative cancellation to
|
||||
/// `Tool::cancel_execution`, and treats execution-future completion as the
|
||||
/// provider's terminal confirmation. Agen may force-close only after its caller's
|
||||
/// deadline expires, at which point the outcome is necessarily unknown.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolExecutionHandle {
|
||||
inner: Arc<ToolExecutionHandleInner>,
|
||||
}
|
||||
|
||||
struct ToolExecutionHandleInner {
|
||||
tool: Arc<dyn Tool>,
|
||||
context: ToolExecutionContext,
|
||||
abort: tokio::task::AbortHandle,
|
||||
}
|
||||
|
||||
impl Drop for ToolExecutionHandleInner {
|
||||
fn drop(&mut self) {
|
||||
// Losing the final live owner is an explicit forced close, never a
|
||||
// best-effort detached provider future.
|
||||
self.abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ToolExecutionHandle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ToolExecutionHandle")
|
||||
.field("call_id", &self.inner.context.call_id)
|
||||
.field("batch_id", &self.inner.context.batch_id)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutionHandle {
|
||||
pub fn start(
|
||||
tool: Arc<dyn Tool>,
|
||||
input_json: String,
|
||||
context: ToolExecutionContext,
|
||||
) -> (Self, ToolExecutionTerminalFuture) {
|
||||
let execution_tool = Arc::clone(&tool);
|
||||
let execution_context = context.clone();
|
||||
let task =
|
||||
tokio::spawn(
|
||||
async move { execution_tool.execute(&input_json, execution_context).await },
|
||||
);
|
||||
let abort = task.abort_handle();
|
||||
(
|
||||
Self {
|
||||
inner: Arc::new(ToolExecutionHandleInner {
|
||||
tool,
|
||||
context,
|
||||
abort,
|
||||
}),
|
||||
},
|
||||
ToolExecutionTerminalFuture { task },
|
||||
)
|
||||
}
|
||||
|
||||
pub fn context(&self) -> &ToolExecutionContext {
|
||||
&self.inner.context
|
||||
}
|
||||
|
||||
pub async fn cancel_before(&self, deadline: tokio::time::Instant) -> Result<(), ToolError> {
|
||||
match tokio::time::timeout_at(
|
||||
deadline,
|
||||
self.inner.tool.cancel_execution(&self.inner.context),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(ToolError::Internal(format!(
|
||||
"tool cancellation request exceeded its deadline for call {}",
|
||||
self.inner.context.call_id
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn force_close(&self) {
|
||||
self.inner.abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ToolExecutionPolicy {
|
||||
/// Time a pause waits for already-started providers to reach a natural safe
|
||||
/// boundary before escalating to explicit cooperative cancellation.
|
||||
pub pause_safe_boundary_timeout: std::time::Duration,
|
||||
/// Maximum time allowed for a provider to accept one cooperative
|
||||
/// cancellation request.
|
||||
pub cancellation_request_timeout: std::time::Duration,
|
||||
/// Maximum time allowed for all providers to confirm terminal results after
|
||||
/// cancellation has been requested.
|
||||
pub terminal_confirmation_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for ToolExecutionPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pause_safe_boundary_timeout: std::time::Duration::from_millis(100),
|
||||
cancellation_request_timeout: std::time::Duration::from_millis(100),
|
||||
terminal_confirmation_timeout: std::time::Duration::from_millis(500),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool trait
|
||||
// =============================================================================
|
||||
@@ -402,6 +579,26 @@ pub trait Tool: Send + Sync {
|
||||
input_json: &str,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError>;
|
||||
|
||||
/// Request cooperative cancellation for one started call.
|
||||
///
|
||||
/// Implementations that own cancellable provider operations should signal
|
||||
/// every live execution identified by `call_id`, then let `execute` return
|
||||
/// the confirmed bounded terminal output. Direct callers may use this
|
||||
/// compatibility surface; Agen uses [`Tool::cancel_execution`] so providers
|
||||
/// can bind cancellation to one exact live attempt.
|
||||
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Request cooperative cancellation for one exact started execution.
|
||||
///
|
||||
/// The default preserves existing tools by delegating to `cancel(call_id)`.
|
||||
/// Providers with their own execution registry should override this method
|
||||
/// and key cancellation by [`ToolExecutionContext::execution_id`].
|
||||
async fn cancel_execution(&self, ctx: &ToolExecutionContext) -> Result<(), ToolError> {
|
||||
self.cancel(&ctx.call_id).await
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -429,6 +626,9 @@ pub struct ToolCall {
|
||||
pub struct ToolResult {
|
||||
/// Corresponding tool call ID
|
||||
pub tool_use_id: String,
|
||||
/// Typed terminal state.
|
||||
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||
pub disposition: ToolResultDisposition,
|
||||
/// Short summary (always kept in history)
|
||||
pub summary: String,
|
||||
/// Detailed output (prunable)
|
||||
@@ -445,11 +645,20 @@ pub struct ToolResult {
|
||||
impl ToolResult {
|
||||
/// Create a success result from a [`ToolOutput`].
|
||||
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
||||
Self::from_output_with_disposition(tool_use_id, output, ToolResultDisposition::Success)
|
||||
}
|
||||
|
||||
pub fn from_output_with_disposition(
|
||||
tool_use_id: impl Into<String>,
|
||||
output: ToolOutput,
|
||||
disposition: ToolResultDisposition,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
disposition,
|
||||
summary: output.summary,
|
||||
content: output.content,
|
||||
is_error: false,
|
||||
is_error: !disposition.is_success(),
|
||||
attachments: output.attachments,
|
||||
}
|
||||
}
|
||||
@@ -458,12 +667,28 @@ impl ToolResult {
|
||||
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
disposition: ToolResultDisposition::Error,
|
||||
summary: message.into(),
|
||||
content: None,
|
||||
is_error: true,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close an execution whose completion and side effects cannot be confirmed.
|
||||
pub fn outcome_unknown(tool_use_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||
summary: "Tool execution outcome unknown".to_string(),
|
||||
content: Some(
|
||||
"Execution was interrupted before completion could be confirmed. Completion and side effects are unknown."
|
||||
.to_string(),
|
||||
),
|
||||
is_error: true,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
mod common;
|
||||
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
|
||||
use common::MockLlmClient;
|
||||
|
||||
fn completed_text_events(text: &str) -> Vec<Event> {
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, text),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_preserves_item_annotations_without_projecting_them() {
|
||||
let client = MockLlmClient::new(completed_text_events("assistant reply"));
|
||||
let engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
|
||||
let mut history = History::<String>::new();
|
||||
let mut next = 0usize;
|
||||
let mut annotate = |item: &Item| {
|
||||
next += 1;
|
||||
let kind = match item {
|
||||
Item::Message { role, .. } => match role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
},
|
||||
Item::ToolCall { .. } => "tool_call",
|
||||
Item::ToolResult { .. } => "tool_result",
|
||||
Item::Reasoning { .. } => "reasoning",
|
||||
};
|
||||
Ok(format!("{next}:{kind}"))
|
||||
};
|
||||
|
||||
let output = engine
|
||||
.run_with_annotation(&mut history, "hello", &mut annotate)
|
||||
.await;
|
||||
|
||||
assert!(matches!(output.result, agen::EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history.entries()[0].annotation, "1:user");
|
||||
assert_eq!(history.entries()[1].annotation, "2:assistant");
|
||||
assert_eq!(history.items_cloned().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_failure_does_not_make_item_live() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::<_, agen::state::Mutable, usize>::new_annotated(client);
|
||||
let mut history = History::<usize>::new();
|
||||
let mut fail = |_item: &Item| Err("commit failed".to_string());
|
||||
|
||||
let err = engine
|
||||
.append_history_with(&mut history, [Item::user_message("uncommitted")], &mut fail)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, EngineError::HistoryAppend(message) if message == "commit failed"));
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_keeps_items_and_annotations_together() {
|
||||
let mut history = History::from_entries(vec![
|
||||
HistoryEntry::new(Item::user_message("old"), "old-ann".to_string()),
|
||||
HistoryEntry::new(Item::user_message("second"), "second-ann".to_string()),
|
||||
]);
|
||||
|
||||
history.truncate(1);
|
||||
assert_eq!(history.entries()[0].item.as_text(), Some("old"));
|
||||
assert_eq!(history.entries()[0].annotation, "old-ann");
|
||||
|
||||
let previous = history.replace_entries(vec![HistoryEntry::new(
|
||||
Item::user_message("restored"),
|
||||
"restored-ann".to_string(),
|
||||
)]);
|
||||
|
||||
assert_eq!(previous.len(), 1);
|
||||
assert_eq!(history.entries()[0].item.as_text(), Some("restored"));
|
||||
assert_eq!(history.entries()[0].annotation, "restored-ann");
|
||||
}
|
||||
@@ -58,6 +58,7 @@ async fn test_callback_llm_retry_event() {
|
||||
max_attempts: 2,
|
||||
total_timeout: Duration::from_secs(1),
|
||||
});
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let notices = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = notices.clone();
|
||||
@@ -65,8 +66,11 @@ async fn test_callback_llm_retry_event() {
|
||||
sink.lock().unwrap().push((llm_call, notice.clone()));
|
||||
});
|
||||
|
||||
let result = engine.run("retry once").await;
|
||||
assert!(result.is_ok(), "engine should succeed after one retry");
|
||||
let result = engine.run(&mut history, "retry once").await;
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"engine should succeed after one retry"
|
||||
);
|
||||
|
||||
let notices = notices.lock().unwrap();
|
||||
assert_eq!(notices.len(), 1);
|
||||
@@ -91,6 +95,7 @@ async fn test_callback_text_block_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
||||
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -108,9 +113,12 @@ async fn test_callback_text_block_events() {
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run("Greet me").await;
|
||||
assert!(result.is_ok(), "Engine should complete");
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let result = engine.run(&mut history, "Greet me").await;
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete"
|
||||
);
|
||||
|
||||
let deltas = text_deltas.lock().unwrap();
|
||||
assert_eq!(deltas.len(), 2);
|
||||
@@ -137,6 +145,7 @@ async fn test_callback_tool_call_complete() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
||||
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -154,8 +163,8 @@ async fn test_callback_tool_call_complete() {
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _ = engine.run("Weather please").await;
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let _ = engine.run(&mut history, "Weather please").await;
|
||||
|
||||
let starts = tool_starts.lock().unwrap();
|
||||
assert_eq!(starts.len(), 1);
|
||||
@@ -183,6 +192,7 @@ async fn test_callback_turn_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
||||
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -197,9 +207,9 @@ async fn test_callback_turn_events() {
|
||||
ends.lock().unwrap().push(turn);
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run("Do something").await;
|
||||
assert!(result.is_ok());
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let result = engine.run(&mut history, "Do something").await;
|
||||
assert!(matches!(result.result, agen::EngineRunExit::Finished));
|
||||
|
||||
let starts = turn_starts.lock().unwrap();
|
||||
let ends = turn_ends.lock().unwrap();
|
||||
@@ -254,6 +264,7 @@ async fn test_callback_tool_result_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
engine.register_tool(fixed_tool(
|
||||
"fixed",
|
||||
@@ -276,7 +287,7 @@ async fn test_callback_tool_result_events() {
|
||||
));
|
||||
});
|
||||
|
||||
let _ = engine.run("call it").await;
|
||||
let _ = engine.run(&mut history, "call it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
@@ -330,6 +341,7 @@ async fn test_callback_tool_result_error_path() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
engine.register_tool(erroring_tool("erroring", "boom"));
|
||||
|
||||
@@ -345,7 +357,7 @@ async fn test_callback_tool_result_error_path() {
|
||||
));
|
||||
});
|
||||
|
||||
let _ = engine.run("fail it").await;
|
||||
let _ = engine.run(&mut history, "fail it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
@@ -374,6 +386,7 @@ async fn test_callback_usage_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
@@ -382,8 +395,8 @@ async fn test_callback_usage_events() {
|
||||
usages.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _ = engine.run("Hello").await;
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let _ = engine.run(&mut history, "Hello").await;
|
||||
|
||||
let usages = usage_events.lock().unwrap();
|
||||
assert_eq!(usages.len(), 1);
|
||||
|
||||
@@ -19,6 +19,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
pub struct MockLlmClient {
|
||||
responses: Arc<Vec<Vec<Event>>>,
|
||||
call_count: Arc<AtomicUsize>,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
}
|
||||
|
||||
impl MockLlmClient {
|
||||
@@ -30,6 +31,7 @@ impl MockLlmClient {
|
||||
Self {
|
||||
responses: Arc::new(responses),
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +43,10 @@ impl MockLlmClient {
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.responses.iter().map(|v| v.len()).sum()
|
||||
}
|
||||
|
||||
pub fn requests(&self) -> Vec<Request> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -51,8 +57,9 @@ impl LlmClient for MockLlmClient {
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
if count >= self.responses.len() {
|
||||
return Err(ClientError::Api {
|
||||
|
||||
@@ -134,11 +134,15 @@ async fn test_engine_simple_text_response() {
|
||||
|
||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||
let engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
// Send a simple message (Mutable::run consumes self, returns tuple)
|
||||
let result = engine.run("Hello").await;
|
||||
let result = engine.run(&mut history, "Hello").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete successfully");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that Engine can correctly process responses containing tool calls
|
||||
@@ -156,6 +160,7 @@ async fn test_engine_tool_call() {
|
||||
|
||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
// Register tool
|
||||
let weather_tool = MockWeatherTool::new();
|
||||
@@ -163,7 +168,9 @@ async fn test_engine_tool_call() {
|
||||
engine.register_tool(weather_tool.definition());
|
||||
|
||||
// Send message (Mutable::run consumes self, returns tuple)
|
||||
let _result = engine.run("What's the weather in Tokyo?").await;
|
||||
let _result = engine
|
||||
.run(&mut history, "What's the weather in Tokyo?")
|
||||
.await;
|
||||
|
||||
// Verify tool was called
|
||||
// Note: max_turns=1 so no request is sent after tool result
|
||||
@@ -195,11 +202,15 @@ async fn test_engine_with_programmatic_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
// Mutable::run consumes self, returns tuple
|
||||
let result = engine.run("Greet me").await;
|
||||
let result = engine.run(&mut history, "Greet me").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete successfully");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
|
||||
|
||||
@@ -9,9 +9,12 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::Item;
|
||||
use agen::interceptor::{
|
||||
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
|
||||
};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, EngineError};
|
||||
use agen::{Engine, EngineError, EngineRunExit, History, StopReason};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
@@ -39,36 +42,37 @@ fn test_mutable_set_system_prompt() {
|
||||
fn test_mutable_history_manipulation() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Initial state is empty
|
||||
assert!(engine.history().is_empty());
|
||||
assert!(history.is_empty());
|
||||
|
||||
// Add to history
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.append_history(&mut history, vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi there!")])
|
||||
.append_history(&mut history, vec![Item::assistant_message("Hi there!")])
|
||||
.unwrap();
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
assert_eq!(history.len(), 2);
|
||||
|
||||
// Append to history via the callback-aware API.
|
||||
engine
|
||||
.append_history(vec![Item::user_message("How are you?")])
|
||||
.append_history(&mut history, vec![Item::user_message("How are you?")])
|
||||
.unwrap();
|
||||
assert_eq!(engine.history().len(), 3);
|
||||
assert_eq!(history.len(), 3);
|
||||
|
||||
// Clear history
|
||||
engine.clear_history();
|
||||
assert!(engine.history().is_empty());
|
||||
engine.clear_history(&mut history);
|
||||
assert!(history.is_empty());
|
||||
|
||||
// Set history
|
||||
let items = vec![
|
||||
Item::user_message("Test"),
|
||||
Item::assistant_message("Response"),
|
||||
];
|
||||
engine.set_history(items);
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
engine.set_history(&mut history, items);
|
||||
assert_eq!(history.len(), 2);
|
||||
}
|
||||
|
||||
/// Verify that Engine can be constructed using builder pattern
|
||||
@@ -76,9 +80,10 @@ fn test_mutable_history_manipulation() {
|
||||
fn test_mutable_builder_pattern() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let engine = Engine::new(client).system_prompt("System prompt");
|
||||
let history: History = History::new();
|
||||
|
||||
assert_eq!(engine.get_system_prompt(), Some("System prompt"));
|
||||
assert!(engine.history().is_empty());
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
/// Verify that multiple items can be added with append_history and callbacks fire.
|
||||
@@ -88,6 +93,7 @@ fn test_mutable_append_history() {
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
let observed_for_callback = Arc::clone(&observed);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
engine.on_history_append(move |item| {
|
||||
if let Some(text) = item.as_text() {
|
||||
observed_for_callback.lock().unwrap().push(text.to_string());
|
||||
@@ -96,18 +102,21 @@ fn test_mutable_append_history() {
|
||||
});
|
||||
|
||||
engine
|
||||
.append_history(vec![Item::user_message("First")])
|
||||
.append_history(&mut history, vec![Item::user_message("First")])
|
||||
.unwrap();
|
||||
|
||||
engine
|
||||
.append_history(vec![
|
||||
.append_history(
|
||||
&mut history,
|
||||
vec![
|
||||
Item::assistant_message("Response 1"),
|
||||
Item::user_message("Second"),
|
||||
Item::assistant_message("Response 2"),
|
||||
])
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(engine.history().len(), 4);
|
||||
assert_eq!(history.len(), 4);
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
["First", "Response 1", "Second", "Response 2"]
|
||||
@@ -182,6 +191,7 @@ async fn history_append_failure_stops_before_tool_execution() {
|
||||
]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
engine.register_tool(tool.definition());
|
||||
engine.on_history_append(|item| {
|
||||
if item.is_tool_call() {
|
||||
@@ -191,15 +201,15 @@ async fn history_append_failure_stops_before_tool_execution() {
|
||||
}
|
||||
});
|
||||
|
||||
let mut engine = engine.lock();
|
||||
let error = engine.run("use the tool").await.unwrap_err();
|
||||
let mut engine = engine.lock(&history);
|
||||
let exit = engine.run(&mut history, "use the tool").await;
|
||||
|
||||
assert!(
|
||||
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC")
|
||||
matches!(exit, EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
|
||||
);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(engine.history().len(), 1);
|
||||
assert_eq!(engine.history()[0].as_text(), Some("use the tool"));
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history.entries()[0].item.as_text(), Some("use the tool"));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -211,21 +221,22 @@ async fn history_append_failure_stops_before_tool_execution() {
|
||||
fn test_lock_transition() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
engine.set_system_prompt("System");
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.append_history(&mut history, vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi")])
|
||||
.append_history(&mut history, vec![Item::assistant_message("Hi")])
|
||||
.unwrap();
|
||||
|
||||
// Lock
|
||||
let locked_engine = engine.lock();
|
||||
let locked_engine = engine.lock(&history);
|
||||
|
||||
// History and system prompt are still accessible in Locked state
|
||||
assert_eq!(locked_engine.get_system_prompt(), Some("System"));
|
||||
assert_eq!(locked_engine.history().len(), 2);
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2);
|
||||
}
|
||||
|
||||
@@ -234,21 +245,22 @@ fn test_lock_transition() {
|
||||
fn test_unlock_transition() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.append_history(&mut history, vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
let locked_engine = engine.lock();
|
||||
let locked_engine = engine.lock(&history);
|
||||
|
||||
// Unlock
|
||||
let mut engine = locked_engine.unlock();
|
||||
|
||||
// History operations are available again in Mutable state
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi")])
|
||||
.append_history(&mut history, vec![Item::assistant_message("Hi")])
|
||||
.unwrap();
|
||||
engine.clear_history();
|
||||
assert!(engine.history().is_empty());
|
||||
engine.clear_history(&mut history);
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -269,20 +281,20 @@ async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Execute (Mutable::run consumes self, returns EngineRunOutput)
|
||||
let out = engine.run("Hi there").await?;
|
||||
let engine = out.engine;
|
||||
let _out = engine.run(&mut history, "Hi there").await;
|
||||
|
||||
// History is updated
|
||||
let history = engine.history();
|
||||
let entries = history.entries();
|
||||
assert_eq!(history.len(), 2); // user + assistant
|
||||
|
||||
// User message
|
||||
assert_eq!(history[0].as_text(), Some("Hi there"));
|
||||
assert_eq!(entries[0].item.as_text(), Some("Hi there"));
|
||||
|
||||
// Assistant message
|
||||
assert_eq!(history[1].as_text(), Some("Hello, I'm an assistant!"));
|
||||
assert_eq!(entries[1].item.as_text(), Some("Hello, I'm an assistant!"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -313,35 +325,36 @@ async fn test_locked_multi_turn_history_accumulation() {
|
||||
]);
|
||||
|
||||
let engine = Engine::new(client).system_prompt("You are helpful.");
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Lock (after setting system prompt)
|
||||
let mut locked_engine = engine.lock();
|
||||
let mut locked_engine = engine.lock(&history);
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 0); // No items yet
|
||||
|
||||
// Turn 1
|
||||
let result1 = locked_engine.run("Hello!").await;
|
||||
assert!(result1.is_ok());
|
||||
assert_eq!(locked_engine.history().len(), 2); // user + assistant
|
||||
let result1 = locked_engine.run(&mut history, "Hello!").await;
|
||||
assert!(matches!(result1, EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 2); // user + assistant
|
||||
|
||||
// Turn 2
|
||||
let result2 = locked_engine.run("Can you help me?").await;
|
||||
assert!(result2.is_ok());
|
||||
assert_eq!(locked_engine.history().len(), 4); // 2 * (user + assistant)
|
||||
let result2 = locked_engine.run(&mut history, "Can you help me?").await;
|
||||
assert!(matches!(result2, EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 4); // 2 * (user + assistant)
|
||||
|
||||
// Verify history contents
|
||||
let history = locked_engine.history();
|
||||
let entries = history.entries();
|
||||
|
||||
// Turn 1 user message
|
||||
assert_eq!(history[0].as_text(), Some("Hello!"));
|
||||
assert_eq!(entries[0].item.as_text(), Some("Hello!"));
|
||||
|
||||
// Turn 1 assistant message
|
||||
assert_eq!(history[1].as_text(), Some("Nice to meet you!"));
|
||||
assert_eq!(entries[1].item.as_text(), Some("Nice to meet you!"));
|
||||
|
||||
// Turn 2 user message
|
||||
assert_eq!(history[2].as_text(), Some("Can you help me?"));
|
||||
assert_eq!(entries[2].item.as_text(), Some("Can you help me?"));
|
||||
|
||||
// Turn 2 assistant message
|
||||
assert_eq!(history[3].as_text(), Some("I can help with that."));
|
||||
assert_eq!(entries[3].item.as_text(), Some("I can help with that."));
|
||||
}
|
||||
|
||||
/// Verify that locked_prefix_len correctly records history length at lock time
|
||||
@@ -367,26 +380,33 @@ async fn test_locked_prefix_len_tracking() {
|
||||
]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Add items beforehand
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Pre-existing message 1")])
|
||||
.append_history(
|
||||
&mut history,
|
||||
vec![Item::user_message("Pre-existing message 1")],
|
||||
)
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Pre-existing response 1")])
|
||||
.append_history(
|
||||
&mut history,
|
||||
vec![Item::assistant_message("Pre-existing response 1")],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
assert_eq!(history.len(), 2);
|
||||
|
||||
// Lock
|
||||
let mut locked_engine = engine.lock();
|
||||
let mut locked_engine = engine.lock(&history);
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time
|
||||
|
||||
// Execute turn
|
||||
locked_engine.run("New message").await.unwrap();
|
||||
locked_engine.run(&mut history, "New message").await;
|
||||
|
||||
// History grows but locked_prefix_len remains unchanged
|
||||
assert_eq!(locked_engine.history().len(), 4); // 2 + 2
|
||||
assert_eq!(history.len(), 4); // 2 + 2
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2); // Unchanged
|
||||
}
|
||||
|
||||
@@ -413,18 +433,22 @@ async fn test_turn_count_increment() -> Result<(), EngineError> {
|
||||
]);
|
||||
|
||||
let engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
assert_eq!(engine.turn_count(), 0);
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
|
||||
// First run consumes Mutable, returns EngineRunOutput
|
||||
let mut engine = engine.run("First").await?.engine;
|
||||
let mut engine = engine.run(&mut history, "First").await.engine;
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
// Retry not yet implemented → AgentTurn:LlmCall is 1:1.
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
|
||||
// Subsequent runs on Locked take &mut self
|
||||
engine.run("Second").await?;
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "Second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.llm_call_count(), 2);
|
||||
|
||||
@@ -444,28 +468,29 @@ async fn test_unlock_edit_relock() {
|
||||
]]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
engine
|
||||
.append_history(vec![
|
||||
Item::user_message("Hello"),
|
||||
Item::assistant_message("Hi"),
|
||||
])
|
||||
.append_history(
|
||||
&mut history,
|
||||
vec![Item::user_message("Hello"), Item::assistant_message("Hi")],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Lock -> Unlock
|
||||
let locked = engine.lock();
|
||||
let locked = engine.lock(&history);
|
||||
assert_eq!(locked.locked_prefix_len(), 2);
|
||||
|
||||
let mut unlocked = locked.unlock();
|
||||
|
||||
// Edit history
|
||||
unlocked.clear_history();
|
||||
unlocked.clear_history(&mut history);
|
||||
unlocked
|
||||
.append_history(vec![Item::user_message("Fresh start")])
|
||||
.append_history(&mut history, vec![Item::user_message("Fresh start")])
|
||||
.unwrap();
|
||||
|
||||
// Re-lock
|
||||
let relocked = unlocked.lock();
|
||||
assert_eq!(relocked.history().len(), 1);
|
||||
let relocked = unlocked.lock(&history);
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(relocked.locked_prefix_len(), 1);
|
||||
}
|
||||
|
||||
@@ -508,19 +533,26 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
||||
]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let tool_a = CountingTool::new("tool_a");
|
||||
engine.register_tool(tool_a.definition());
|
||||
|
||||
let mut locked = engine.lock();
|
||||
locked.run("first").await.expect("first run");
|
||||
let mut locked = engine.lock(&history);
|
||||
assert!(matches!(
|
||||
locked.run(&mut history, "first").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
|
||||
|
||||
let mut unlocked = locked.unlock();
|
||||
let tool_b = CountingTool::new("tool_b");
|
||||
unlocked.register_tool(tool_b.definition());
|
||||
|
||||
let mut relocked = unlocked.lock();
|
||||
relocked.run("second").await.expect("second run");
|
||||
let mut relocked = unlocked.lock(&history);
|
||||
assert!(matches!(
|
||||
relocked.run(&mut history, "second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
|
||||
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
|
||||
@@ -535,8 +567,9 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
||||
fn test_system_prompt_preserved_in_locked_state() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let engine = Engine::new(client).system_prompt("Important system prompt");
|
||||
let history: History = History::new();
|
||||
|
||||
let locked = engine.lock();
|
||||
let locked = engine.lock(&history);
|
||||
assert_eq!(locked.get_system_prompt(), Some("Important system prompt"));
|
||||
|
||||
let unlocked = locked.unlock();
|
||||
@@ -551,13 +584,228 @@ fn test_system_prompt_preserved_in_locked_state() {
|
||||
fn test_system_prompt_change_after_unlock() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let engine = Engine::new(client).system_prompt("Original prompt");
|
||||
let history: History = History::new();
|
||||
|
||||
let locked = engine.lock();
|
||||
let locked = engine.lock(&history);
|
||||
let mut unlocked = locked.unlock();
|
||||
|
||||
unlocked.set_system_prompt("New prompt");
|
||||
assert_eq!(unlocked.get_system_prompt(), Some("New prompt"));
|
||||
|
||||
let relocked = unlocked.lock();
|
||||
let relocked = unlocked.lock(&history);
|
||||
assert_eq!(relocked.get_system_prompt(), Some("New prompt"));
|
||||
}
|
||||
|
||||
fn completed_text_events() -> Vec<Event> {
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "done"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
struct YieldOnce {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for YieldOnce {
|
||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
PreRequestAction::Yield
|
||||
} else {
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PauseToolOnce {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PauseToolOnce {
|
||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
PreToolAction::Pause
|
||||
} else {
|
||||
PreToolAction::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContinueTurnOnce {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ContinueTurnOnce {
|
||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
|
||||
} else {
|
||||
TurnEndAction::Finish
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn max_turns_is_scoped_to_each_fresh_run() {
|
||||
let mut history: History = History::new();
|
||||
let responses = vec![completed_text_events(), completed_text_events()];
|
||||
let mut engine = Engine::new(MockLlmClient::with_responses(responses));
|
||||
engine.set_max_turns(Some(1));
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "first").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yielded_resume_keeps_the_same_unspent_turn_budget() {
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(YieldOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "start").await,
|
||||
EngineRunExit::Yielded
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 0);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(0));
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
let mut history: History = History::new();
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(MockLlmClient::new(events));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "call it").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
assert_eq!(tool.call_count(), 1, "the consumed turn's tool still runs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
let mut history: History = History::new();
|
||||
let tool_events = vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
let client = MockLlmClient::with_responses(vec![tool_events, completed_text_events()]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "pause").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "replace").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
assert_eq!(tool.call_count(), 1, "pending-tool semantics are unchanged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interceptor_continuation_consumes_the_logical_run_budget() {
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(ContinueTurnOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "start").await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_turn_count(7);
|
||||
engine.set_active_run_turn_count(Some(1));
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 7);
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use agen::Engine;
|
||||
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
||||
ToolResultDisposition,
|
||||
};
|
||||
use agen::{Engine, History, Item, ToolExecutionPolicy};
|
||||
use async_trait::async_trait;
|
||||
|
||||
mod common;
|
||||
@@ -70,6 +71,144 @@ impl Tool for SlowTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FirstAttemptHangsTool {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl FirstAttemptHangsTool {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new("hang_once")
|
||||
.description("Hangs on the first execution attempt")
|
||||
.input_schema(serde_json::json!({"type": "object"}));
|
||||
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FirstAttemptHangsTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let attempt = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if attempt == 0 {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
Ok("completed on retry".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CooperativeCancelTool {
|
||||
calls: Arc<AtomicUsize>,
|
||||
cancelled: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl CooperativeCancelTool {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
cancelled: Arc::new(tokio::sync::Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new("cooperative")
|
||||
.description("Returns bounded progress after cancellation")
|
||||
.input_schema(serde_json::json!({"type": "object"}));
|
||||
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CooperativeCancelTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.cancelled.notified().await;
|
||||
Err(ToolError::Cancelled(ToolOutput {
|
||||
summary: "cooperative command cancelled".to_string(),
|
||||
content: Some("stdout before cancellation\nstderr before cancellation".to_string()),
|
||||
attachments: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||
self.cancelled.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SafePauseTool {
|
||||
calls: Arc<AtomicUsize>,
|
||||
cancellations: Arc<AtomicUsize>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl SafePauseTool {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
cancellations: Arc::new(AtomicUsize::new(0)),
|
||||
release: Arc::new(tokio::sync::Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new("safe_pause")
|
||||
.description("Waits for a safe-boundary release")
|
||||
.input_schema(serde_json::json!({"type": "object"}));
|
||||
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SafePauseTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.release.notified().await;
|
||||
Ok(ToolOutput {
|
||||
summary: "safe-boundary complete".to_string(),
|
||||
content: Some("safe-boundary complete".to_string()),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||
self.cancellations.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ContextRecordingTool {
|
||||
name: String,
|
||||
@@ -145,6 +284,7 @@ async fn test_parallel_tool_execution() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let tool1 = SlowTool::new("slow_tool_1", 100);
|
||||
let tool2 = SlowTool::new("slow_tool_2", 100);
|
||||
let tool3 = SlowTool::new("slow_tool_3", 100);
|
||||
@@ -159,7 +299,7 @@ async fn test_parallel_tool_execution() {
|
||||
|
||||
let start = Instant::now();
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _result = engine.run("Run all tools").await;
|
||||
let _result = engine.run(&mut history, "Run all tools").await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// Verify all tools were called
|
||||
@@ -178,6 +318,450 @@ async fn test_parallel_tool_execution() {
|
||||
println!("Parallel execution completed in {:?}", elapsed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_results_commit_before_publish_without_waiting_for_siblings() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_slow", "slow_first"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "call_fast", "fast_second"),
|
||||
Event::tool_input_delta(1, r#"{}"#),
|
||||
Event::tool_use_stop(1),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Done"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let client_probe = client.clone();
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(SlowTool::new("slow_first", 100).definition());
|
||||
engine.register_tool(SlowTool::new("fast_second", 5).definition());
|
||||
|
||||
let observed = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let published = observed.clone();
|
||||
engine.on_tool_result(move |result| {
|
||||
published
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("publish:{}", result.tool_use_id));
|
||||
});
|
||||
|
||||
let committed = observed.clone();
|
||||
let mut annotate = move |item: &Item| {
|
||||
if let Item::ToolResult { call_id, .. } = item {
|
||||
committed.lock().unwrap().push(format!("commit:{call_id}"));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut history = History::new();
|
||||
let _ = engine
|
||||
.run_with_annotation(&mut history, "run both", &mut annotate)
|
||||
.await;
|
||||
observed.lock().unwrap().push("run-returned".to_string());
|
||||
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
[
|
||||
"commit:call_fast",
|
||||
"publish:call_fast",
|
||||
"commit:call_slow",
|
||||
"publish:call_slow",
|
||||
"run-returned",
|
||||
]
|
||||
);
|
||||
|
||||
let committed_order: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match &entry.item {
|
||||
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(committed_order, ["call_fast", "call_slow"]);
|
||||
|
||||
let requests = client_probe.requests();
|
||||
let projected_order: Vec<_> = requests[1]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(projected_order, ["call_slow", "call_fast"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_preserves_completed_results_and_resume_skips_them() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_hang", "hang_once"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "call_fast_a", "fast_a"),
|
||||
Event::tool_input_delta(1, r#"{}"#),
|
||||
Event::tool_use_stop(1),
|
||||
Event::tool_use_start(2, "call_fast_b", "fast_b"),
|
||||
Event::tool_input_delta(2, r#"{}"#),
|
||||
Event::tool_use_stop(2),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Recovered"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let hanging = FirstAttemptHangsTool::new();
|
||||
let fast_a = SlowTool::new("fast_a", 1);
|
||||
let fast_b = SlowTool::new("fast_b", 2);
|
||||
engine.register_tool(hanging.definition());
|
||||
engine.register_tool(fast_a.definition());
|
||||
engine.register_tool(fast_b.definition());
|
||||
|
||||
let cancel = engine.cancel_sender();
|
||||
let cancel_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
cancel.send(()).await.unwrap();
|
||||
});
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "start").await;
|
||||
let mut engine = output.engine;
|
||||
cancel_task.await.unwrap();
|
||||
|
||||
let completed_before_resume = history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult { call_id, .. }
|
||||
if call_id == "call_fast_a" || call_id == "call_fast_b"
|
||||
)
|
||||
})
|
||||
.count();
|
||||
let unknown_before_resume = history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||
..
|
||||
} if call_id == "call_hang"
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(completed_before_resume, 2);
|
||||
assert_eq!(unknown_before_resume, 1);
|
||||
assert_eq!(fast_a.call_count(), 1);
|
||||
assert_eq!(fast_b.call_count(), 1);
|
||||
assert_eq!(hanging.call_count(), 1);
|
||||
|
||||
let _ = engine.resume(&mut history).await;
|
||||
|
||||
assert_eq!(
|
||||
fast_a.call_count(),
|
||||
1,
|
||||
"completed call must not be re-executed"
|
||||
);
|
||||
assert_eq!(
|
||||
fast_b.call_count(),
|
||||
1,
|
||||
"completed call must not be re-executed"
|
||||
);
|
||||
assert_eq!(
|
||||
hanging.call_count(),
|
||||
1,
|
||||
"OutcomeUnknown is terminal and must not be re-executed"
|
||||
);
|
||||
let completed_after_resume = history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult { call_id, .. }
|
||||
if call_id == "call_fast_a" || call_id == "call_fast_b"
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(completed_after_resume, 2);
|
||||
assert_eq!(
|
||||
history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||
..
|
||||
} if call_id == "call_hang"
|
||||
)
|
||||
})
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cooperative_cancellation_commits_bounded_terminal_output() {
|
||||
let client = MockLlmClient::with_responses(vec![vec![
|
||||
Event::tool_use_start(0, "call_cooperative", "cooperative"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]]);
|
||||
let mut engine = Engine::new(client);
|
||||
let tool = CooperativeCancelTool::new();
|
||||
engine.register_tool(tool.definition());
|
||||
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||
let published = observed.clone();
|
||||
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
|
||||
let committed = observed.clone();
|
||||
let mut annotate = move |item: &Item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
committed.lock().unwrap().push("committed");
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let cancel = engine.cancel_sender();
|
||||
let cancel_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
cancel.send(()).await.unwrap();
|
||||
});
|
||||
let mut history = History::new();
|
||||
let output = engine
|
||||
.run_with_annotation(&mut history, "start", &mut annotate)
|
||||
.await;
|
||||
observed.lock().unwrap().push("run-returned");
|
||||
cancel_task.await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
["committed", "published", "run-returned"]
|
||||
);
|
||||
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||
let terminal: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match &entry.item {
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition,
|
||||
content,
|
||||
..
|
||||
} if call_id == "call_cooperative" => Some((*disposition, content.as_deref())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(terminal.len(), 1);
|
||||
assert_eq!(terminal[0].0, ToolResultDisposition::Cancelled);
|
||||
assert_eq!(
|
||||
terminal[0].1,
|
||||
Some("stdout before cancellation\nstderr before cancellation")
|
||||
);
|
||||
assert!(matches!(
|
||||
output.result,
|
||||
agen::EngineRunExit::Interrupted(agen::StopReason::Cancelled)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_waits_for_started_tool_terminal_without_cancelling_provider() {
|
||||
let client = MockLlmClient::with_responses(vec![vec![
|
||||
Event::tool_use_start(0, "call_safe_pause", "safe_pause"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]]);
|
||||
let mut engine = Engine::new(client);
|
||||
let tool = SafePauseTool::new();
|
||||
engine.register_tool(tool.definition());
|
||||
|
||||
let pause = engine.pause_sender();
|
||||
let calls = Arc::clone(&tool.calls);
|
||||
let release = Arc::clone(&tool.release);
|
||||
let control = tokio::spawn(async move {
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while calls.load(Ordering::SeqCst) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("tool execution starts");
|
||||
pause.send(()).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
release.notify_one();
|
||||
});
|
||||
|
||||
let started_at = std::time::Instant::now();
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "pause safely").await;
|
||||
control.await.unwrap();
|
||||
|
||||
assert!(started_at.elapsed() >= Duration::from_millis(50));
|
||||
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0);
|
||||
assert!(matches!(output.result, agen::EngineRunExit::Paused));
|
||||
assert!(history.iter().any(|entry| matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::Success,
|
||||
..
|
||||
} if call_id == "call_safe_pause"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_escalates_to_explicit_cancel_and_confirm_after_safe_boundary_deadline() {
|
||||
let client = MockLlmClient::with_responses(vec![vec![
|
||||
Event::tool_use_start(0, "call_pause_cancel", "cooperative"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]]);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_millis(20),
|
||||
cancellation_request_timeout: Duration::from_millis(50),
|
||||
terminal_confirmation_timeout: Duration::from_millis(100),
|
||||
});
|
||||
let tool = CooperativeCancelTool::new();
|
||||
engine.register_tool(tool.definition());
|
||||
|
||||
let pause = engine.pause_sender();
|
||||
let calls = Arc::clone(&tool.calls);
|
||||
let control = tokio::spawn(async move {
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while calls.load(Ordering::SeqCst) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("tool execution starts");
|
||||
pause.send(()).await.unwrap();
|
||||
});
|
||||
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "pause with escalation").await;
|
||||
control.await.unwrap();
|
||||
|
||||
assert!(matches!(output.result, agen::EngineRunExit::Paused));
|
||||
assert!(history.iter().any(|entry| matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::Cancelled,
|
||||
..
|
||||
} if call_id == "call_pause_cancel"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_completion_race_commits_one_terminal_output() {
|
||||
for iteration in 0..24u64 {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_racy", "racy"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let delay = 2 + iteration % 3;
|
||||
let tool = SlowTool::new("racy", delay);
|
||||
engine.register_tool(tool.definition());
|
||||
let cancel = engine.cancel_sender();
|
||||
let cancel_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(delay)).await;
|
||||
let _ = cancel.send(()).await;
|
||||
});
|
||||
|
||||
let mut history = History::new();
|
||||
let _ = engine.run(&mut history, "race").await;
|
||||
cancel_task.await.unwrap();
|
||||
let terminal_count = history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult { call_id, .. } if call_id == "call_racy"
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(terminal_count, 1, "iteration {iteration}");
|
||||
assert_eq!(tool.call_count(), 1, "iteration {iteration}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_result_commit_failure_prevents_publication() {
|
||||
let client = MockLlmClient::with_responses(vec![vec![
|
||||
Event::tool_use_start(0, "call_fast", "fast"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]]);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(SlowTool::new("fast", 1).definition());
|
||||
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let published_probe = published.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
published_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let mut history = History::new();
|
||||
let mut reject_tool_result = |item: &Item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
Err("session log unavailable".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let _ = engine
|
||||
.run_with_annotation(&mut history, "start", &mut reject_tool_result)
|
||||
.await;
|
||||
|
||||
assert_eq!(published.load(Ordering::SeqCst), 0);
|
||||
assert!(
|
||||
history
|
||||
.iter()
|
||||
.all(|entry| !matches!(entry.item, Item::ToolResult { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_execution_context_order_and_batch_id() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
@@ -205,13 +789,14 @@ async fn test_tool_execution_context_order_and_batch_id() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
engine.register_tool(ContextRecordingTool::new("record_a", contexts.clone()).definition());
|
||||
engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition());
|
||||
engine.register_tool(ContextRecordingTool::new("record_c", contexts.clone()).definition());
|
||||
|
||||
let _ = engine.run("record contexts").await;
|
||||
let _ = engine.run(&mut history, "record contexts").await;
|
||||
|
||||
let mut contexts = contexts.lock().unwrap().clone();
|
||||
contexts.sort_by_key(|ctx| ctx.call_index);
|
||||
@@ -256,11 +841,12 @@ async fn test_tool_execution_context_batch_id_changes_between_batches() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
engine.register_tool(ContextRecordingTool::new("record", contexts.clone()).definition());
|
||||
|
||||
let _ = engine.run("record batches").await;
|
||||
let _ = engine.run(&mut history, "record batches").await;
|
||||
|
||||
let contexts = contexts.lock().unwrap().clone();
|
||||
assert_eq!(contexts.len(), 2);
|
||||
@@ -298,6 +884,7 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let executed_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
let pre_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
let post_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -344,7 +931,9 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
|
||||
post_contexts: post_contexts.clone(),
|
||||
});
|
||||
|
||||
let _ = engine.run("record skipped and synthetic contexts").await;
|
||||
let _ = engine
|
||||
.run(&mut history, "record skipped and synthetic contexts")
|
||||
.await;
|
||||
|
||||
let mut pre_contexts = pre_contexts.lock().unwrap().clone();
|
||||
pre_contexts.sort_by_key(|ctx| ctx.call_index);
|
||||
@@ -389,6 +978,7 @@ async fn test_before_tool_call_skip() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
let allowed_tool = SlowTool::new("allowed_tool", 10);
|
||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||
@@ -416,7 +1006,7 @@ async fn test_before_tool_call_skip() {
|
||||
engine.set_interceptor(BlockingPolicy);
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _result = engine.run("Test hook").await;
|
||||
let _result = engine.run(&mut history, "Test hook").await;
|
||||
|
||||
// allowed_tool is called, but blocked_tool is not
|
||||
assert_eq!(
|
||||
@@ -457,6 +1047,7 @@ async fn test_post_tool_call_modification() {
|
||||
]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SimpleTool;
|
||||
@@ -503,9 +1094,12 @@ async fn test_post_tool_call_modification() {
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run("Test modification").await;
|
||||
let result = engine.run(&mut history, "Test modification").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete"
|
||||
);
|
||||
|
||||
// Verify hook was called and content was modified
|
||||
let content = modified_content.lock().unwrap().clone();
|
||||
@@ -540,6 +1134,7 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||
let blocked_clone = blocked_tool.clone();
|
||||
engine.register_tool(blocked_tool.definition());
|
||||
@@ -558,10 +1153,10 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
||||
|
||||
engine.set_interceptor(SyntheticPolicy);
|
||||
|
||||
let result = engine.run("Test synthetic result").await.unwrap();
|
||||
let _result = engine.run(&mut history, "Test synthetic result").await;
|
||||
|
||||
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
|
||||
assert!(result.engine.history().iter().any(|item| matches!(
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
agen::Item::ToolResult {
|
||||
call_id,
|
||||
@@ -571,3 +1166,76 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
||||
} if call_id == "call_1" && summary == "permission denied"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
||||
let client = MockLlmClient::new(vec![
|
||||
Event::tool_use_start(0, "call_confirmed", "confirmed"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let tool = SlowTool::new("confirmed", 1);
|
||||
engine.register_tool(tool.definition());
|
||||
|
||||
struct AbortAfterResult;
|
||||
#[async_trait]
|
||||
impl Interceptor for AbortAfterResult {
|
||||
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||
PostToolAction::Abort("policy stopped the run".to_string())
|
||||
}
|
||||
}
|
||||
engine.set_interceptor(AbortAfterResult);
|
||||
|
||||
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||
let published = observed.clone();
|
||||
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
|
||||
let committed = observed.clone();
|
||||
let mut annotate = move |item: &Item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
committed.lock().unwrap().push("committed");
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let mut history = History::new();
|
||||
let output = engine
|
||||
.run_with_annotation(&mut history, "run confirmed tool", &mut annotate)
|
||||
.await;
|
||||
observed.lock().unwrap().push("run-returned");
|
||||
|
||||
assert_eq!(tool.call_count(), 1);
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
["committed", "published", "run-returned"]
|
||||
);
|
||||
assert!(matches!(
|
||||
output.result,
|
||||
agen::EngineRunExit::Interrupted(agen::StopReason::Unexpected(
|
||||
agen::EngineError::Aborted(ref reason)
|
||||
)) if reason == "policy stopped the run"
|
||||
));
|
||||
let terminal: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match &entry.item {
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition,
|
||||
..
|
||||
} if call_id == "call_confirmed" => Some(*disposition),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(terminal, [ToolResultDisposition::Success]);
|
||||
assert!(!history.iter().any(|entry| matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||
..
|
||||
} if call_id == "call_confirmed"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
|
||||
mod common;
|
||||
|
||||
use agen::Engine;
|
||||
use agen::Item;
|
||||
use agen::llm_client::event::{
|
||||
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
|
||||
StatusEvent,
|
||||
};
|
||||
use agen::{Engine, History};
|
||||
use common::MockLlmClient;
|
||||
|
||||
fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> {
|
||||
@@ -65,15 +65,15 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
|
||||
]);
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("question?").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
let mut history: History = History::new();
|
||||
let _out = engine.run(&mut history, "question?").await;
|
||||
|
||||
let history = engine.history();
|
||||
let entries = history.entries();
|
||||
// user / reasoning / assistant_message
|
||||
assert_eq!(history.len(), 3, "history: {history:?}");
|
||||
|
||||
assert!(matches!(history[0], Item::Message { .. }));
|
||||
match &history[1] {
|
||||
assert!(matches!(entries[0].item, Item::Message { .. }));
|
||||
match &entries[1].item {
|
||||
Item::Reasoning {
|
||||
text, signature, ..
|
||||
} => {
|
||||
@@ -82,7 +82,7 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
|
||||
}
|
||||
other => panic!("expected Reasoning, got {other:?}"),
|
||||
}
|
||||
assert_eq!(history[2].as_text(), Some("Here's the answer"));
|
||||
assert_eq!(entries[2].item.as_text(), Some("Here's the answer"));
|
||||
}
|
||||
|
||||
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
|
||||
@@ -109,11 +109,11 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
|
||||
]);
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("q").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
let mut history: History = History::new();
|
||||
let _out = engine.run(&mut history, "q").await;
|
||||
|
||||
let history = engine.history();
|
||||
match &history[1] {
|
||||
let entries = history.entries();
|
||||
match &entries[1].item {
|
||||
Item::Reasoning {
|
||||
text,
|
||||
summary,
|
||||
@@ -155,13 +155,13 @@ async fn reasoning_precedes_text_in_assistant_burst() {
|
||||
}));
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("q").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
let mut history: History = History::new();
|
||||
let _out = engine.run(&mut history, "q").await;
|
||||
|
||||
let history = engine.history();
|
||||
let entries = history.entries();
|
||||
// user / reasoning(先頭) / assistant_message
|
||||
assert!(matches!(history[1], Item::Reasoning { .. }));
|
||||
assert_eq!(history[2].as_text(), Some("intermediate"));
|
||||
assert!(matches!(entries[1].item, Item::Reasoning { .. }));
|
||||
assert_eq!(entries[2].item.as_text(), Some("intermediate"));
|
||||
}
|
||||
|
||||
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
|
||||
@@ -207,14 +207,18 @@ async fn injected_reasoning_survives_into_outgoing_request() {
|
||||
};
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
// resume: 既存 history を流し込む
|
||||
engine.set_history(vec![
|
||||
engine.set_history(
|
||||
&mut history,
|
||||
vec![
|
||||
Item::user_message("prior question"),
|
||||
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
||||
Item::assistant_message("prior answer"),
|
||||
]);
|
||||
],
|
||||
);
|
||||
|
||||
let _ = engine.run("follow up").await.expect("run ok");
|
||||
let _ = engine.run(&mut history, "follow up").await;
|
||||
|
||||
let req = captured
|
||||
.lock()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use agen::Engine;
|
||||
use agen::{Engine, History};
|
||||
use agen::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
@@ -22,7 +22,8 @@ fn main() {
|
||||
cap,
|
||||
);
|
||||
let engine = Engine::new(client);
|
||||
let mut locked = engine.lock();
|
||||
let history = History::new();
|
||||
let mut locked = engine.lock(&history);
|
||||
let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused"));
|
||||
let _ = locked.register_tool(def);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
error[E0599]: no method named `register_tool` found for struct `Engine<HttpTransport<AnthropicScheme>, Locked>` in the current scope
|
||||
--> tests/ui/locked_register_tool.rs:27:20
|
||||
--> tests/ui/locked_register_tool.rs:28:20
|
||||
|
|
||||
27 | let _ = locked.register_tool(def);
|
||||
28 | let _ = locked.register_tool(def);
|
||||
| ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>`
|
||||
|
|
||||
= note: the method was found for
|
||||
- `Engine<C>`
|
||||
- `Engine<C, Mutable, A>`
|
||||
|
||||
@@ -5,19 +5,19 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
protocol = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
workspace-api.workspace = true
|
||||
workdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::{Method, StatusCode, Url, redirect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const TOKEN_FILE_NAME: &str = "backend-tokens.json";
|
||||
const MAX_REDIRECTS: usize = 10;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct BackendOrigin(String);
|
||||
|
||||
impl BackendOrigin {
|
||||
pub fn parse(input: &str) -> Result<Self, BackendApiClientError> {
|
||||
let url = Url::parse(input.trim()).map_err(|error| {
|
||||
BackendApiClientError::InvalidBackendOrigin(format!(
|
||||
"Backend URL is not a valid absolute URL: {error}"
|
||||
))
|
||||
})?;
|
||||
if !url.path().bytes().all(|byte| byte == b'/')
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||
"Backend URL must contain only an origin, without a path, query, or fragment"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Self::from_url(url)
|
||||
}
|
||||
|
||||
fn from_url(mut url: Url) -> Result<Self, BackendApiClientError> {
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||
"Backend URL scheme must be http or https".to_string(),
|
||||
));
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||
"Backend URL must not contain user information".to_string(),
|
||||
));
|
||||
}
|
||||
if url.host().is_none() {
|
||||
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||
"Backend URL must contain a host".to_string(),
|
||||
));
|
||||
}
|
||||
let default_port = match url.scheme() {
|
||||
"http" => 80,
|
||||
"https" => 443,
|
||||
_ => unreachable!("validated Backend URL scheme"),
|
||||
};
|
||||
if url.port() == Some(default_port) {
|
||||
url.set_port(None).map_err(|()| {
|
||||
BackendApiClientError::InvalidBackendOrigin(
|
||||
"Backend URL contains an invalid port".to_string(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
url.set_path("");
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
let normalized = url.as_str().trim_end_matches('/').to_string();
|
||||
Ok(Self(normalized))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn url(&self, path_and_query: &str) -> Result<Url, BackendApiClientError> {
|
||||
if !path_and_query.starts_with('/') || path_and_query.starts_with("//") {
|
||||
return Err(BackendApiClientError::InvalidRequestPath(
|
||||
"Backend API request path must start with one `/`".to_string(),
|
||||
));
|
||||
}
|
||||
Url::parse(&format!("{}{path_and_query}", self.0)).map_err(|error| {
|
||||
BackendApiClientError::InvalidRequestPath(format!(
|
||||
"Backend API request path is invalid: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for BackendOrigin {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_tuple("BackendOrigin").field(&self.0).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BackendOrigin {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BackendAccessToken(String);
|
||||
|
||||
impl fmt::Debug for BackendAccessToken {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("BackendAccessToken([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BackendApiClient {
|
||||
origin: BackendOrigin,
|
||||
access_token: BackendAccessToken,
|
||||
asynchronous: reqwest::Client,
|
||||
}
|
||||
|
||||
impl fmt::Debug for BackendApiClient {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("BackendApiClient")
|
||||
.field("origin", &self.origin)
|
||||
.field("access_token", &self.access_token)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl BackendApiClient {
|
||||
pub fn from_stored_token(base_url: &str) -> Result<Self, BackendApiClientError> {
|
||||
let path = backend_token_file_path()?;
|
||||
Self::from_token_file(base_url, &path)
|
||||
}
|
||||
|
||||
fn from_token_file(base_url: &str, path: &Path) -> Result<Self, BackendApiClientError> {
|
||||
let origin = BackendOrigin::parse(base_url)?;
|
||||
let token_file = read_token_file(path)?;
|
||||
let entry = token_file.tokens.get(origin.as_str()).ok_or_else(|| {
|
||||
BackendApiClientError::TokenEntryMissing {
|
||||
origin: origin.clone(),
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
})?;
|
||||
validate_token_entry(entry, &origin, path)?;
|
||||
Self::new(origin, BackendAccessToken(entry.access_token.clone()))
|
||||
}
|
||||
|
||||
fn new(
|
||||
origin: BackendOrigin,
|
||||
access_token: BackendAccessToken,
|
||||
) -> Result<Self, BackendApiClientError> {
|
||||
let asynchronous = reqwest::Client::builder()
|
||||
.redirect(redirect_policy(origin.clone()))
|
||||
.build()
|
||||
.map_err(BackendApiClientError::Http)?;
|
||||
Ok(Self {
|
||||
origin,
|
||||
access_token,
|
||||
asynchronous,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn origin(&self) -> &BackendOrigin {
|
||||
&self.origin
|
||||
}
|
||||
|
||||
pub fn request(
|
||||
&self,
|
||||
method: Method,
|
||||
path_and_query: &str,
|
||||
) -> Result<reqwest::RequestBuilder, BackendApiClientError> {
|
||||
let url = self.origin.url(path_and_query)?;
|
||||
Ok(self
|
||||
.asynchronous
|
||||
.request(method, url)
|
||||
.bearer_auth(&self.access_token.0))
|
||||
}
|
||||
|
||||
pub fn blocking_request(
|
||||
&self,
|
||||
method: Method,
|
||||
path_and_query: &str,
|
||||
) -> Result<reqwest::blocking::RequestBuilder, BackendApiClientError> {
|
||||
let url = self.origin.url(path_and_query)?;
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.redirect(redirect_policy(self.origin.clone()))
|
||||
.build()
|
||||
.map_err(BackendApiClientError::Http)?;
|
||||
Ok(client
|
||||
.request(method, url)
|
||||
.bearer_auth(&self.access_token.0))
|
||||
}
|
||||
|
||||
pub(crate) fn authorization_header_value(&self) -> String {
|
||||
format!("Bearer {}", self.access_token.0)
|
||||
}
|
||||
|
||||
pub async fn require_success(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
) -> Result<reqwest::Response, BackendApiClientError> {
|
||||
let status = response.status();
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
|
||||
self.check_status(status)?;
|
||||
}
|
||||
status if !status.is_success() => {
|
||||
let detail = response
|
||||
.bytes()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|body| backend_error_detail(&body));
|
||||
return Err(BackendApiClientError::BackendResponse {
|
||||
origin: self.origin.clone(),
|
||||
status: status.as_u16(),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
|
||||
origin: self.origin.clone(),
|
||||
}),
|
||||
StatusCode::FORBIDDEN => Err(BackendApiClientError::Forbidden {
|
||||
origin: self.origin.clone(),
|
||||
}),
|
||||
status if !status.is_success() => Err(BackendApiClientError::BackendStatus {
|
||||
origin: self.origin.clone(),
|
||||
status: status.as_u16(),
|
||||
}),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_access_token_for_test(
|
||||
base_url: &str,
|
||||
access_token: &str,
|
||||
) -> Result<Self, BackendApiClientError> {
|
||||
Self::new(
|
||||
BackendOrigin::parse(base_url)?,
|
||||
BackendAccessToken(access_token.to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn redirect_policy(origin: BackendOrigin) -> redirect::Policy {
|
||||
redirect::Policy::custom(move |attempt| {
|
||||
if attempt.previous().len() >= MAX_REDIRECTS {
|
||||
return attempt.error("Backend request exceeded the redirect limit");
|
||||
}
|
||||
match BackendOrigin::from_url(attempt.url().clone()) {
|
||||
Ok(target_origin) if target_origin == origin => attempt.follow(),
|
||||
Ok(target_origin) => attempt.error(format!(
|
||||
"Backend request refused a cross-origin redirect from {origin} to {target_origin}"
|
||||
)),
|
||||
Err(error) => attempt.error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BackendErrorBody {
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn backend_error_detail(body: &[u8]) -> Option<String> {
|
||||
serde_json::from_slice::<BackendErrorBody>(body)
|
||||
.ok()
|
||||
.map(|body| body.message)
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BackendApiClientError {
|
||||
InvalidBackendOrigin(String),
|
||||
InvalidRequestPath(String),
|
||||
ConfigDirectoryUnavailable,
|
||||
TokenFileMissing {
|
||||
path: PathBuf,
|
||||
},
|
||||
TokenFileMalformed {
|
||||
path: PathBuf,
|
||||
message: String,
|
||||
},
|
||||
TokenEntryMissing {
|
||||
origin: BackendOrigin,
|
||||
path: PathBuf,
|
||||
},
|
||||
TokenExpired {
|
||||
origin: BackendOrigin,
|
||||
expired_at: String,
|
||||
},
|
||||
Http(reqwest::Error),
|
||||
Unauthorized {
|
||||
origin: BackendOrigin,
|
||||
},
|
||||
Forbidden {
|
||||
origin: BackendOrigin,
|
||||
},
|
||||
BackendStatus {
|
||||
origin: BackendOrigin,
|
||||
status: u16,
|
||||
},
|
||||
BackendResponse {
|
||||
origin: BackendOrigin,
|
||||
status: u16,
|
||||
detail: Option<String>,
|
||||
},
|
||||
Io {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for BackendApiClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidBackendOrigin(message) | Self::InvalidRequestPath(message) => {
|
||||
f.write_str(message)
|
||||
}
|
||||
Self::ConfigDirectoryUnavailable => f.write_str(
|
||||
"cannot locate the client configuration directory for backend-tokens.json",
|
||||
),
|
||||
Self::TokenFileMissing { path } => write!(
|
||||
f,
|
||||
"Backend token file {} is missing; run `yoi login --backend <BACKEND>` first",
|
||||
path.display()
|
||||
),
|
||||
Self::TokenFileMalformed { path, message } => write!(
|
||||
f,
|
||||
"Backend token file {} is malformed: {message}; run `yoi login --backend <BACKEND>` again",
|
||||
path.display()
|
||||
),
|
||||
Self::TokenEntryMissing { origin, path } => write!(
|
||||
f,
|
||||
"no Backend token for {origin} exists in {}; login URLs are matched by normalized origin, so run `yoi login --backend {origin}`",
|
||||
path.display()
|
||||
),
|
||||
Self::TokenExpired { origin, expired_at } => write!(
|
||||
f,
|
||||
"Backend token for {origin} expired at {expired_at}; run `yoi login --backend {origin}` again"
|
||||
),
|
||||
Self::Http(error) => write!(f, "Backend request failed: {error}"),
|
||||
Self::Unauthorized { origin } => write!(
|
||||
f,
|
||||
"Backend {origin} returned HTTP 401 for the saved token; it may be expired or revoked, so run `yoi login --backend {origin}` again"
|
||||
),
|
||||
Self::Forbidden { origin } => write!(
|
||||
f,
|
||||
"Backend {origin} returned HTTP 403; the saved token is authenticated but is not authorized for this operation"
|
||||
),
|
||||
Self::BackendStatus { origin, status } => {
|
||||
write!(f, "Backend {origin} returned HTTP {status}")
|
||||
}
|
||||
Self::BackendResponse {
|
||||
origin,
|
||||
status,
|
||||
detail,
|
||||
} => {
|
||||
write!(f, "Backend {origin} returned HTTP {status}")?;
|
||||
if let Some(detail) = detail {
|
||||
write!(f, ": {detail}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::Io { path, source } => {
|
||||
write!(f, "failed to access {}: {source}", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BackendApiClientError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Http(error) => Some(error),
|
||||
Self::Io { source, .. } => Some(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BackendTokenFile {
|
||||
tokens: BTreeMap<String, BackendTokenEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BackendTokenEntry {
|
||||
token_type: String,
|
||||
access_token: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expires_at: Option<String>,
|
||||
}
|
||||
|
||||
pub fn save_backend_token(
|
||||
base_url: &str,
|
||||
token_type: &str,
|
||||
access_token: &str,
|
||||
) -> Result<PathBuf, BackendApiClientError> {
|
||||
save_backend_token_with_expiry(base_url, token_type, access_token, None)
|
||||
}
|
||||
|
||||
fn save_backend_token_with_expiry(
|
||||
base_url: &str,
|
||||
token_type: &str,
|
||||
access_token: &str,
|
||||
expires_at: Option<String>,
|
||||
) -> Result<PathBuf, BackendApiClientError> {
|
||||
let path = backend_token_file_path()?;
|
||||
save_backend_token_to_file(base_url, token_type, access_token, expires_at, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn save_backend_token_to_file(
|
||||
base_url: &str,
|
||||
token_type: &str,
|
||||
access_token: &str,
|
||||
expires_at: Option<String>,
|
||||
path: &Path,
|
||||
) -> Result<(), BackendApiClientError> {
|
||||
let origin = BackendOrigin::parse(base_url)?;
|
||||
let mut token_file = if path.exists() {
|
||||
read_token_file(&path)?
|
||||
} else {
|
||||
BackendTokenFile {
|
||||
tokens: BTreeMap::new(),
|
||||
}
|
||||
};
|
||||
let entry = BackendTokenEntry {
|
||||
token_type: token_type.to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
expires_at,
|
||||
};
|
||||
validate_token_entry(&entry, &origin, path)?;
|
||||
token_file.tokens.insert(origin.to_string(), entry);
|
||||
write_token_file(path, &token_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn backend_token_file_path() -> Result<PathBuf, BackendApiClientError> {
|
||||
if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") {
|
||||
return Ok(PathBuf::from(config_home).join("yoi").join(TOKEN_FILE_NAME));
|
||||
}
|
||||
let Some(home) = env::var_os("HOME") else {
|
||||
return Err(BackendApiClientError::ConfigDirectoryUnavailable);
|
||||
};
|
||||
Ok(PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("yoi")
|
||||
.join(TOKEN_FILE_NAME))
|
||||
}
|
||||
|
||||
fn read_token_file(path: &Path) -> Result<BackendTokenFile, BackendApiClientError> {
|
||||
let bytes = fs::read(path).map_err(|source| {
|
||||
if source.kind() == std::io::ErrorKind::NotFound {
|
||||
BackendApiClientError::TokenFileMissing {
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
BackendApiClientError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let raw: BackendTokenFile = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
BackendApiClientError::TokenFileMalformed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
normalize_token_file(raw, path)
|
||||
}
|
||||
|
||||
fn normalize_token_file(
|
||||
token_file: BackendTokenFile,
|
||||
path: &Path,
|
||||
) -> Result<BackendTokenFile, BackendApiClientError> {
|
||||
let mut normalized = BTreeMap::new();
|
||||
for (raw_origin, entry) in token_file.tokens {
|
||||
let origin = BackendOrigin::parse(&raw_origin).map_err(|error| {
|
||||
BackendApiClientError::TokenFileMalformed {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("token key `{raw_origin}` is invalid: {error}"),
|
||||
}
|
||||
})?;
|
||||
if normalized.insert(origin.to_string(), entry).is_some() {
|
||||
return Err(BackendApiClientError::TokenFileMalformed {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("more than one token entry normalizes to `{origin}`"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(BackendTokenFile { tokens: normalized })
|
||||
}
|
||||
|
||||
fn validate_token_entry(
|
||||
entry: &BackendTokenEntry,
|
||||
origin: &BackendOrigin,
|
||||
path: &Path,
|
||||
) -> Result<(), BackendApiClientError> {
|
||||
if !entry.token_type.eq_ignore_ascii_case("Bearer") {
|
||||
return Err(BackendApiClientError::TokenFileMalformed {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("token for `{origin}` does not use the Bearer token type"),
|
||||
});
|
||||
}
|
||||
if entry.access_token.trim().is_empty()
|
||||
|| entry.access_token.contains('\r')
|
||||
|| entry.access_token.contains('\n')
|
||||
{
|
||||
return Err(BackendApiClientError::TokenFileMalformed {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("token for `{origin}` is empty or contains an invalid line break"),
|
||||
});
|
||||
}
|
||||
if reqwest::header::HeaderValue::from_str(&format!("Bearer {}", entry.access_token)).is_err() {
|
||||
return Err(BackendApiClientError::TokenFileMalformed {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("token for `{origin}` cannot be represented as an HTTP header"),
|
||||
});
|
||||
}
|
||||
if let Some(expires_at) = entry.expires_at.as_deref() {
|
||||
let expiration = DateTime::parse_from_rfc3339(expires_at).map_err(|error| {
|
||||
BackendApiClientError::TokenFileMalformed {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("token for `{origin}` has invalid expires_at: {error}"),
|
||||
}
|
||||
})?;
|
||||
if expiration <= Utc::now() {
|
||||
return Err(BackendApiClientError::TokenExpired {
|
||||
origin: origin.clone(),
|
||||
expired_at: expires_at.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_token_file(
|
||||
path: &Path,
|
||||
token_file: &BackendTokenFile,
|
||||
) -> Result<(), BackendApiClientError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or(BackendApiClientError::ConfigDirectoryUnavailable)?;
|
||||
fs::create_dir_all(parent).map_err(|source| BackendApiClientError::Io {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let payload = serde_json::to_vec_pretty(token_file).map_err(|error| {
|
||||
BackendApiClientError::TokenFileMalformed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let temp_path = parent.join(format!(".{TOKEN_FILE_NAME}.tmp-{}", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options
|
||||
.open(&temp_path)
|
||||
.map_err(|source| BackendApiClientError::Io {
|
||||
path: temp_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
file.write_all(&payload)
|
||||
.and_then(|()| file.write_all(b"\n"))
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|source| BackendApiClientError::Io {
|
||||
path: temp_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
fs::rename(&temp_path, path).map_err(|source| BackendApiClientError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_path(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
env::temp_dir().join(format!(
|
||||
"yoi-client-{label}-{}-{nonce}.json",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn write_fixture(path: &Path, value: serde_json::Value) {
|
||||
fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_origin_normalizes_safe_equivalents() {
|
||||
let variants = [
|
||||
"HTTP://Example.COM",
|
||||
"http://example.com/",
|
||||
"http://example.com:80////",
|
||||
];
|
||||
for variant in variants {
|
||||
assert_eq!(
|
||||
BackendOrigin::parse(variant).unwrap().as_str(),
|
||||
"http://example.com"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
BackendOrigin::parse("https://EXAMPLE.com:443/")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"https://example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
BackendOrigin::parse("https://example.com:8443/")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"https://example.com:8443"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_error_detail_preserves_public_server_message() {
|
||||
let detail = backend_error_detail(
|
||||
br#"{"error":"Bad Request","message":"working_directory_runtime_mismatch: Working directory is owned by a different Runtime","diagnostics":[{"code":"working_directory_runtime_mismatch"}]}"#,
|
||||
);
|
||||
let error = BackendApiClientError::BackendResponse {
|
||||
origin: BackendOrigin::parse("http://127.0.0.1:8787").unwrap(),
|
||||
status: 400,
|
||||
detail,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Backend http://127.0.0.1:8787 returned HTTP 400: working_directory_runtime_mismatch: Working directory is owned by a different Runtime"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_origin_rejects_unsafe_authority_changes() {
|
||||
for invalid in [
|
||||
"ftp://example.com",
|
||||
"https://user@example.com",
|
||||
"https://example.com/api",
|
||||
"https://example.com/?query=1",
|
||||
"https://example.com/#fragment",
|
||||
] {
|
||||
assert!(BackendOrigin::parse(invalid).is_err(), "accepted {invalid}");
|
||||
}
|
||||
assert_ne!(
|
||||
BackendOrigin::parse("http://localhost:8787").unwrap(),
|
||||
BackendOrigin::parse("http://127.0.0.1:8787").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_lookup_distinguishes_missing_malformed_mismatch_and_expired() {
|
||||
let missing = temp_path("missing");
|
||||
assert!(matches!(
|
||||
BackendApiClient::from_token_file("http://localhost:8787", &missing),
|
||||
Err(BackendApiClientError::TokenFileMissing { .. })
|
||||
));
|
||||
|
||||
let malformed = temp_path("malformed");
|
||||
fs::write(&malformed, b"not json").unwrap();
|
||||
assert!(matches!(
|
||||
BackendApiClient::from_token_file("http://localhost:8787", &malformed),
|
||||
Err(BackendApiClientError::TokenFileMalformed { .. })
|
||||
));
|
||||
|
||||
let mismatch = temp_path("mismatch");
|
||||
write_fixture(
|
||||
&mismatch,
|
||||
serde_json::json!({"tokens": {"http://localhost:8787": {
|
||||
"token_type": "Bearer", "access_token": "secret"
|
||||
}}}),
|
||||
);
|
||||
assert!(matches!(
|
||||
BackendApiClient::from_token_file("http://127.0.0.1:8787", &mismatch),
|
||||
Err(BackendApiClientError::TokenEntryMissing { .. })
|
||||
));
|
||||
|
||||
let expired = temp_path("expired");
|
||||
write_fixture(
|
||||
&expired,
|
||||
serde_json::json!({"tokens": {"http://localhost:8787": {
|
||||
"token_type": "Bearer",
|
||||
"access_token": "secret",
|
||||
"expires_at": "2000-01-01T00:00:00Z"
|
||||
}}}),
|
||||
);
|
||||
assert!(matches!(
|
||||
BackendApiClient::from_token_file("http://localhost:8787", &expired),
|
||||
Err(BackendApiClientError::TokenExpired { .. })
|
||||
));
|
||||
|
||||
for path in [malformed, mismatch, expired] {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_write_and_lookup_share_origin_normalization() {
|
||||
let path = temp_path("normalized-write");
|
||||
save_backend_token_to_file(
|
||||
"HTTP://Example.COM:80////",
|
||||
"Bearer",
|
||||
"normalized-secret",
|
||||
None,
|
||||
&path,
|
||||
)
|
||||
.unwrap();
|
||||
let contents = fs::read_to_string(&path).unwrap();
|
||||
assert!(contents.contains("\"http://example.com\""));
|
||||
let client = BackendApiClient::from_token_file("http://example.com/", &path).unwrap();
|
||||
assert_eq!(
|
||||
client.authorization_header_value(),
|
||||
"Bearer normalized-secret"
|
||||
);
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_debug_and_errors_never_include_token_value() {
|
||||
let client = BackendApiClient::from_access_token_for_test(
|
||||
"http://localhost:8787",
|
||||
"never-print-this-token",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!format!("{client:?}").contains("never-print-this-token"));
|
||||
assert!(
|
||||
!BackendApiClientError::Unauthorized {
|
||||
origin: client.origin().clone()
|
||||
}
|
||||
.to_string()
|
||||
.contains("never-print-this-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_requests_follow_only_same_origin_redirects() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = thread::spawn(move || {
|
||||
for response in [
|
||||
"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&origin, "redirect-secret").unwrap();
|
||||
let response = client
|
||||
.blocking_request(Method::GET, "/start")
|
||||
.unwrap()
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_requests_reject_cross_origin_redirects_without_leaking_token() {
|
||||
let source = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let target = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
target.set_nonblocking(true).unwrap();
|
||||
let source_origin = format!("http://{}", source.local_addr().unwrap());
|
||||
let target_origin = format!("http://{}", target.local_addr().unwrap());
|
||||
let location = format!("{target_origin}/capture");
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = source.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
||||
let response = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&source_origin, "redirect-secret")
|
||||
.unwrap();
|
||||
let error = client
|
||||
.blocking_request(Method::GET, "/start")
|
||||
.unwrap()
|
||||
.send()
|
||||
.unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("redirect"));
|
||||
assert!(!message.contains("redirect-secret"));
|
||||
handle.join().unwrap();
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
assert!(matches!(
|
||||
target.accept(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_diagnostics_distinguish_unauthorized_and_forbidden() {
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test("http://localhost:8787", "secret")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
client.check_status(StatusCode::UNAUTHORIZED),
|
||||
Err(BackendApiClientError::Unauthorized { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
client.check_status(StatusCode::FORBIDDEN),
|
||||
Err(BackendApiClientError::Forbidden { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::BackendOrigin;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
@@ -9,9 +10,11 @@ pub struct BackendAuthTarget {
|
||||
|
||||
impl BackendAuthTarget {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
}
|
||||
let base_url = base_url.into();
|
||||
let base_url = BackendOrigin::parse(&base_url)
|
||||
.map(|origin| origin.to_string())
|
||||
.unwrap_or(base_url);
|
||||
Self { base_url }
|
||||
}
|
||||
|
||||
fn api_url(&self, path: &str) -> String {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use protocol::stream::{decode_event, encode_method};
|
||||
use protocol::{ErrorCode, Event, Method};
|
||||
use std::collections::VecDeque;
|
||||
use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError};
|
||||
use crate::{BackendApiClient, BackendApiClientError, Client};
|
||||
use reqwest::Method as HttpMethod;
|
||||
use std::fmt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||
pub use workspace_api::{
|
||||
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
|
||||
@@ -15,6 +13,11 @@ pub use workspace_api::{
|
||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
||||
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
||||
WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest,
|
||||
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
|
||||
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
|
||||
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
|
||||
WorkingDirectorySummary as BackendWorkingDirectorySummary,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -101,32 +104,33 @@ impl BackendRuntimeListTarget {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BackendRuntimeClient {
|
||||
target: BackendRuntimeTarget,
|
||||
command_tx: mpsc::UnboundedSender<Method>,
|
||||
events: mpsc::UnboundedReceiver<Event>,
|
||||
diagnostics: VecDeque<Event>,
|
||||
_protocol_task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BackendRuntimeClientError {
|
||||
InvalidTarget(String),
|
||||
Api(BackendApiClientError),
|
||||
Http(reqwest::Error),
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for BackendRuntimeClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidTarget(message) => f.write_str(message),
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
Self::Protocol(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BackendRuntimeClientError {}
|
||||
|
||||
impl From<BackendApiClientError> for BackendRuntimeClientError {
|
||||
fn from(error: BackendApiClientError) -> Self {
|
||||
Self::Api(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for BackendRuntimeClientError {
|
||||
fn from(error: reqwest::Error) -> Self {
|
||||
Self::Http(error)
|
||||
@@ -137,7 +141,7 @@ pub async fn list_backend_workers(
|
||||
target: &BackendRuntimeListTarget,
|
||||
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
||||
validate_list_target(target)?;
|
||||
let http = reqwest::Client::new();
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
||||
let path = backend_runtime_workers_path(
|
||||
target
|
||||
@@ -146,12 +150,9 @@ pub async fn list_backend_workers(
|
||||
.expect("validated Backend Workspace scope"),
|
||||
runtime_id,
|
||||
);
|
||||
let url = join_base_and_path(&target.base_url, &path);
|
||||
return Ok(http
|
||||
.get(url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
let response = api.request(HttpMethod::GET, &path)?.send().await?;
|
||||
api.check_status(response.status())?;
|
||||
return Ok(response
|
||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||
.await?);
|
||||
}
|
||||
@@ -162,12 +163,9 @@ pub async fn list_backend_workers(
|
||||
.as_deref()
|
||||
.expect("validated Backend Workspace scope"),
|
||||
);
|
||||
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
||||
let runtimes = http
|
||||
.get(runtime_url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?;
|
||||
api.check_status(response.status())?;
|
||||
let runtimes = response
|
||||
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
||||
.await?;
|
||||
|
||||
@@ -181,29 +179,43 @@ pub async fn list_backend_workers(
|
||||
.expect("validated Backend Workspace scope"),
|
||||
&runtime.runtime_id,
|
||||
);
|
||||
let url = join_base_and_path(&target.base_url, &path);
|
||||
match http
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())
|
||||
{
|
||||
Ok(response) => {
|
||||
let response = response
|
||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||
.await?;
|
||||
diagnostics.extend(response.diagnostics);
|
||||
items.extend(response.items);
|
||||
}
|
||||
Err(error) => diagnostics.push(BackendDiagnostic {
|
||||
let response = match api.request(HttpMethod::GET, &path)?.send().await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
diagnostics.push(BackendDiagnostic {
|
||||
code: "runtime_worker_list_failed".to_string(),
|
||||
severity: BackendDiagnosticSeverity::Error,
|
||||
message: format!(
|
||||
"failed to list workers for runtime {}: {error}",
|
||||
runtime.runtime_id
|
||||
),
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if matches!(
|
||||
response.status(),
|
||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||
) {
|
||||
api.check_status(response.status())?;
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
diagnostics.push(BackendDiagnostic {
|
||||
code: "runtime_worker_list_failed".to_string(),
|
||||
severity: BackendDiagnosticSeverity::Error,
|
||||
message: format!(
|
||||
"failed to list workers for runtime {}: Backend returned HTTP {}",
|
||||
runtime.runtime_id,
|
||||
response.status().as_u16()
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let response = response
|
||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||
.await?;
|
||||
diagnostics.extend(response.diagnostics);
|
||||
items.extend(response.items);
|
||||
}
|
||||
|
||||
Ok(BackendRuntimeListResponse {
|
||||
@@ -224,7 +236,7 @@ pub async fn list_backend_stopped_workers(
|
||||
"stopped worker listing requires a runtime id".to_string(),
|
||||
));
|
||||
};
|
||||
let http = reqwest::Client::new();
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let path = backend_runtime_workers_path(
|
||||
target
|
||||
.workspace_id
|
||||
@@ -232,12 +244,12 @@ pub async fn list_backend_stopped_workers(
|
||||
.expect("validated Backend Workspace scope"),
|
||||
runtime_id,
|
||||
);
|
||||
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
||||
Ok(http
|
||||
.get(url)
|
||||
let response = api
|
||||
.request(HttpMethod::GET, &format!("{path}?status=stopped"))?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.await?;
|
||||
api.check_status(response.status())?;
|
||||
Ok(response
|
||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||
.await?)
|
||||
}
|
||||
@@ -246,166 +258,61 @@ pub async fn restore_backend_worker(
|
||||
target: &BackendRuntimeTarget,
|
||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||
validate_target(target)?;
|
||||
let http = reqwest::Client::new();
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let path = backend_runtime_worker_restore_path(
|
||||
&target.workspace_id,
|
||||
&target.runtime_id,
|
||||
&target.worker_id,
|
||||
);
|
||||
let url = join_base_and_path(&target.base_url, &path);
|
||||
Ok(http
|
||||
.post(url)
|
||||
let response = api
|
||||
.request(HttpMethod::POST, &path)?
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<BackendWorkerRestoreResponse>()
|
||||
.await?)
|
||||
.await?;
|
||||
let response = api.require_success(response).await?;
|
||||
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
||||
}
|
||||
|
||||
impl BackendRuntimeClient {
|
||||
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
||||
pub async fn connect_backend_runtime(
|
||||
target: BackendRuntimeTarget,
|
||||
) -> Result<Client<WebSocket>, BackendRuntimeClientError> {
|
||||
validate_target(&target)?;
|
||||
let (event_tx, rx) = mpsc::unbounded_channel();
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let protocol_target = target.clone();
|
||||
let protocol_event_tx = event_tx.clone();
|
||||
let protocol_task = tokio::spawn(async move {
|
||||
run_worker_protocol_transport(protocol_target, command_rx, protocol_event_tx).await;
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
target,
|
||||
command_tx,
|
||||
events: rx,
|
||||
diagnostics: VecDeque::new(),
|
||||
_protocol_task: protocol_task,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn try_next_event(&mut self) -> Option<Event> {
|
||||
if let Some(event) = self.diagnostics.pop_front() {
|
||||
return Some(event);
|
||||
}
|
||||
self.events.try_recv().ok()
|
||||
}
|
||||
|
||||
pub async fn next_event(&mut self) -> Option<Event> {
|
||||
if let Some(event) = self.diagnostics.pop_front() {
|
||||
return Some(event);
|
||||
}
|
||||
self.events.recv().await
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, method: &Method) -> Result<(), BackendRuntimeClientError> {
|
||||
self.command_tx.send(method.clone()).map_err(|_| {
|
||||
BackendRuntimeClientError::InvalidTarget(format!(
|
||||
"Backend protocol command stream is closed for {}",
|
||||
self.target.display_label()
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let request = protocol_ws_request(&target, &api).map_err(|error| {
|
||||
BackendRuntimeClientError::Protocol(format!(
|
||||
"Backend protocol request could not be constructed for {}: {error}",
|
||||
target.display_label()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
match WebSocket::connect(request).await {
|
||||
Ok(socket) => Ok(Client::new(socket)),
|
||||
Err(WebSocketError::WebSocket(error)) => Err(BackendRuntimeClientError::Protocol(
|
||||
protocol_connect_error_message(&target, &api, &error),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BackendRuntimeClient {
|
||||
fn drop(&mut self) {
|
||||
self._protocol_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_worker_protocol_transport(
|
||||
target: BackendRuntimeTarget,
|
||||
mut commands: mpsc::UnboundedReceiver<Method>,
|
||||
tx: mpsc::UnboundedSender<Event>,
|
||||
fn protocol_connect_error_message(
|
||||
target: &BackendRuntimeTarget,
|
||||
api: &BackendApiClient,
|
||||
error: &tokio_tungstenite::tungstenite::Error,
|
||||
) -> String {
|
||||
if let tokio_tungstenite::tungstenite::Error::Http(response) = error {
|
||||
if let Ok(status) = reqwest::StatusCode::from_u16(response.status().as_u16()) {
|
||||
if matches!(
|
||||
status,
|
||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||
) {
|
||||
let url = protocol_ws_url(&target);
|
||||
match connect_async(&url).await {
|
||||
Ok((ws, _)) => {
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
maybe_method = commands.recv() => {
|
||||
let Some(method) = maybe_method else {
|
||||
break;
|
||||
};
|
||||
match encode_method(&method) {
|
||||
Ok(text) => {
|
||||
if let Err(error) = sink.send(TungsteniteMessage::Text(text.into())).await {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
"Backend protocol command send failed for {}: {error}",
|
||||
target.display_label()
|
||||
)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
"Backend protocol command could not serialize method for {}: {error}",
|
||||
target.display_label()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
frame = stream.next() => {
|
||||
match frame {
|
||||
Some(Ok(TungsteniteMessage::Text(text))) => {
|
||||
match decode_event(&text) {
|
||||
Ok(event) => {
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
"Backend protocol response was not valid Event JSON for {}: {error}",
|
||||
target.display_label()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(TungsteniteMessage::Close(_))) | None => {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
"Backend protocol command stream closed for {}",
|
||||
target.display_label()
|
||||
)));
|
||||
break;
|
||||
}
|
||||
Some(Ok(TungsteniteMessage::Ping(_)))
|
||||
| Some(Ok(TungsteniteMessage::Pong(_)))
|
||||
| Some(Ok(TungsteniteMessage::Binary(_)))
|
||||
| Some(Ok(TungsteniteMessage::Frame(_))) => {}
|
||||
Some(Err(error)) => {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
"Backend protocol WebSocket error for {}: {error}",
|
||||
target.display_label()
|
||||
)));
|
||||
break;
|
||||
if let Err(error) = api.check_status(status) {
|
||||
return error.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
format!(
|
||||
"Backend protocol WebSocket connect failed for {}: {error}",
|
||||
target.display_label()
|
||||
)));
|
||||
while commands.recv().await.is_some() {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
"Backend protocol command was not sent because command stream is unavailable for {}",
|
||||
target.display_label()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_event(message: impl Into<String>) -> Event {
|
||||
Event::Error {
|
||||
code: ErrorCode::Internal,
|
||||
message: message.into(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeClientError> {
|
||||
@@ -496,6 +403,19 @@ fn backend_runtime_worker_restore_path(
|
||||
)
|
||||
}
|
||||
|
||||
fn protocol_ws_request(
|
||||
target: &BackendRuntimeTarget,
|
||||
api: &BackendApiClient,
|
||||
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, String> {
|
||||
let mut request = protocol_ws_url(target)
|
||||
.into_client_request()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let value = HeaderValue::from_str(&api.authorization_header_value())
|
||||
.map_err(|_| "saved Backend token is not a valid Authorization header".to_string())?;
|
||||
request.headers_mut().insert(AUTHORIZATION, value);
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||
let path = format!(
|
||||
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
||||
@@ -557,6 +477,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_request_attaches_saved_bearer_authorization() {
|
||||
let target = BackendRuntimeTarget::new(
|
||||
"http://127.0.0.1:8787/",
|
||||
"workspace alpha",
|
||||
"runtime/one",
|
||||
"worker one",
|
||||
);
|
||||
let api = BackendApiClient::from_access_token_for_test(
|
||||
"http://127.0.0.1:8787",
|
||||
"websocket-secret",
|
||||
)
|
||||
.unwrap();
|
||||
let request = protocol_ws_request(&target, &api).unwrap();
|
||||
assert_eq!(
|
||||
request.headers().get(AUTHORIZATION).unwrap(),
|
||||
"Bearer websocket-secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
||||
let payload = serde_json::json!({
|
||||
@@ -590,8 +530,8 @@ mod tests {
|
||||
.unwrap()
|
||||
.occupied_by
|
||||
.expect("occupied Workdir");
|
||||
assert_eq!(occupied_by.worker.runtime_id, "arcadia");
|
||||
assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64");
|
||||
assert_eq!(occupied_by.runtime_id, "arcadia");
|
||||
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
|
||||
|
||||
let mut stale = payload;
|
||||
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use crate::{BackendApiClient, BackendApiClientError};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use workspace_api::{
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
|
||||
WorkspaceSummary,
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkspace {
|
||||
pub workspace_id: String,
|
||||
pub owner_account_id: Option<String>,
|
||||
pub display_name: String,
|
||||
pub state: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
pub type BackendWorkspace = WorkspaceSummary;
|
||||
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
|
||||
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -29,25 +29,6 @@ pub struct CreateBackendWorkspaceRepository {
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct CreateBackendWorkspaceResponse {
|
||||
pub workspace: BackendWorkspace,
|
||||
pub repository: CreateBackendWorkspaceRepositoryRecord,
|
||||
pub config_revision: u64,
|
||||
pub request_fingerprint: String,
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct CreateBackendWorkspaceRepositoryRecord {
|
||||
pub workspace_id: String,
|
||||
pub repository_id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub uri: String,
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendWorkspaceCatalogTarget {
|
||||
pub base_url: String,
|
||||
@@ -64,7 +45,7 @@ impl BackendWorkspaceCatalogTarget {
|
||||
#[derive(Debug)]
|
||||
pub enum BackendWorkspaceClientError {
|
||||
InvalidTarget(String),
|
||||
RequestFailed { status: u16, message: String },
|
||||
Api(BackendApiClientError),
|
||||
Http(reqwest::Error),
|
||||
}
|
||||
|
||||
@@ -72,9 +53,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidTarget(message) => f.write_str(message),
|
||||
Self::RequestFailed { status, message } => {
|
||||
write!(f, "Backend request failed with HTTP {status}: {message}")
|
||||
}
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
@@ -82,6 +61,12 @@ impl fmt::Display for BackendWorkspaceClientError {
|
||||
|
||||
impl std::error::Error for BackendWorkspaceClientError {}
|
||||
|
||||
impl From<BackendApiClientError> for BackendWorkspaceClientError {
|
||||
fn from(error: BackendApiClientError) -> Self {
|
||||
Self::Api(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||
fn from(error: reqwest::Error) -> Self {
|
||||
Self::Http(error)
|
||||
@@ -91,56 +76,72 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||
pub async fn list_backend_workspaces(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
validate_target(target)?;
|
||||
let url = format!(
|
||||
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
|
||||
target.base_url.trim_end_matches('/')
|
||||
);
|
||||
let response = reqwest::Client::new().get(url).send().await?;
|
||||
let response = require_success(response).await?;
|
||||
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
list_backend_workspaces_with_client(&client).await
|
||||
}
|
||||
|
||||
async fn list_backend_workspaces_with_client(
|
||||
client: &BackendApiClient,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
let response = client
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
||||
)?
|
||||
.send()
|
||||
.await?;
|
||||
client.check_status(response.status())?;
|
||||
Ok(response.json::<WorkspaceCatalogListResponse>().await?.0)
|
||||
}
|
||||
|
||||
pub async fn create_backend_workspace(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
request: &CreateBackendWorkspaceRequest,
|
||||
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
||||
validate_target(target)?;
|
||||
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
|
||||
let response = reqwest::Client::new()
|
||||
.post(url)
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let response = client
|
||||
.request(Method::POST, "/api/workspaces")?
|
||||
.json(request)
|
||||
.send()
|
||||
.await?;
|
||||
let response = require_success(response).await?;
|
||||
client.check_status(response.status())?;
|
||||
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
||||
}
|
||||
|
||||
async fn require_success(
|
||||
response: reqwest::Response,
|
||||
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.unwrap_or_default();
|
||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
||||
}
|
||||
|
||||
fn validate_target(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend API base URL must start with http:// or https://".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_catalog_request_uses_shared_bearer_client() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /api/workspaces?limit=200 "));
|
||||
assert!(request.contains("authorization: bearer catalog-secret\r\n"));
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap();
|
||||
assert!(
|
||||
list_backend_workspaces_with_client(&client)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
use protocol::stream::{decode_event, encode_method};
|
||||
use protocol::{Event, Method};
|
||||
|
||||
use crate::transport::Socket;
|
||||
|
||||
/// Typed Worker protocol client over an injected message transport.
|
||||
pub struct Client<T> {
|
||||
socket: T,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError<E> {
|
||||
Transport(E),
|
||||
Protocol(serde_json::Error),
|
||||
}
|
||||
|
||||
impl<T> Client<T> {
|
||||
pub fn new(socket: T) -> Self {
|
||||
Self { socket }
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> T {
|
||||
self.socket
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Socket> Client<T> {
|
||||
pub async fn send(&mut self, method: &Method) -> Result<(), ClientError<T::Error>> {
|
||||
let message = encode_method(method).map_err(ClientError::Protocol)?;
|
||||
self.socket
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(ClientError::Transport)
|
||||
}
|
||||
|
||||
pub async fn next_event(&mut self) -> Result<Option<Event>, ClientError<T::Error>> {
|
||||
self.socket
|
||||
.next()
|
||||
.await
|
||||
.map_err(ClientError::Transport)?
|
||||
.map(|message| decode_event(&message).map_err(ClientError::Protocol))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn try_next_event(&mut self) -> Result<Option<Event>, ClientError<T::Error>> {
|
||||
self.socket
|
||||
.try_next()
|
||||
.map_err(ClientError::Transport)?
|
||||
.map(|message| decode_event(&message).map_err(ClientError::Protocol))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: fmt::Display> fmt::Display for ClientError<E> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Transport(error) => write!(formatter, "Worker transport error: {error}"),
|
||||
Self::Protocol(error) => write!(formatter, "Worker protocol error: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Error + 'static> Error for ClientError<E> {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::Transport(error) => Some(error),
|
||||
Self::Protocol(error) => Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use std::convert::Infallible;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use protocol::stream::{decode_method, encode_event};
|
||||
use protocol::{Event, Method, WorkerStatus};
|
||||
|
||||
use super::Client;
|
||||
use crate::transport::Socket;
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestSocket {
|
||||
sent: Vec<String>,
|
||||
incoming: VecDeque<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Socket for TestSocket {
|
||||
type Error = Infallible;
|
||||
|
||||
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
||||
self.sent.push(message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
Ok(self.incoming.pop_front())
|
||||
}
|
||||
|
||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
Ok(self.incoming.pop_front())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn encodes_methods_and_decodes_events_above_transport() {
|
||||
let mut socket = TestSocket::default();
|
||||
socket.incoming.push_back(
|
||||
encode_event(&Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
})
|
||||
.expect("encode event"),
|
||||
);
|
||||
let mut client = Client::new(socket);
|
||||
|
||||
client
|
||||
.send(&Method::run_text("hello"))
|
||||
.await
|
||||
.expect("send method");
|
||||
assert!(matches!(
|
||||
decode_method(&client.socket.sent[0]),
|
||||
Ok(Method::Run { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
client.next_event().await,
|
||||
Ok(Some(Event::Status {
|
||||
status: WorkerStatus::Idle
|
||||
}))
|
||||
));
|
||||
}
|
||||
}
|
||||
+23
-32
@@ -1,57 +1,48 @@
|
||||
//! Worker プロトコルを喋るクライアント。
|
||||
//! Backend Workspace/Runtime と既存 Worker protocol へ接続するクライアント。
|
||||
//!
|
||||
//! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、
|
||||
//! `Event` を受け取る低レベル接続。
|
||||
//! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY`
|
||||
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
|
||||
//! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。
|
||||
//!
|
||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||
//! Standalone execution is owned by the `standalone` crate and does not spawn
|
||||
//! a Worker subprocess through this crate.
|
||||
|
||||
pub mod backend_auth;
|
||||
pub mod backend_api;
|
||||
mod backend_auth;
|
||||
pub mod backend_runtime;
|
||||
pub mod backend_workspace;
|
||||
pub mod runtime_command;
|
||||
pub mod spawn;
|
||||
mod client;
|
||||
pub mod target;
|
||||
pub mod ticket_role;
|
||||
mod worker_client;
|
||||
pub mod transport;
|
||||
mod workspace_product;
|
||||
|
||||
pub use backend_api::{
|
||||
BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path,
|
||||
save_backend_token,
|
||||
};
|
||||
pub use backend_auth::{
|
||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||
poll_device_login, start_device_login, wait_for_device_login,
|
||||
};
|
||||
pub use backend_runtime::{
|
||||
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClient, BackendRuntimeClientError,
|
||||
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError,
|
||||
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
|
||||
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
||||
list_backend_workers, restore_backend_worker,
|
||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
|
||||
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
|
||||
};
|
||||
pub use backend_workspace::{
|
||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
||||
};
|
||||
pub use runtime_command::WorkerRuntimeCommand;
|
||||
pub use client::{Client, ClientError};
|
||||
pub use target::{
|
||||
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind,
|
||||
WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest,
|
||||
WorkerResume, WorkerSpawn,
|
||||
BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent,
|
||||
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
|
||||
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
||||
};
|
||||
|
||||
pub use spawn::{
|
||||
SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
|
||||
spawn_worker, spawn_worker_with_options,
|
||||
pub use workspace_api::{
|
||||
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
|
||||
CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse,
|
||||
CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole,
|
||||
CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary,
|
||||
};
|
||||
pub use ticket_role::{
|
||||
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
|
||||
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning,
|
||||
launch_ticket_role_worker, launch_ticket_role_worker_with_options, plan_ticket_role_launch,
|
||||
plan_ticket_role_launch_with_config,
|
||||
};
|
||||
pub use worker_client::WorkerClient;
|
||||
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
||||
pub use workspace_product::BackendWorkspaceProductClient;
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
//! Worker runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ
|
||||
//! ハンドシェイク。
|
||||
//!
|
||||
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
|
||||
//! 指定してこの関数に渡す。worker はそれを受けて socket を bind し、stderr に
|
||||
//! `YOI-READY\t<name>\t<socket>` を吐く。
|
||||
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
|
||||
//! UI の進捗表示や E2E のログ収集はここで賄う。
|
||||
//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス
|
||||
//! ライフサイクルから切り離した detached worker を作る。ready 後の lifecycle
|
||||
//! 管理は runtime ディレクトリ / socket を介して行う。
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::WorkerRuntimeCommand;
|
||||
use tokio::process::Command;
|
||||
use uuid::Uuid;
|
||||
|
||||
const READY_PREFIX: &str = "YOI-READY\t";
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerProcessLaunchConfig {
|
||||
pub runtime_command: WorkerRuntimeCommand,
|
||||
/// `worker.name` として使う識別子。runtime ディレクトリ
|
||||
/// (`manifest::paths::worker_runtime_dir`) の解決と、ready 行に乗る
|
||||
/// 名前との突き合わせに使う。
|
||||
pub worker_name: String,
|
||||
/// Optional reusable Profile selector. Worker identity is always supplied
|
||||
/// separately with `--worker`; profile selection must not imply a name.
|
||||
pub profile: Option<String>,
|
||||
/// Explicit runtime workspace root. The child receives it via
|
||||
/// `--workspace` so startup does not infer workspace identity from the
|
||||
/// parent process cwd.
|
||||
pub workspace_root: PathBuf,
|
||||
/// Optional child process cwd. This is not runtime workspace identity and
|
||||
/// is not passed as a CLI argument; the child observes it as its ordinary
|
||||
/// process current directory.
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
|
||||
/// resume させる。
|
||||
pub resume_from: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct WorkerProcessLaunchOptions {
|
||||
/// Extra child CLI arguments supplied by an upper resolver layer. The
|
||||
/// low-level launch config intentionally does not model Ticket IDs,
|
||||
/// Ticket roles, orchestration roles, executable authority, or raw
|
||||
/// browser-provided profile/cwd/workspace inputs.
|
||||
pub extra_args: Vec<String>,
|
||||
}
|
||||
|
||||
impl WorkerProcessLaunchOptions {
|
||||
pub fn with_hidden_arg(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.extra_args.extend([name.into(), value.into()]);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.extra_args.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub type SpawnConfig = WorkerProcessLaunchConfig;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SpawnReady {
|
||||
pub worker_name: String,
|
||||
pub socket_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SpawnError {
|
||||
Io(io::Error),
|
||||
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。
|
||||
RuntimeDirUnavailable,
|
||||
WorkerLaunchFailed {
|
||||
command: WorkerRuntimeCommand,
|
||||
source: io::Error,
|
||||
},
|
||||
WorkerExitedEarly {
|
||||
stderr_tail: String,
|
||||
},
|
||||
Timeout,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SpawnError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(e) => write!(f, "io error: {e}"),
|
||||
Self::RuntimeDirUnavailable => write!(
|
||||
f,
|
||||
"could not resolve runtime directory (set YOI_HOME, YOI_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)"
|
||||
),
|
||||
Self::WorkerLaunchFailed { command, source } => write!(
|
||||
f,
|
||||
"failed to launch worker runtime command `{command}`: {source}"
|
||||
),
|
||||
Self::WorkerExitedEarly { stderr_tail } => {
|
||||
if stderr_tail.is_empty() {
|
||||
write!(f, "worker exited before becoming ready")
|
||||
} else {
|
||||
write!(f, "worker exited before becoming ready: {stderr_tail}")
|
||||
}
|
||||
}
|
||||
Self::Timeout => write!(
|
||||
f,
|
||||
"worker did not become ready within {}s",
|
||||
READY_TIMEOUT.as_secs()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SpawnError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) | Self::WorkerLaunchFailed { source: error, .. } => Some(error),
|
||||
Self::RuntimeDirUnavailable | Self::WorkerExitedEarly { .. } | Self::Timeout => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for SpawnError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_args(
|
||||
config: &WorkerProcessLaunchConfig,
|
||||
options: &WorkerProcessLaunchOptions,
|
||||
) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"--workspace".to_string(),
|
||||
config.workspace_root.display().to_string(),
|
||||
];
|
||||
if let Some(id) = config.resume_from {
|
||||
args.extend([
|
||||
"--session".to_string(),
|
||||
id.to_string(),
|
||||
"--worker".to_string(),
|
||||
config.worker_name.clone(),
|
||||
]);
|
||||
} else {
|
||||
args.extend(["--worker".to_string(), config.worker_name.clone()]);
|
||||
if let Some(profile) = &config.profile {
|
||||
args.extend(["--profile".to_string(), profile.clone()]);
|
||||
}
|
||||
}
|
||||
args.extend(options.extra_args.clone());
|
||||
args
|
||||
}
|
||||
|
||||
/// worker を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。
|
||||
///
|
||||
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
|
||||
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
|
||||
pub async fn spawn_worker<F>(
|
||||
config: WorkerProcessLaunchConfig,
|
||||
progress: F,
|
||||
) -> Result<SpawnReady, SpawnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
spawn_worker_with_options(config, WorkerProcessLaunchOptions::default(), progress).await
|
||||
}
|
||||
|
||||
pub async fn spawn_worker_with_options<F>(
|
||||
config: WorkerProcessLaunchConfig,
|
||||
options: WorkerProcessLaunchOptions,
|
||||
mut progress: F,
|
||||
) -> Result<SpawnReady, SpawnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let worker_runtime_dir = manifest::paths::worker_runtime_dir(&config.worker_name)
|
||||
.ok_or(SpawnError::RuntimeDirUnavailable)?;
|
||||
std::fs::create_dir_all(&worker_runtime_dir).map_err(SpawnError::Io)?;
|
||||
let stderr_path = worker_runtime_dir.join("stderr.log");
|
||||
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
|
||||
|
||||
let mut command = Command::new(config.runtime_command.program());
|
||||
command
|
||||
.args(config.runtime_command.prefix_args())
|
||||
.current_dir(config.cwd.as_ref().unwrap_or(&config.workspace_root))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.process_group(0);
|
||||
for arg in runtime_args(&config, &options) {
|
||||
command.arg(arg);
|
||||
}
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|source| SpawnError::WorkerLaunchFailed {
|
||||
command: config.runtime_command.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
// Default `kill_on_drop = false` plus `process_group(0)` makes this
|
||||
// a detached Worker once startup succeeds: dropping the handle does not
|
||||
// terminate it, and terminal-generated signals for the parent's
|
||||
// process group do not hit the Worker. Runtime state/socket files are
|
||||
// the source of truth after that point.
|
||||
let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await {
|
||||
Ok(ready) => ready,
|
||||
Err(e) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let _ = child.wait().await;
|
||||
});
|
||||
Ok(ready)
|
||||
}
|
||||
|
||||
async fn wait_for_ready_file<F>(
|
||||
progress: &mut F,
|
||||
stderr_path: &Path,
|
||||
child: &mut tokio::process::Child,
|
||||
) -> Result<SpawnReady, SpawnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let mut tail = StderrTail::new();
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
let mut offset = 0usize;
|
||||
|
||||
loop {
|
||||
let content = match tokio::fs::read_to_string(stderr_path).await {
|
||||
Ok(content) => content,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(SpawnError::Io(e)),
|
||||
};
|
||||
if content.len() > offset {
|
||||
for line in content[offset..].lines() {
|
||||
if let Some(rest) = line.strip_prefix(READY_PREFIX) {
|
||||
let mut parts = rest.splitn(2, '\t');
|
||||
let worker_name = parts.next().unwrap_or("").to_string();
|
||||
let socket_str = parts.next().unwrap_or("").to_string();
|
||||
if worker_name.is_empty() || socket_str.is_empty() {
|
||||
return Err(SpawnError::WorkerExitedEarly {
|
||||
stderr_tail: format!("malformed ready line: {line}"),
|
||||
});
|
||||
}
|
||||
let socket_path = PathBuf::from(socket_str);
|
||||
wait_for_socket(
|
||||
&socket_path,
|
||||
deadline,
|
||||
child,
|
||||
stderr_path,
|
||||
&mut tail,
|
||||
&mut offset,
|
||||
)
|
||||
.await?;
|
||||
return Ok(SpawnReady {
|
||||
worker_name,
|
||||
socket_path,
|
||||
});
|
||||
}
|
||||
tail.push(line);
|
||||
progress(line);
|
||||
}
|
||||
offset = content.len();
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(SpawnError::Timeout);
|
||||
}
|
||||
tokio::select! {
|
||||
status = child.wait() => {
|
||||
let _ = status;
|
||||
// Worker は exit 直前に最終 stderr 行を flush することがある。
|
||||
// child.wait() が解決した後に再読みして、原因行を取りこ
|
||||
// ぼさず WorkerExitedEarly に載せる。
|
||||
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
|
||||
return Err(SpawnError::WorkerExitedEarly {
|
||||
stderr_tail: tail.into_string(),
|
||||
});
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_socket(
|
||||
socket_path: &Path,
|
||||
deadline: tokio::time::Instant,
|
||||
child: &mut tokio::process::Child,
|
||||
stderr_path: &Path,
|
||||
tail: &mut StderrTail,
|
||||
offset: &mut usize,
|
||||
) -> Result<(), SpawnError> {
|
||||
loop {
|
||||
match tokio::net::UnixStream::connect(socket_path).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e)
|
||||
if e.kind() == io::ErrorKind::NotFound
|
||||
|| e.kind() == io::ErrorKind::ConnectionRefused => {}
|
||||
Err(e) => return Err(SpawnError::Io(e)),
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(SpawnError::Timeout);
|
||||
}
|
||||
tokio::select! {
|
||||
status = child.wait() => {
|
||||
let _ = status;
|
||||
drain_stderr_into_tail(stderr_path, tail, offset).await;
|
||||
return Err(SpawnError::WorkerExitedEarly {
|
||||
stderr_tail: tail.as_string(),
|
||||
});
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_stderr_into_tail(stderr_path: &Path, tail: &mut StderrTail, offset: &mut usize) {
|
||||
let Ok(content) = tokio::fs::read_to_string(stderr_path).await else {
|
||||
return;
|
||||
};
|
||||
if content.len() <= *offset {
|
||||
return;
|
||||
}
|
||||
for line in content[*offset..].lines() {
|
||||
if !line.starts_with(READY_PREFIX) {
|
||||
tail.push(line);
|
||||
}
|
||||
}
|
||||
*offset = content.len();
|
||||
}
|
||||
|
||||
struct StderrTail {
|
||||
lines: std::collections::VecDeque<String>,
|
||||
}
|
||||
|
||||
impl StderrTail {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
lines: std::collections::VecDeque::with_capacity(8),
|
||||
}
|
||||
}
|
||||
fn push(&mut self, line: &str) {
|
||||
if self.lines.len() == 8 {
|
||||
self.lines.pop_front();
|
||||
}
|
||||
self.lines.push_back(line.to_string());
|
||||
}
|
||||
fn as_string(&self) -> String {
|
||||
self.lines.iter().cloned().collect::<Vec<_>>().join(" | ")
|
||||
}
|
||||
fn into_string(self) -> String {
|
||||
self.lines.into_iter().collect::<Vec<_>>().join(" | ")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn base_config() -> WorkerProcessLaunchConfig {
|
||||
WorkerProcessLaunchConfig {
|
||||
runtime_command: WorkerRuntimeCommand::new("/bin/yoi", vec![OsString::from("worker")]),
|
||||
worker_name: "explicit-worker".to_string(),
|
||||
profile: Some("project:companion".to_string()),
|
||||
workspace_root: PathBuf::from("/work/other-project"),
|
||||
cwd: None,
|
||||
resume_from: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_args_keep_workspace_worker_and_profile_separate() {
|
||||
assert_eq!(
|
||||
runtime_args(&base_config(), &WorkerProcessLaunchOptions::default()),
|
||||
vec![
|
||||
"--workspace",
|
||||
"/work/other-project",
|
||||
"--worker",
|
||||
"explicit-worker",
|
||||
"--profile",
|
||||
"project:companion",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_args_use_session_mode_without_profile_identity_alias() {
|
||||
let mut config = base_config();
|
||||
config.resume_from = Some(Uuid::nil());
|
||||
assert_eq!(
|
||||
runtime_args(&config, &WorkerProcessLaunchOptions::default()),
|
||||
vec![
|
||||
"--workspace",
|
||||
"/work/other-project",
|
||||
"--session",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"--worker",
|
||||
"explicit-worker",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_args_include_upper_resolver_extra_args_without_child_cwd() {
|
||||
let mut config = base_config();
|
||||
config.cwd = Some(PathBuf::from("/work/main/.worktree/orchestration/yoi"));
|
||||
|
||||
assert_eq!(
|
||||
runtime_args(
|
||||
&config,
|
||||
&WorkerProcessLaunchOptions::default()
|
||||
.with_hidden_arg("--ticket-role", "orchestrator"),
|
||||
),
|
||||
vec![
|
||||
"--workspace",
|
||||
"/work/other-project",
|
||||
"--worker",
|
||||
"explicit-worker",
|
||||
"--profile",
|
||||
"project:companion",
|
||||
"--ticket-role",
|
||||
"orchestrator",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+157
-190
@@ -1,16 +1,20 @@
|
||||
use std::fmt;
|
||||
use std::{fmt, path::PathBuf};
|
||||
|
||||
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||
use crate::{
|
||||
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
|
||||
BackendRuntimeTarget,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TargetKind {
|
||||
Local,
|
||||
/// One-process Standalone authority with no Runtime or Workspace backend.
|
||||
Standalone,
|
||||
Backend,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedTarget {
|
||||
Local,
|
||||
Standalone,
|
||||
Backend {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
@@ -20,7 +24,7 @@ pub enum ResolvedTarget {
|
||||
impl ResolvedTarget {
|
||||
pub fn kind(&self) -> TargetKind {
|
||||
match self {
|
||||
Self::Local => TargetKind::Local,
|
||||
Self::Standalone => TargetKind::Standalone,
|
||||
Self::Backend { .. } => TargetKind::Backend,
|
||||
}
|
||||
}
|
||||
@@ -29,31 +33,12 @@ impl ResolvedTarget {
|
||||
impl fmt::Display for TargetKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Local => f.write_str("local"),
|
||||
Self::Standalone => f.write_str("Standalone"),
|
||||
Self::Backend => f.write_str("Backend"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LocalTarget;
|
||||
|
||||
impl LocalTarget {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn runtime_command(&self) -> Result<WorkerRuntimeCommand, TargetError> {
|
||||
WorkerRuntimeCommand::resolve().map_err(TargetError::local_runtime_command)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalTarget {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendTarget {
|
||||
pub base_url: String,
|
||||
@@ -62,11 +47,19 @@ pub struct BackendTarget {
|
||||
|
||||
impl BackendTarget {
|
||||
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
|
||||
let base_url = base_url.into();
|
||||
let base_url = BackendOrigin::parse(&base_url)
|
||||
.map(|origin| origin.to_string())
|
||||
.unwrap_or(base_url);
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
base_url,
|
||||
workspace_id: workspace_id.map(Into::into),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authenticated_client(&self) -> Result<BackendApiClient, BackendApiClientError> {
|
||||
BackendApiClient::from_stored_token(&self.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -108,34 +101,31 @@ impl WorkerConnectionSelector {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerSpawn {
|
||||
pub runtime_command: WorkerRuntimeCommand,
|
||||
pub state_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerByName {
|
||||
pub runtime_command: WorkerRuntimeCommand,
|
||||
pub struct StandaloneWorkerListIntent {
|
||||
pub state_dir: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub include_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerResume {
|
||||
pub runtime_command: WorkerRuntimeCommand,
|
||||
pub struct StandaloneWorkerResumeIntent {
|
||||
pub state_dir: PathBuf,
|
||||
pub worker_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Dashboard {
|
||||
Local {
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
},
|
||||
Backend {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
},
|
||||
pub struct Dashboard {
|
||||
pub base_url: String,
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerList {
|
||||
pub local_runtime_command: Option<WorkerRuntimeCommand>,
|
||||
pub backend_target: Option<BackendRuntimeListTarget>,
|
||||
pub backend_target: BackendRuntimeListTarget,
|
||||
pub include_stopped: bool,
|
||||
}
|
||||
|
||||
@@ -161,12 +151,6 @@ impl TargetError {
|
||||
message: format!("invalid {target} target: {}", message.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_runtime_command(error: std::io::Error) -> Self {
|
||||
Self {
|
||||
message: format!("failed to resolve local Worker runtime command: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TargetError {
|
||||
@@ -183,71 +167,40 @@ pub trait Target: fmt::Debug + Send + Sync {
|
||||
/// Resolve the target once for Workspace product-state operations.
|
||||
///
|
||||
/// Backend targets must carry an explicit Workspace identity. Callers use
|
||||
/// this value instead of rediscovering Backend/local authority from cwd or
|
||||
/// process configuration after command dispatch.
|
||||
/// this value instead of rediscovering authority from cwd or process
|
||||
/// configuration after command dispatch.
|
||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError>;
|
||||
|
||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
|
||||
|
||||
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
|
||||
|
||||
fn resume_worker(&self) -> Result<WorkerResume, TargetError>;
|
||||
|
||||
fn dashboard(&self) -> Result<Dashboard, TargetError>;
|
||||
|
||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError>;
|
||||
|
||||
fn connect_worker(
|
||||
&self,
|
||||
selector: WorkerConnectionSelector,
|
||||
) -> Result<WorkerConnection, TargetError>;
|
||||
}
|
||||
|
||||
impl Target for LocalTarget {
|
||||
fn kind(&self) -> TargetKind {
|
||||
TargetKind::Local
|
||||
}
|
||||
|
||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
||||
Ok(ResolvedTarget::Local)
|
||||
}
|
||||
|
||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||
Ok(WorkerSpawn {
|
||||
runtime_command: self.runtime_command()?,
|
||||
})
|
||||
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
||||
}
|
||||
|
||||
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
|
||||
Ok(WorkerByName {
|
||||
runtime_command: self.runtime_command()?,
|
||||
})
|
||||
fn standalone_worker_list(
|
||||
&self,
|
||||
_include_all: bool,
|
||||
) -> Result<StandaloneWorkerListIntent, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"standalone Worker listing",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
|
||||
Ok(WorkerResume {
|
||||
runtime_command: self.runtime_command()?,
|
||||
})
|
||||
fn standalone_worker_resume(
|
||||
&self,
|
||||
_worker_id: String,
|
||||
) -> Result<StandaloneWorkerResumeIntent, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"standalone Worker restore",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||
Ok(Dashboard::Local {
|
||||
runtime_command: self.runtime_command()?,
|
||||
})
|
||||
Err(TargetError::unsupported("Worker dashboard", self.kind()))
|
||||
}
|
||||
|
||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
if request.runtime_id.is_some() {
|
||||
return Err(TargetError::unsupported(
|
||||
"Explicit runtime id for local worker listing",
|
||||
self.kind(),
|
||||
));
|
||||
}
|
||||
Ok(WorkerList {
|
||||
local_runtime_command: Some(self.runtime_command()?),
|
||||
backend_target: None,
|
||||
include_stopped: request.include_stopped,
|
||||
})
|
||||
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
Err(TargetError::unsupported("Worker listing", self.kind()))
|
||||
}
|
||||
|
||||
fn connect_worker(
|
||||
@@ -261,6 +214,59 @@ impl Target for LocalTarget {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StandaloneTarget {
|
||||
state_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl StandaloneTarget {
|
||||
#[must_use]
|
||||
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
state_dir: state_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Target for StandaloneTarget {
|
||||
fn kind(&self) -> TargetKind {
|
||||
TargetKind::Standalone
|
||||
}
|
||||
|
||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
||||
Ok(ResolvedTarget::Standalone)
|
||||
}
|
||||
|
||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||
Ok(WorkerSpawn {
|
||||
state_dir: self.state_dir.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn standalone_worker_list(
|
||||
&self,
|
||||
include_all: bool,
|
||||
) -> Result<StandaloneWorkerListIntent, TargetError> {
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?;
|
||||
Ok(StandaloneWorkerListIntent {
|
||||
state_dir: self.state_dir.clone(),
|
||||
cwd,
|
||||
include_all,
|
||||
})
|
||||
}
|
||||
|
||||
fn standalone_worker_resume(
|
||||
&self,
|
||||
worker_id: String,
|
||||
) -> Result<StandaloneWorkerResumeIntent, TargetError> {
|
||||
Ok(StandaloneWorkerResumeIntent {
|
||||
state_dir: self.state_dir.clone(),
|
||||
worker_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Target for BackendTarget {
|
||||
fn kind(&self) -> TargetKind {
|
||||
TargetKind::Backend
|
||||
@@ -279,42 +285,27 @@ impl Target for BackendTarget {
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
||||
}
|
||||
|
||||
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"Worker name attachment",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
|
||||
Err(TargetError::unsupported("Worker resume", self.kind()))
|
||||
}
|
||||
|
||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||
match self.resolve()? {
|
||||
ResolvedTarget::Backend {
|
||||
let ResolvedTarget::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
} => Ok(Dashboard::Backend {
|
||||
} = self.resolve()?
|
||||
else {
|
||||
unreachable!("BackendTarget resolves only Backend authority")
|
||||
};
|
||||
Ok(Dashboard {
|
||||
base_url,
|
||||
workspace_id,
|
||||
}),
|
||||
ResolvedTarget::Local => unreachable!("BackendTarget cannot resolve as Local"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
Ok(WorkerList {
|
||||
local_runtime_command: None,
|
||||
backend_target: Some(BackendRuntimeListTarget::new(
|
||||
backend_target: BackendRuntimeListTarget::new(
|
||||
self.base_url.clone(),
|
||||
self.workspace_id.clone(),
|
||||
request.runtime_id,
|
||||
)),
|
||||
),
|
||||
include_stopped: request.include_stopped,
|
||||
})
|
||||
}
|
||||
@@ -371,8 +362,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_target_resolves_local_product_state_authority() {
|
||||
assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local);
|
||||
fn standalone_target_carries_in_process_state_without_runtime_command() {
|
||||
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
|
||||
|
||||
assert_eq!(target.kind(), TargetKind::Standalone);
|
||||
assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone);
|
||||
assert_eq!(
|
||||
target.spawn_worker().unwrap(),
|
||||
WorkerSpawn {
|
||||
state_dir: PathBuf::from("/tmp/yoi-standalone-state"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_target_never_exposes_workspace_worker_operations() {
|
||||
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
|
||||
|
||||
assert_eq!(
|
||||
target
|
||||
.list_workers(WorkerListRequest::new(None))
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
"Worker listing is not supported by Standalone target"
|
||||
);
|
||||
assert_eq!(
|
||||
target.dashboard().unwrap_err().to_string(),
|
||||
"Worker dashboard is not supported by Standalone target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -381,26 +398,13 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
target.dashboard().unwrap(),
|
||||
Dashboard::Backend {
|
||||
Dashboard {
|
||||
base_url: "http://127.0.0.1:8787".to_string(),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_rejects_dashboard_without_workspace_selection() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||
|
||||
assert!(
|
||||
target
|
||||
.dashboard()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("workspace selection is required")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_builds_worker_list() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
||||
@@ -408,26 +412,13 @@ mod tests {
|
||||
.list_workers(WorkerListRequest::new(Some("runtime-a".to_string())))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787");
|
||||
assert_eq!(
|
||||
workers.backend_target.as_ref().unwrap().base_url,
|
||||
"http://127.0.0.1:8787"
|
||||
);
|
||||
assert_eq!(
|
||||
workers
|
||||
.backend_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.workspace_id
|
||||
.as_deref(),
|
||||
workers.backend_target.workspace_id.as_deref(),
|
||||
Some("workspace-a")
|
||||
);
|
||||
assert_eq!(
|
||||
workers
|
||||
.backend_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.runtime_id
|
||||
.as_deref(),
|
||||
workers.backend_target.runtime_id.as_deref(),
|
||||
Some("runtime-a")
|
||||
);
|
||||
}
|
||||
@@ -446,41 +437,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_rejects_worker_connection_before_workspace_selection() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||
let error =
|
||||
match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) {
|
||||
Ok(_) => panic!("unscoped connection must fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
fn standalone_target_builds_explicit_worker_intents() {
|
||||
let target = StandaloneTarget::new("/tmp/yoi-client-workers");
|
||||
let list = target.standalone_worker_list(true).unwrap();
|
||||
assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-workers"));
|
||||
assert!(list.include_all);
|
||||
assert!(list.cwd.is_absolute());
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("workspace selection is required")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_rejects_local_worker_operations() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||
let err = target.spawn_worker().unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Worker spawn is not supported by Backend target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_target_builds_local_worker_list() {
|
||||
let target = LocalTarget::new();
|
||||
let workers = target
|
||||
.list_workers(WorkerListRequest::with_stopped(None))
|
||||
let resume = target
|
||||
.standalone_worker_resume("019d1234-0000-7000-8000-000000000000".to_string())
|
||||
.unwrap();
|
||||
|
||||
assert!(workers.local_runtime_command.is_some());
|
||||
assert!(workers.backend_target.is_none());
|
||||
assert!(workers.include_stopped);
|
||||
assert_eq!(resume.state_dir, list.state_dir);
|
||||
assert_eq!(resume.worker_id, "019d1234-0000-7000-8000-000000000000");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::Socket as SocketContract;
|
||||
|
||||
const CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
pub struct Socket {
|
||||
outgoing: mpsc::Sender<String>,
|
||||
incoming: mpsc::Receiver<String>,
|
||||
}
|
||||
|
||||
/// Host-side endpoint paired with an in-process client transport.
|
||||
pub struct Peer {
|
||||
incoming: mpsc::Receiver<String>,
|
||||
outgoing: mpsc::Sender<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum SocketError {
|
||||
#[error("in-process Worker protocol transport closed")]
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl Socket {
|
||||
pub fn pair() -> (Self, Peer) {
|
||||
let (client_tx, peer_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
let (peer_tx, client_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
(
|
||||
Self {
|
||||
outgoing: client_tx,
|
||||
incoming: client_rx,
|
||||
},
|
||||
Peer {
|
||||
incoming: peer_rx,
|
||||
outgoing: peer_tx,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocketContract for Socket {
|
||||
type Error = SocketError;
|
||||
|
||||
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
||||
self.outgoing
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|_| SocketError::Closed)
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
Ok(self.incoming.recv().await)
|
||||
}
|
||||
|
||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
match self.incoming.try_recv() {
|
||||
Ok(message) => Ok(Some(message)),
|
||||
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
pub async fn next(&mut self) -> Option<String> {
|
||||
self.incoming.recv().await
|
||||
}
|
||||
|
||||
pub async fn send(&self, message: String) -> Result<(), String> {
|
||||
self.outgoing.send(message).await.map_err(|error| error.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use protocol::stream::{decode_method, encode_event};
|
||||
use protocol::{Event, Method, WorkerStatus};
|
||||
|
||||
use super::Socket;
|
||||
use crate::Client;
|
||||
|
||||
#[tokio::test]
|
||||
async fn pair_carries_typed_protocol_through_generic_client() {
|
||||
let (socket, mut peer) = Socket::pair();
|
||||
let mut client = Client::new(socket);
|
||||
|
||||
client
|
||||
.send(&Method::run_text("hello"))
|
||||
.await
|
||||
.expect("send method");
|
||||
assert!(matches!(
|
||||
peer.next().await.as_deref().map(decode_method),
|
||||
Some(Ok(Method::Run { .. }))
|
||||
));
|
||||
|
||||
peer.send(
|
||||
encode_event(&Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
})
|
||||
.expect("encode event"),
|
||||
)
|
||||
.await
|
||||
.expect("send event");
|
||||
assert!(matches!(
|
||||
client.next_event().await,
|
||||
Ok(Some(Event::Status {
|
||||
status: WorkerStatus::Idle
|
||||
}))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use std::error::Error;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub mod in_process;
|
||||
pub mod unix_socket;
|
||||
pub mod websocket;
|
||||
|
||||
/// Message-oriented transport for one Worker protocol connection.
|
||||
///
|
||||
/// Implementations own physical framing. `client::Client` owns the typed
|
||||
/// Method/Event protocol encoding layered on top of these UTF-8 messages.
|
||||
#[async_trait]
|
||||
pub trait Socket {
|
||||
type Error: Error + Send + Sync + 'static;
|
||||
|
||||
async fn send(&mut self, message: String) -> Result<(), Self::Error>;
|
||||
|
||||
async fn next(&mut self) -> Result<Option<String>, Self::Error>;
|
||||
|
||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error>;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use super::Socket as SocketContract;
|
||||
|
||||
pub struct Socket {
|
||||
writer: tokio::io::WriteHalf<UnixStream>,
|
||||
messages: mpsc::Receiver<io::Result<String>>,
|
||||
reader_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Socket {
|
||||
pub async fn connect(path: &Path) -> io::Result<Self> {
|
||||
let stream = UnixStream::connect(path).await?;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
let (message_tx, messages) = mpsc::channel(256);
|
||||
let reader_task = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(message)) if message.trim().is_empty() => {}
|
||||
Ok(Some(message)) => {
|
||||
if message_tx.send(Ok(message)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => return,
|
||||
Err(error) => {
|
||||
let _ = message_tx.send(Err(error)).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
writer,
|
||||
messages,
|
||||
reader_task,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocketContract for Socket {
|
||||
type Error = io::Error;
|
||||
|
||||
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
||||
self.writer.write_all(message.as_bytes()).await?;
|
||||
self.writer.write_all(b"\n").await?;
|
||||
self.writer.flush().await
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
match self.messages.recv().await {
|
||||
Some(message) => message.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
match self.messages.try_recv() {
|
||||
Ok(message) => message.map(Some),
|
||||
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Socket {
|
||||
fn drop(&mut self) {
|
||||
self.reader_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::ErrorKind;
|
||||
use std::time::Duration;
|
||||
|
||||
use protocol::stream::{decode_method, encode_event};
|
||||
use protocol::{Event, Method, WorkerStatus};
|
||||
use tempfile::tempdir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
use super::*;
|
||||
use crate::Client;
|
||||
|
||||
async fn assert_peer_closed(stream: &mut UnixStream, reason: &str) {
|
||||
let mut buf = [0_u8; 1];
|
||||
match tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf))
|
||||
.await
|
||||
.expect(reason)
|
||||
{
|
||||
Ok(0) => {}
|
||||
Err(error) if error.kind() == ErrorKind::ConnectionReset => {}
|
||||
Ok(n) => panic!("server should observe peer close, read {n} byte(s)"),
|
||||
Err(error) => panic!("server read failed unexpectedly: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_receives_events_over_unix_socket() {
|
||||
let socket_dir = tempdir().unwrap();
|
||||
let socket_path = socket_dir.path().join("events.sock");
|
||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let event = encode_event(&Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
})
|
||||
.unwrap();
|
||||
stream.write_all(event.as_bytes()).await.unwrap();
|
||||
stream.write_all(b"\n").await.unwrap();
|
||||
});
|
||||
|
||||
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
|
||||
.await
|
||||
.expect("client should receive event while alive")
|
||||
.expect("transport should succeed");
|
||||
assert!(matches!(
|
||||
event,
|
||||
Some(Event::Status {
|
||||
status: WorkerStatus::Idle
|
||||
})
|
||||
));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_sends_methods_over_unix_socket() {
|
||||
let socket_dir = tempdir().unwrap();
|
||||
let socket_path = socket_dir.path().join("send.sock");
|
||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (reader, _) = listener.accept().await.unwrap();
|
||||
BufReader::new(reader).lines().next_line().await.unwrap()
|
||||
});
|
||||
|
||||
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
|
||||
client
|
||||
.send(&Method::run_text("hello"))
|
||||
.await
|
||||
.expect("send method");
|
||||
|
||||
let received = server.await.unwrap().expect("method message");
|
||||
assert!(matches!(decode_method(&received), Ok(Method::Run { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_socket_closes_server_connection() {
|
||||
let socket_dir = tempdir().unwrap();
|
||||
let socket_path = socket_dir.path().join("drop.sock");
|
||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
assert_peer_closed(&mut stream, "dropped socket should close promptly").await;
|
||||
});
|
||||
|
||||
let socket = Socket::connect(&socket_path).await.unwrap();
|
||||
drop(socket);
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use async_trait::async_trait;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use thiserror::Error;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
use tokio_tungstenite::tungstenite::{self, Message};
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||
|
||||
use super::Socket as SocketContract;
|
||||
|
||||
type Writer = futures::stream::SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
|
||||
|
||||
pub struct Socket {
|
||||
writer: Writer,
|
||||
messages: mpsc::Receiver<Result<String, SocketError>>,
|
||||
reader_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SocketError {
|
||||
#[error("WebSocket transport failed: {0}")]
|
||||
WebSocket(#[from] tungstenite::Error),
|
||||
}
|
||||
|
||||
impl Socket {
|
||||
pub async fn connect(request: Request<()>) -> Result<Self, SocketError> {
|
||||
let (stream, _) = connect_async(request).await?;
|
||||
let (writer, mut reader) = stream.split();
|
||||
let (message_tx, messages) = mpsc::channel(256);
|
||||
let reader_task = tokio::spawn(async move {
|
||||
loop {
|
||||
match reader.next().await {
|
||||
Some(Ok(Message::Text(message))) => {
|
||||
if message_tx.send(Ok(message.to_string())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => return,
|
||||
Some(Ok(
|
||||
Message::Binary(_)
|
||||
| Message::Ping(_)
|
||||
| Message::Pong(_)
|
||||
| Message::Frame(_),
|
||||
)) => {}
|
||||
Some(Err(error)) => {
|
||||
let _ = message_tx.send(Err(SocketError::WebSocket(error))).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
writer,
|
||||
messages,
|
||||
reader_task,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocketContract for Socket {
|
||||
type Error = SocketError;
|
||||
|
||||
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
||||
self.writer.send(Message::Text(message.into())).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
match self.messages.recv().await {
|
||||
Some(message) => message.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
match self.messages.try_recv() {
|
||||
Ok(message) => message.map(Some),
|
||||
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Socket {
|
||||
fn drop(&mut self) {
|
||||
self.reader_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use protocol::stream::{decode_method, encode_event};
|
||||
use protocol::{Event, Method, WorkerStatus};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::accept_async;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
|
||||
use super::*;
|
||||
use crate::Client;
|
||||
|
||||
#[tokio::test]
|
||||
async fn carries_typed_protocol_through_generic_client() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut socket = accept_async(stream).await.unwrap();
|
||||
let message = socket.next().await.unwrap().unwrap();
|
||||
assert!(matches!(
|
||||
message,
|
||||
Message::Text(ref text)
|
||||
if matches!(decode_method(text), Ok(Method::Run { .. }))
|
||||
));
|
||||
let event = encode_event(&Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
})
|
||||
.unwrap();
|
||||
socket.send(Message::Text(event.into())).await.unwrap();
|
||||
});
|
||||
|
||||
let request = format!("ws://{address}").into_client_request().unwrap();
|
||||
let mut client = Client::new(Socket::connect(request).await.unwrap());
|
||||
client
|
||||
.send(&Method::run_text("hello"))
|
||||
.await
|
||||
.expect("send method");
|
||||
assert!(matches!(
|
||||
client.next_event().await,
|
||||
Ok(Some(Event::Status {
|
||||
status: WorkerStatus::Idle
|
||||
}))
|
||||
));
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||
use protocol::{Event, Method};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub struct WorkerClient {
|
||||
writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>,
|
||||
event_rx: mpsc::Receiver<Event>,
|
||||
reader_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl WorkerClient {
|
||||
pub async fn connect(path: &Path) -> Result<Self, io::Error> {
|
||||
let stream = UnixStream::connect(path).await?;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
let writer = JsonLineWriter::new(writer);
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel::<Event>(256);
|
||||
|
||||
let reader_task = tokio::spawn(async move {
|
||||
let mut reader = JsonLineReader::new(reader);
|
||||
while let Ok(Some(event)) = reader.next::<Event>().await {
|
||||
if event_tx.send(event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
writer,
|
||||
event_rx,
|
||||
reader_task,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, method: &Method) -> Result<(), io::Error> {
|
||||
self.writer.write(method).await
|
||||
}
|
||||
|
||||
pub fn try_next_event(&mut self) -> Option<Event> {
|
||||
self.event_rx.try_recv().ok()
|
||||
}
|
||||
|
||||
pub async fn next_event(&mut self) -> Option<Event> {
|
||||
self.event_rx.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WorkerClient {
|
||||
fn drop(&mut self) {
|
||||
self.reader_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::ErrorKind;
|
||||
use std::time::Duration;
|
||||
|
||||
use protocol::{Segment, WorkerStatus};
|
||||
use tempfile::tempdir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn assert_peer_closed(stream: &mut UnixStream, reason: &str) {
|
||||
let mut buf = [0_u8; 1];
|
||||
match tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf))
|
||||
.await
|
||||
.expect(reason)
|
||||
{
|
||||
Ok(0) => {}
|
||||
Err(error) if error.kind() == ErrorKind::ConnectionReset => {}
|
||||
Ok(n) => panic!("server should observe peer close, read {n} byte(s)"),
|
||||
Err(error) => panic!("server read failed unexpectedly: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn receives_events_while_client_is_alive() {
|
||||
let socket_dir = tempdir().unwrap();
|
||||
let socket_path = socket_dir.path().join("events.sock");
|
||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut writer = JsonLineWriter::new(stream);
|
||||
writer
|
||||
.write(&Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
|
||||
.await
|
||||
.expect("client should receive event while alive");
|
||||
assert!(matches!(
|
||||
event,
|
||||
Some(Event::Status {
|
||||
status: WorkerStatus::Idle
|
||||
})
|
||||
));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_writes_methods_while_client_is_alive() {
|
||||
let socket_dir = tempdir().unwrap();
|
||||
let socket_path = socket_dir.path().join("send.sock");
|
||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut reader = JsonLineReader::new(stream);
|
||||
reader.next::<Method>().await.unwrap()
|
||||
});
|
||||
|
||||
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||
let method = Method::Run {
|
||||
input: vec![Segment::text("hello")],
|
||||
};
|
||||
client.send(&method).await.unwrap();
|
||||
|
||||
let received = tokio::time::timeout(Duration::from_secs(1), server)
|
||||
.await
|
||||
.expect("server should receive method while client is alive")
|
||||
.unwrap();
|
||||
match received {
|
||||
Some(Method::Run { input }) => assert_eq!(input, vec![Segment::text("hello")]),
|
||||
other => panic!("expected Run method, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_repeated_clients_closes_server_connections() {
|
||||
let socket_dir = tempdir().unwrap();
|
||||
let socket_path = socket_dir.path().join("drop.sock");
|
||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
for _ in 0..16 {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
assert_peer_closed(
|
||||
&mut stream,
|
||||
"dropped client should close its socket promptly",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
for _ in 0..16 {
|
||||
let client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||
drop(client);
|
||||
}
|
||||
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_client_aborts_blocked_reader_task() {
|
||||
let socket_dir = tempdir().unwrap();
|
||||
let socket_path = socket_dir.path().join("blocked-reader.sock");
|
||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
stream.write_all(b"{\"event\"").await.unwrap();
|
||||
assert_peer_closed(
|
||||
&mut stream,
|
||||
"aborting the blocked client reader should close the socket",
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
drop(client);
|
||||
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use workspace_api::{
|
||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
};
|
||||
|
||||
use crate::BackendWorkspaceClientError;
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
|
||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||
|
||||
@@ -26,7 +26,7 @@ struct BackendWorkerLaunchOptions {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BackendWorkerLaunchRuntime {
|
||||
runtime_id: String,
|
||||
can_spawn_worker: bool,
|
||||
worker_creation_available: bool,
|
||||
working_directory_required: bool,
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ struct BackendWorkspaceOrchestratorResponse {
|
||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
||||
/// Callers should derive these once from `Target::resolve()` and must not retry
|
||||
/// failed requests against repository-local state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackendWorkspaceProductClient {
|
||||
base_url: String,
|
||||
api: BackendApiClient,
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
@@ -58,22 +58,32 @@ impl BackendWorkspaceProductClient {
|
||||
base_url: impl Into<String>,
|
||||
workspace_id: impl Into<String>,
|
||||
) -> Result<Self, BackendWorkspaceClientError> {
|
||||
let base_url = base_url.into().trim_end_matches('/').to_string();
|
||||
if base_url.is_empty() {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend base URL must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let base_url = base_url.into();
|
||||
let api = BackendApiClient::from_stored_token(&base_url)?;
|
||||
let workspace_id = workspace_id.into();
|
||||
if workspace_id.trim().is_empty() {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend Workspace identity must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
base_url,
|
||||
workspace_id,
|
||||
})
|
||||
Ok(Self { api, workspace_id })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_with_access_token(
|
||||
base_url: impl Into<String>,
|
||||
workspace_id: impl Into<String>,
|
||||
access_token: &str,
|
||||
) -> Result<Self, BackendWorkspaceClientError> {
|
||||
let base_url = base_url.into();
|
||||
let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?;
|
||||
let workspace_id = workspace_id.into();
|
||||
if workspace_id.trim().is_empty() {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend Workspace identity must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self { api, workspace_id })
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
@@ -261,7 +271,7 @@ impl BackendWorkspaceProductClient {
|
||||
let runtime = options
|
||||
.runtimes
|
||||
.iter()
|
||||
.find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required)
|
||||
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
||||
.ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
||||
@@ -316,7 +326,7 @@ impl BackendWorkspaceProductClient {
|
||||
body: Option<&B>,
|
||||
) -> Result<R, BackendWorkspaceClientError> {
|
||||
let response = self.request(method, path, body)?.send()?;
|
||||
let response = ensure_success(response)?;
|
||||
self.api.check_status(response.status())?;
|
||||
response.json().map_err(BackendWorkspaceClientError::Http)
|
||||
}
|
||||
|
||||
@@ -326,7 +336,8 @@ impl BackendWorkspaceProductClient {
|
||||
path: &str,
|
||||
body: Option<&B>,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
ensure_success(self.request(method, path, body)?.send()?)?;
|
||||
let response = self.request(method, path, body)?.send()?;
|
||||
self.api.check_status(response.status())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -336,14 +347,12 @@ impl BackendWorkspaceProductClient {
|
||||
path: &str,
|
||||
body: Option<&B>,
|
||||
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
||||
let client = reqwest::blocking::Client::builder().build()?;
|
||||
let url = format!(
|
||||
"{}/api/w/{}/{}",
|
||||
self.base_url,
|
||||
let path = format!(
|
||||
"/api/w/{}/{}",
|
||||
encode_path_segment(&self.workspace_id),
|
||||
path.trim_start_matches('/')
|
||||
);
|
||||
let request = client.request(method, url);
|
||||
let request = self.api.blocking_request(method, &path)?;
|
||||
Ok(match body {
|
||||
Some(body) => request.json(body),
|
||||
None => request,
|
||||
@@ -473,8 +482,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, _queued_by: &str) -> ticket::Result<()> {
|
||||
self.send_unit::<()>(
|
||||
fn queue_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
_queued_by: &str,
|
||||
) -> ticket::Result<ticket::TicketQueueOutcome> {
|
||||
self.send_json::<(), _>(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/workflow/queue",
|
||||
@@ -584,19 +597,6 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
|
||||
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
||||
}
|
||||
|
||||
fn ensure_success(
|
||||
response: reqwest::blocking::Response,
|
||||
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let message = response
|
||||
.text()
|
||||
.unwrap_or_else(|_| "Backend request failed".to_string());
|
||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
||||
}
|
||||
|
||||
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
||||
match id {
|
||||
TicketIdOrSlug::Id(id) => id.to_string(),
|
||||
@@ -694,24 +694,32 @@ mod tests {
|
||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let response = client.list_objectives(1_000).unwrap();
|
||||
|
||||
assert!(response.items.is_empty());
|
||||
assert!(
|
||||
request
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
|
||||
);
|
||||
let request = request.recv().unwrap();
|
||||
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
||||
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
||||
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied");
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
let (base_url, request, handle) =
|
||||
one_response_server("403 Forbidden", "test-backend-token");
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = client
|
||||
.create_objective(&ObjectiveCreateRequest {
|
||||
@@ -723,6 +731,7 @@ mod tests {
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("403"));
|
||||
assert!(!error.to_string().contains("test-backend-token"));
|
||||
assert!(
|
||||
request
|
||||
.recv()
|
||||
@@ -735,7 +744,12 @@ mod tests {
|
||||
#[test]
|
||||
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let relations = client
|
||||
.query_ticket_relations(
|
||||
@@ -754,7 +768,12 @@ mod tests {
|
||||
#[test]
|
||||
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
||||
|
||||
@@ -773,14 +792,19 @@ mod tests {
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
(
|
||||
"200 OK",
|
||||
r#"{"runtimes":[{"runtime_id":"embedded","can_spawn_worker":true,"working_directory_required":false}]}"#,
|
||||
r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#,
|
||||
),
|
||||
(
|
||||
"200 OK",
|
||||
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
||||
),
|
||||
]);
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let status = client.launch_ticket_intake("T-1").unwrap();
|
||||
|
||||
@@ -802,7 +826,12 @@ mod tests {
|
||||
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
||||
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let status = client.start_workspace_orchestrator().unwrap();
|
||||
|
||||
@@ -818,7 +847,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn product_client_requires_workspace_identity() {
|
||||
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err();
|
||||
let error = BackendWorkspaceProductClient::new_with_access_token(
|
||||
"http://127.0.0.1:8787",
|
||||
"",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("Workspace identity"));
|
||||
}
|
||||
|
||||
|
||||
@@ -101,9 +101,11 @@ pub fn complete_current(
|
||||
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
||||
let result = session_environment(snapshot.clone())
|
||||
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?
|
||||
.map(|result| WasmCompletionResult {
|
||||
from: result.from,
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
|
||||
let result = result
|
||||
.map(|result| {
|
||||
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
|
||||
from: utf8_to_utf16_offset(&source, result.from)?,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
@@ -114,7 +116,9 @@ pub fn complete_current(
|
||||
priority: item.priority,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
encode(result)
|
||||
})
|
||||
}
|
||||
@@ -177,6 +181,16 @@ fn utf16_to_utf8_offset(source: &str, utf16_offset: usize) -> Result<usize, JsVa
|
||||
}
|
||||
}
|
||||
|
||||
fn utf8_to_utf16_offset(source: &str, utf8_offset: usize) -> Result<usize, JsValue> {
|
||||
if utf8_offset > source.len() {
|
||||
return Err(JsValue::from_str("UTF-8 offset is outside the source"));
|
||||
}
|
||||
if !source.is_char_boundary(utf8_offset) {
|
||||
return Err(JsValue::from_str("UTF-8 offset splits a character"));
|
||||
}
|
||||
Ok(source[..utf8_offset].encode_utf16().count())
|
||||
}
|
||||
|
||||
fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
|
||||
from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
|
||||
}
|
||||
|
||||
@@ -1203,6 +1203,9 @@ impl SnapshotEnvironment {
|
||||
{
|
||||
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
|
||||
member_source.push_str(&context.schema_path.join("."));
|
||||
if !context.schema_path.is_empty() && context.from == utf8_byte_offset {
|
||||
member_source.push('.');
|
||||
}
|
||||
let mut completion = LanguageService::new(self).complete(
|
||||
entrypoint.as_str(),
|
||||
&member_source,
|
||||
@@ -1961,6 +1964,31 @@ mod tests {
|
||||
.iter()
|
||||
.any(|item| item.label == "default_profile")
|
||||
);
|
||||
|
||||
let blank_nested_source = "{ profile = { } } as WorkspaceConfigSchema";
|
||||
let blank_nested_cursor = blank_nested_source.find("{ }").unwrap() + 2;
|
||||
let blank_nested = environment
|
||||
.complete_config(
|
||||
&path("main.dcdl"),
|
||||
blank_nested_source,
|
||||
blank_nested_cursor,
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(blank_nested.from, blank_nested_cursor);
|
||||
assert!(
|
||||
blank_nested
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.label == "default_profile")
|
||||
);
|
||||
assert!(
|
||||
!blank_nested
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.label == "profile")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
match slug {
|
||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 3,
|
||||
revision: 4,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}),
|
||||
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 3,
|
||||
revision: 4,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}];
|
||||
@@ -46,6 +46,30 @@ pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coder_review_flow_uses_current_selector_ref_review_contract() {
|
||||
let source = builtin_flow_source(CODER_REVIEW_FLOW_SLUG).expect("coder review Flow");
|
||||
for required in [
|
||||
"OpenMergeRequest",
|
||||
"ShowMergeRequest",
|
||||
"ReviewMergeRequest",
|
||||
"CompleteMergeRequest",
|
||||
"existing Merge Request `selector_from`",
|
||||
"Target-only movement does not invalidate",
|
||||
] {
|
||||
assert!(source.content.contains(required), "missing {required}");
|
||||
}
|
||||
for stale in [
|
||||
"MergeRequestOpen",
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReview",
|
||||
"MergeRequestComplete",
|
||||
"new immutable revision",
|
||||
] {
|
||||
assert!(!source.content.contains(stale), "stale contract {stale}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
||||
assert!(!builtin_flow_sources().is_empty());
|
||||
|
||||
@@ -157,10 +157,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn grep_request(path: &str, pattern: &str) -> GrepRequest {
|
||||
GrepRequest {
|
||||
pattern: pattern.to_string(),
|
||||
path: FsPath::new(path).unwrap(),
|
||||
glob: None,
|
||||
file_type: None,
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
multiline: false,
|
||||
output_mode: GrepOutputMode::Content,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
||||
assert!(FsPath::new("src/lib.rs").is_ok());
|
||||
assert!(FsPath::new("/tmp/file").is_err());
|
||||
assert!(FsPath::new_scoped("/tmp/file").is_ok());
|
||||
assert!(FsPath::new_scoped("/tmp/../secret").is_err());
|
||||
assert!(FsPath::new("../file").is_err());
|
||||
assert!(FsPath::new("src\\lib.rs").is_err());
|
||||
}
|
||||
@@ -279,4 +297,313 @@ mod tests {
|
||||
assert_eq!(grep.matched_files, 2);
|
||||
assert!(!grep.output.contains("c.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_accepts_a_direct_file_without_searching_siblings() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let selected = temp.path().join("selected.txt");
|
||||
std::fs::write(&selected, "before\nneedle selected\nafter\n").unwrap();
|
||||
std::fs::write(temp.path().join("sibling.txt"), "needle sibling\n").unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
|
||||
let mut request = grep_request("selected.txt", "needle");
|
||||
request.before_context = 1;
|
||||
request.after_context = 1;
|
||||
let direct = run_grep(&root, selected, request, &readable).unwrap();
|
||||
|
||||
assert_eq!(direct.match_count, 1);
|
||||
assert_eq!(direct.matched_files, 1);
|
||||
assert_eq!(
|
||||
direct.output,
|
||||
concat!(
|
||||
"selected.txt\n",
|
||||
" 1 │ before\n",
|
||||
" > 2 │ needle selected\n",
|
||||
" 3 │ after\n",
|
||||
)
|
||||
);
|
||||
assert!(!direct.output.contains("sibling"));
|
||||
|
||||
let directory = run_grep(
|
||||
&root,
|
||||
root.clone(),
|
||||
GrepRequest {
|
||||
pattern: "needle".to_string(),
|
||||
path: FsPath::root(),
|
||||
glob: None,
|
||||
file_type: None,
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
multiline: false,
|
||||
output_mode: GrepOutputMode::Content,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
&readable,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(directory.match_count, 2);
|
||||
assert_eq!(directory.matched_files, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_direct_file_applies_glob_and_type_filters_for_every_output_mode() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let nested = temp.path().join("nested");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
let selected = nested.join("selected.rs");
|
||||
std::fs::write(&selected, "needle one\nneedle two\n").unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
|
||||
for mode in [
|
||||
GrepOutputMode::Content,
|
||||
GrepOutputMode::FilesWithMatches,
|
||||
GrepOutputMode::Count,
|
||||
] {
|
||||
for (glob, file_type) in [(Some("other/*.rs"), None), (None, Some("python"))] {
|
||||
let mut request = grep_request("nested/selected.rs", "needle");
|
||||
request.output_mode = mode;
|
||||
request.glob = glob.map(str::to_string);
|
||||
request.file_type = file_type.map(str::to_string);
|
||||
|
||||
let excluded = run_grep(&root, selected.clone(), request, &readable).unwrap();
|
||||
assert_eq!(excluded.output, "", "mode {mode:?}");
|
||||
assert_eq!(excluded.match_count, 0, "mode {mode:?}");
|
||||
assert_eq!(excluded.matched_files, 0, "mode {mode:?}");
|
||||
assert!(!excluded.truncated, "mode {mode:?}");
|
||||
}
|
||||
|
||||
let mut request = grep_request("nested/selected.rs", "needle");
|
||||
request.output_mode = mode;
|
||||
request.glob = Some("nested/*.rs".to_string());
|
||||
request.file_type = Some("rust".to_string());
|
||||
let matched = run_grep(&root, selected.clone(), request, &readable).unwrap();
|
||||
|
||||
match mode {
|
||||
GrepOutputMode::Content => {
|
||||
assert_eq!(matched.match_count, 2);
|
||||
assert_eq!(matched.matched_files, 1);
|
||||
assert!(matched.output.starts_with("nested/selected.rs\n"));
|
||||
assert!(matched.output.contains("> 1 │ needle one"));
|
||||
assert!(matched.output.contains("> 2 │ needle two"));
|
||||
}
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
assert_eq!(matched.match_count, 1);
|
||||
assert_eq!(matched.matched_files, 1);
|
||||
assert_eq!(matched.output, "nested/selected.rs\n");
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
assert_eq!(matched.match_count, 2);
|
||||
assert_eq!(matched.matched_files, 1);
|
||||
assert_eq!(matched.output, "nested/selected.rs:2\n");
|
||||
}
|
||||
}
|
||||
assert!(!matched.truncated, "mode {mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_direct_file_preserves_explicit_hidden_and_gitignored_behavior() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let hidden = temp.path().join(".hidden.rs");
|
||||
let ignored = temp.path().join("ignored.rs");
|
||||
std::fs::write(&hidden, "needle hidden\n").unwrap();
|
||||
std::fs::write(&ignored, "needle ignored\n").unwrap();
|
||||
std::fs::write(temp.path().join(".gitignore"), "ignored.rs\n").unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
|
||||
for (path, expected) in [
|
||||
(".hidden.rs", "needle hidden"),
|
||||
("ignored.rs", "needle ignored"),
|
||||
] {
|
||||
let result = run_grep(
|
||||
&root,
|
||||
root.join(path),
|
||||
grep_request(path, "needle"),
|
||||
&readable,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.match_count, 1, "path {path}");
|
||||
assert!(result.output.contains(expected), "path {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_direct_file_preserves_case_multiline_and_bounds() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let selected = temp.path().join("selected.txt");
|
||||
std::fs::write(&selected, "NEEDLE first\nstart\nfinish\nneedle last\n").unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
|
||||
let mut case_request = grep_request("selected.txt", "needle");
|
||||
case_request.case_insensitive = true;
|
||||
case_request.offset = 1;
|
||||
case_request.limit = 1;
|
||||
let bounded = run_grep(&root, selected.clone(), case_request, &readable).unwrap();
|
||||
assert_eq!(bounded.match_count, 1);
|
||||
assert!(!bounded.output.contains("NEEDLE first"));
|
||||
assert!(bounded.output.contains("needle last"));
|
||||
assert!(bounded.truncated);
|
||||
|
||||
let mut multiline_request = grep_request("selected.txt", "start\\nfinish");
|
||||
multiline_request.multiline = true;
|
||||
let multiline = run_grep(&root, selected, multiline_request, &readable).unwrap();
|
||||
assert_eq!(multiline.match_count, 1);
|
||||
assert!(multiline.output.contains("start\nfinish"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_returns_not_found_for_a_missing_direct_path() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let missing = root.join("missing.txt");
|
||||
let readable = RootAccess(root.clone());
|
||||
|
||||
let error = run_grep(
|
||||
&root,
|
||||
missing.clone(),
|
||||
grep_request("missing.txt", "needle"),
|
||||
&readable,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, FsError::NotFound(path) if path == missing));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
std::fs::create_dir(root.join("target-dir")).unwrap();
|
||||
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
|
||||
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
|
||||
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
|
||||
symlink(root.join("missing-target"), root.join("broken-link")).unwrap();
|
||||
|
||||
let request = |path: &str| grep_request(path, "needle");
|
||||
|
||||
let file_result = run_grep(
|
||||
&root,
|
||||
root.join("file-link.rs"),
|
||||
request("file-link.rs"),
|
||||
&readable,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(file_result.match_count, 1);
|
||||
assert!(file_result.output.starts_with("file-link.rs\n"));
|
||||
|
||||
let directory_error = run_grep(
|
||||
&root,
|
||||
root.join("directory-link"),
|
||||
request("directory-link"),
|
||||
&readable,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
directory_error,
|
||||
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. }
|
||||
if path == root.join("directory-link")
|
||||
));
|
||||
|
||||
let broken_error = run_grep(
|
||||
&root,
|
||||
root.join("broken-link"),
|
||||
request("broken-link"),
|
||||
&readable,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
broken_error,
|
||||
FsError::BrokenSymlink { path, .. } if path == root.join("broken-link")
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn grep_rejects_a_direct_special_file_as_invalid_argument() {
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let socket = temp.path().join("grep.sock");
|
||||
let _listener = UnixListener::bind(&socket).unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
|
||||
let error = run_grep(
|
||||
&root,
|
||||
socket,
|
||||
grep_request("grep.sock", "needle"),
|
||||
&readable,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
FsError::InvalidArgument(message)
|
||||
if message.contains("must be a regular file or directory")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_content_groups_lines_by_file_and_marks_matches() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
temp.path().join("first.txt"),
|
||||
"before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
|
||||
let grep = run_grep(
|
||||
&root,
|
||||
root.clone(),
|
||||
GrepRequest {
|
||||
pattern: "needle".to_string(),
|
||||
path: FsPath::root(),
|
||||
glob: Some("*.txt".to_string()),
|
||||
output_mode: GrepOutputMode::Content,
|
||||
case_insensitive: false,
|
||||
before_context: 1,
|
||||
after_context: 1,
|
||||
multiline: false,
|
||||
file_type: None,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
},
|
||||
&readable,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(grep.match_count, 3);
|
||||
assert_eq!(grep.matched_files, 2);
|
||||
assert_eq!(
|
||||
grep.output,
|
||||
concat!(
|
||||
"first.txt\n",
|
||||
" 1 │ before\n",
|
||||
" > 2 │ needle one\n",
|
||||
" 3 │ after\n",
|
||||
" …\n",
|
||||
" 6 │ before distant\n",
|
||||
" > 7 │ needle distant\n",
|
||||
" 8 │ after distant\n",
|
||||
"\n",
|
||||
"second.txt\n",
|
||||
" > 1 │ needle two\n",
|
||||
)
|
||||
);
|
||||
assert_eq!(grep.output.matches("first.txt").count(), 1);
|
||||
assert_eq!(grep.output.matches("second.txt").count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::FsError;
|
||||
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
/// Scope-checked filesystem path. Relative paths resolve below the bound
|
||||
/// Workdir root; absolute paths require an explicit matching scope rule.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct FsPath(String);
|
||||
@@ -16,11 +17,30 @@ impl<'de> Deserialize<'de> for FsPath {
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Self::new(&value).map_err(serde::de::Error::custom)
|
||||
Self::new_scoped(&value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl FsPath {
|
||||
/// Construct a path for a scope-checked operation that may target an
|
||||
/// explicitly granted absolute path outside the provider root.
|
||||
pub fn new_scoped(value: impl Into<String>) -> Result<Self, FsError> {
|
||||
let value = value.into();
|
||||
if !Path::new(&value).is_absolute() {
|
||||
return Self::new(value);
|
||||
}
|
||||
if value.contains('\\') {
|
||||
return Err(FsError::InvalidPath(value));
|
||||
}
|
||||
if Path::new(&value)
|
||||
.components()
|
||||
.any(|component| component == Component::ParentDir)
|
||||
{
|
||||
return Err(FsError::InvalidPath(value));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn root() -> Self {
|
||||
Self(String::new())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::FsAccessPolicy;
|
||||
@@ -5,8 +7,8 @@ use grep_regex::RegexMatcherBuilder;
|
||||
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
||||
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
||||
use ignore::WalkBuilder;
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
use ignore::types::TypesBuilder;
|
||||
use ignore::overrides::{Override, OverrideBuilder};
|
||||
use ignore::types::{Types, TypesBuilder};
|
||||
|
||||
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
|
||||
|
||||
@@ -57,20 +59,11 @@ impl GrepReport {
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
for line in &self.lines {
|
||||
let separator = if line.is_match { ':' } else { '-' };
|
||||
let path = logical_display(root, &line.path);
|
||||
if self.show_line_numbers
|
||||
&& let Some(number) = line.line_number
|
||||
{
|
||||
output.push_str(&format!(
|
||||
"{path}{separator}{number}{separator}{}\n",
|
||||
line.text
|
||||
output.push_str(&render_content_lines(
|
||||
root,
|
||||
&self.lines,
|
||||
self.show_line_numbers,
|
||||
));
|
||||
} else {
|
||||
output.push_str(&format!("{path}{separator}{}\n", line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GrepResult {
|
||||
@@ -82,6 +75,48 @@ impl GrepReport {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String {
|
||||
let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new();
|
||||
for line in lines {
|
||||
grouped.entry(&line.path).or_default().push(line);
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() {
|
||||
if file_index > 0 {
|
||||
output.push('\n');
|
||||
}
|
||||
let _ = writeln!(output, "{}", logical_display(root, path));
|
||||
|
||||
let number_width = file_lines
|
||||
.iter()
|
||||
.filter_map(|line| line.line_number)
|
||||
.map(|number| number.to_string().len())
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
let mut previous_line_end = None;
|
||||
for line in file_lines {
|
||||
if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number)
|
||||
&& number > previous_end
|
||||
{
|
||||
let _ = writeln!(output, " …");
|
||||
}
|
||||
|
||||
let marker = if line.is_match { '>' } else { ' ' };
|
||||
if show_line_numbers && let Some(number) = line.line_number {
|
||||
let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text);
|
||||
} else {
|
||||
let _ = writeln!(output, " {marker} │ {}", line.text);
|
||||
}
|
||||
previous_line_end = line
|
||||
.line_number
|
||||
.map(|number| number + line.text.split('\n').count() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn logical_display(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
@@ -91,6 +126,38 @@ fn logical_display(root: &Path, path: &Path) -> String {
|
||||
|
||||
const DEFAULT_HEAD_LIMIT: usize = 250;
|
||||
|
||||
fn build_overrides(base: &Path, glob: Option<&str>) -> Result<Option<Override>, FsError> {
|
||||
let Some(glob) = glob else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut builder = OverrideBuilder::new(base);
|
||||
builder
|
||||
.add(glob)
|
||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))?;
|
||||
builder
|
||||
.build()
|
||||
.map(Some)
|
||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))
|
||||
}
|
||||
|
||||
fn build_types(file_type: Option<&str>) -> Result<Option<Types>, FsError> {
|
||||
let Some(file_type) = file_type else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut builder = TypesBuilder::new();
|
||||
builder.add_defaults();
|
||||
builder.select(file_type);
|
||||
builder
|
||||
.build()
|
||||
.map(Some)
|
||||
.map_err(|error| FsError::InvalidArgument(format!("invalid type {file_type}: {error}")))
|
||||
}
|
||||
|
||||
fn direct_file_selected(path: &Path, overrides: Option<&Override>, types: Option<&Types>) -> bool {
|
||||
!overrides.is_some_and(|filter| filter.matched(path, false).is_ignore())
|
||||
&& !types.is_some_and(|filter| filter.matched(path, false).is_ignore())
|
||||
}
|
||||
|
||||
struct GrepParams {
|
||||
pattern: String,
|
||||
path: Option<PathBuf>,
|
||||
@@ -186,13 +253,15 @@ pub fn run_grep(
|
||||
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
||||
_ => FsError::io(&base, e),
|
||||
})?;
|
||||
if !base_meta.is_dir() {
|
||||
if !base_meta.is_file() && !base_meta.is_dir() {
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"grep search path is not a directory: {}",
|
||||
"grep search path must be a regular file or directory: {}",
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if base_meta.is_dir()
|
||||
&& let Some(info) = symlink.as_ref()
|
||||
{
|
||||
return Err(FsError::SymlinkDirectoryNotTraversed {
|
||||
tool: "Grep",
|
||||
path: base.clone(),
|
||||
@@ -200,32 +269,9 @@ pub fn run_grep(
|
||||
});
|
||||
}
|
||||
|
||||
let mut wb = WalkBuilder::new(&base);
|
||||
wb.hidden(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.ignore(true)
|
||||
.parents(true)
|
||||
.follow_links(false);
|
||||
|
||||
if let Some(t) = p.file_type.as_deref() {
|
||||
let mut tb = TypesBuilder::new();
|
||||
tb.add_defaults();
|
||||
tb.select(t);
|
||||
let types = tb
|
||||
.build()
|
||||
.map_err(|e| FsError::InvalidArgument(format!("invalid type {t}: {e}")))?;
|
||||
wb.types(types);
|
||||
}
|
||||
if let Some(g) = p.glob.as_deref() {
|
||||
let mut ob = OverrideBuilder::new(&base);
|
||||
ob.add(g).map_err(|e| FsError::InvalidGlob(e.to_string()))?;
|
||||
let ov = ob
|
||||
.build()
|
||||
.map_err(|e| FsError::InvalidGlob(e.to_string()))?;
|
||||
wb.overrides(ov);
|
||||
}
|
||||
let filter_base = if base_meta.is_file() { root } else { &base };
|
||||
let types = build_types(p.file_type.as_deref())?;
|
||||
let overrides = build_overrides(filter_base, p.glob.as_deref())?;
|
||||
|
||||
let mode = p.output_mode.unwrap_or_default();
|
||||
let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
@@ -240,74 +286,133 @@ pub fn run_grep(
|
||||
lines: Vec::new(),
|
||||
truncated: false,
|
||||
};
|
||||
let mut matching_files_seen = 0;
|
||||
let mut matches_seen = 0;
|
||||
|
||||
// Per-mode walker state.
|
||||
let mut matching_files_seen: usize = 0;
|
||||
let mut matches_seen: usize = 0;
|
||||
if base_meta.is_file() {
|
||||
if direct_file_selected(&base, overrides.as_ref(), types.as_ref()) {
|
||||
scan_path(
|
||||
&mut searcher,
|
||||
&matcher,
|
||||
&base,
|
||||
mode,
|
||||
&mut report,
|
||||
&mut matching_files_seen,
|
||||
&mut matches_seen,
|
||||
offset,
|
||||
head_limit,
|
||||
)?;
|
||||
}
|
||||
return Ok(report.into_result(root));
|
||||
}
|
||||
|
||||
'walker: for entry in wb.build().flatten() {
|
||||
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
let mut walker = WalkBuilder::new(&base);
|
||||
walker
|
||||
.hidden(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.ignore(true)
|
||||
.parents(true)
|
||||
.follow_links(false);
|
||||
if let Some(types) = types {
|
||||
walker.types(types);
|
||||
}
|
||||
if let Some(overrides) = overrides {
|
||||
walker.overrides(overrides);
|
||||
}
|
||||
|
||||
for entry in walker.build().flatten() {
|
||||
if !entry
|
||||
.file_type()
|
||||
.map(|kind| kind.is_file())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !access.is_readable(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
let hit = scan_any_match(&mut searcher, &matcher, path)?;
|
||||
if !hit {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.files.push(path.to_path_buf());
|
||||
if report.files.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
let count = scan_count(&mut searcher, &matcher, path)?;
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.counts.push((path.to_path_buf(), count));
|
||||
if report.counts.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
let before_count = matches_seen;
|
||||
let mut sink = ContentSink {
|
||||
path: path.to_path_buf(),
|
||||
lines: &mut report.lines,
|
||||
matches_seen: &mut matches_seen,
|
||||
if scan_path(
|
||||
&mut searcher,
|
||||
&matcher,
|
||||
path,
|
||||
mode,
|
||||
&mut report,
|
||||
&mut matching_files_seen,
|
||||
&mut matches_seen,
|
||||
offset,
|
||||
head_limit,
|
||||
};
|
||||
searcher
|
||||
.search_path(&matcher, path, &mut sink)
|
||||
.map_err(|e| FsError::io(path, e))?;
|
||||
// If we hit head_limit during this file, stop walking.
|
||||
if matches_seen >= offset.saturating_add(head_limit) && matches_seen > before_count
|
||||
{
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report.into_result(root))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scan_path(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
mode: GrepOutputMode,
|
||||
report: &mut GrepReport,
|
||||
matching_files_seen: &mut usize,
|
||||
matches_seen: &mut usize,
|
||||
offset: usize,
|
||||
head_limit: usize,
|
||||
) -> Result<bool, FsError> {
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
if !scan_any_match(searcher, matcher, path)? {
|
||||
return Ok(false);
|
||||
}
|
||||
if *matching_files_seen >= offset {
|
||||
report.files.push(path.to_path_buf());
|
||||
if report.files.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
*matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
let count = scan_count(searcher, matcher, path)?;
|
||||
if count == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if *matching_files_seen >= offset {
|
||||
report.counts.push((path.to_path_buf(), count));
|
||||
if report.counts.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
*matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
let before_count = *matches_seen;
|
||||
let mut sink = ContentSink {
|
||||
path: path.to_path_buf(),
|
||||
lines: &mut report.lines,
|
||||
matches_seen,
|
||||
offset,
|
||||
head_limit,
|
||||
};
|
||||
searcher
|
||||
.search_path(matcher, path, &mut sink)
|
||||
.map_err(|error| FsError::io(path, error))?;
|
||||
if *matches_seen >= offset.saturating_add(head_limit) && *matches_seen > before_count {
|
||||
report.truncated = true;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn scan_any_match(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
arc-swap = "1"
|
||||
agen = { workspace = true }
|
||||
decodal.workspace = true
|
||||
protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use decodal::{Data, Engine, ImportLoader, LoadedImport};
|
||||
use serde_json::{Map, Number, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::profile::ProfileError;
|
||||
|
||||
pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2";
|
||||
pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BuiltinProfileImport {
|
||||
pub specifier: &'static str,
|
||||
pub resolved_path: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BuiltinProfileResource {
|
||||
pub selector: Option<&'static str>,
|
||||
pub path: &'static str,
|
||||
pub source: &'static str,
|
||||
pub description: &'static str,
|
||||
pub imports: &'static [BuiltinProfileImport],
|
||||
}
|
||||
|
||||
const BASE_PATH: &str = "profiles/base.dcdl";
|
||||
const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
|
||||
specifier: "./base.dcdl",
|
||||
resolved_path: BASE_PATH,
|
||||
}];
|
||||
const NO_IMPORTS: &[BuiltinProfileImport] = &[];
|
||||
|
||||
pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
BuiltinProfileResource {
|
||||
selector: None,
|
||||
path: BASE_PATH,
|
||||
source: include_str!("../../../resources/profiles/base.dcdl"),
|
||||
description: "Shared built-in Profile defaults.",
|
||||
imports: NO_IMPORTS,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some(BUILTIN_DEFAULT_PROFILE),
|
||||
path: "profiles/default.dcdl",
|
||||
source: include_str!("../../../resources/profiles/default.dcdl"),
|
||||
description: "Standalone Yoi coding profile.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:coder"),
|
||||
path: "profiles/coder.dcdl",
|
||||
source: include_str!("../../../resources/profiles/coder.dcdl"),
|
||||
description: "Ticket implementation with direct Reviewer SubWorkers.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:companion"),
|
||||
path: "profiles/companion.dcdl",
|
||||
source: include_str!("../../../resources/profiles/companion.dcdl"),
|
||||
description: "General assistance with Workspace tools.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:intake"),
|
||||
path: "profiles/intake.dcdl",
|
||||
source: include_str!("../../../resources/profiles/intake.dcdl"),
|
||||
description: "Read-only intake and planning.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:reviewer"),
|
||||
path: "profiles/reviewer.dcdl",
|
||||
source: include_str!("../../../resources/profiles/reviewer.dcdl"),
|
||||
description: "Independent review of a published Merge Request source.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:orchestrator"),
|
||||
path: "profiles/orchestrator.dcdl",
|
||||
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
|
||||
description: "Workspace orchestration and Worker control.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:memory-consolidation"),
|
||||
path: "profiles/memory-consolidation.dcdl",
|
||||
source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"),
|
||||
description: "Internal Memory consolidation service.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BuiltinProfileCatalogSnapshot {
|
||||
pub id: &'static str,
|
||||
pub sources: BTreeMap<String, String>,
|
||||
pub entrypoints: BTreeMap<String, String>,
|
||||
pub imports: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl BuiltinProfileCatalogSnapshot {
|
||||
pub fn digest(&self) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.id.as_bytes());
|
||||
for (path, source) in &self.sources {
|
||||
hasher.update((path.len() as u64).to_le_bytes());
|
||||
hasher.update(path.as_bytes());
|
||||
hasher.update((source.len() as u64).to_le_bytes());
|
||||
hasher.update(source.as_bytes());
|
||||
}
|
||||
for (selector, path) in &self.entrypoints {
|
||||
hasher.update((selector.len() as u64).to_le_bytes());
|
||||
hasher.update(selector.as_bytes());
|
||||
hasher.update((path.len() as u64).to_le_bytes());
|
||||
hasher.update(path.as_bytes());
|
||||
}
|
||||
for (request, resolved_path) in &self.imports {
|
||||
hasher.update((request.len() as u64).to_le_bytes());
|
||||
hasher.update(request.as_bytes());
|
||||
hasher.update((resolved_path.len() as u64).to_le_bytes());
|
||||
hasher.update(resolved_path.as_bytes());
|
||||
}
|
||||
format!("sha256:{:x}", hasher.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot {
|
||||
let mut sources = BTreeMap::new();
|
||||
let mut entrypoints = BTreeMap::new();
|
||||
let mut imports = BTreeMap::new();
|
||||
|
||||
for resource in BUILTIN_PROFILE_RESOURCES {
|
||||
sources.insert(resource.path.to_owned(), resource.source.to_owned());
|
||||
for import in resource.imports {
|
||||
imports.insert(
|
||||
format!("{}\0{}", resource.path, import.specifier),
|
||||
import.resolved_path.to_owned(),
|
||||
);
|
||||
}
|
||||
if let Some(selector) = resource.selector {
|
||||
entrypoints.insert(selector.to_owned(), resource.path.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
BuiltinProfileCatalogSnapshot {
|
||||
id: BUILTIN_PROFILE_CATALOG_ID,
|
||||
sources,
|
||||
entrypoints,
|
||||
imports,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_profile_entrypoints() -> impl Iterator<Item = &'static BuiltinProfileResource> {
|
||||
BUILTIN_PROFILE_RESOURCES
|
||||
.iter()
|
||||
.filter(|resource| resource.selector.is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_builtin_profile_artifact(
|
||||
selector: &str,
|
||||
) -> Result<Option<Value>, ProfileError> {
|
||||
let catalog = builtin_profile_catalog_snapshot();
|
||||
let Some(entrypoint) = catalog.entrypoints.get(selector) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let source = catalog
|
||||
.sources
|
||||
.get(entrypoint)
|
||||
.expect("built-in Profile entrypoint must name a source")
|
||||
.clone();
|
||||
let mut engine = Engine::new(BuiltinProfileImportLoader {
|
||||
sources: catalog.sources,
|
||||
});
|
||||
let module = engine
|
||||
.add_root_source(entrypoint, entrypoint, &source)
|
||||
.map_err(|error| ProfileError::BuiltinProfileEvaluation {
|
||||
selector: selector.to_owned(),
|
||||
message: format!("{error:?}"),
|
||||
})?;
|
||||
let value =
|
||||
engine
|
||||
.eval_module(module)
|
||||
.map_err(|error| ProfileError::BuiltinProfileEvaluation {
|
||||
selector: selector.to_owned(),
|
||||
message: format!("{error:?}"),
|
||||
})?;
|
||||
let data =
|
||||
engine
|
||||
.materialize(&value)
|
||||
.map_err(|error| ProfileError::BuiltinProfileEvaluation {
|
||||
selector: selector.to_owned(),
|
||||
message: format!("{error:?}"),
|
||||
})?;
|
||||
Ok(Some(data_to_json(&data)))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BuiltinProfileImportLoader {
|
||||
sources: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ImportLoader for BuiltinProfileImportLoader {
|
||||
fn load(
|
||||
&mut self,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> decodal::Result<LoadedImport> {
|
||||
let current_key = current_key.ok_or_else(|| {
|
||||
decodal::Diagnostic::new(
|
||||
decodal::DiagnosticKind::Import,
|
||||
decodal::Span::default(),
|
||||
format!("built-in Profile import `{specifier}` has no source context"),
|
||||
)
|
||||
})?;
|
||||
let resolved = resolve_import_path(current_key, specifier).ok_or_else(|| {
|
||||
decodal::Diagnostic::new(
|
||||
decodal::DiagnosticKind::Import,
|
||||
decodal::Span::default(),
|
||||
format!("built-in Profile import `{specifier}` from `{current_key}` is invalid"),
|
||||
)
|
||||
})?;
|
||||
let source = self.sources.get(&resolved).ok_or_else(|| {
|
||||
decodal::Diagnostic::new(
|
||||
decodal::DiagnosticKind::Import,
|
||||
decodal::Span::default(),
|
||||
format!("built-in Profile import `{specifier}` from `{current_key}` was not found"),
|
||||
)
|
||||
})?;
|
||||
Ok(LoadedImport::source(
|
||||
resolved.clone(),
|
||||
resolved,
|
||||
source.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_import_path(current_key: &str, specifier: &str) -> Option<String> {
|
||||
let current_parent = current_key
|
||||
.rsplit_once('/')
|
||||
.map_or("", |(parent, _)| parent);
|
||||
let joined = if let Some(relative) = specifier.strip_prefix("./") {
|
||||
format!("{current_parent}/{relative}")
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if joined
|
||||
.split('/')
|
||||
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(joined)
|
||||
}
|
||||
|
||||
fn data_to_json(data: &Data) -> Value {
|
||||
match data {
|
||||
Data::Bool(value) => Value::Bool(*value),
|
||||
Data::Int(value) => Value::Number(Number::from(*value)),
|
||||
Data::Float(value) => Number::from_f64(*value)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null),
|
||||
Data::String(value) => Value::String(value.clone()),
|
||||
Data::Array(values) => Value::Array(values.iter().map(data_to_json).collect()),
|
||||
Data::Object(fields) => Value::Object(
|
||||
fields
|
||||
.iter()
|
||||
.map(|field| (field.name.clone(), data_to_json(&field.value)))
|
||||
.collect::<Map<_, _>>(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_has_one_explicit_entrypoint_for_each_builtin_profile() {
|
||||
let catalog = builtin_profile_catalog_snapshot();
|
||||
assert_eq!(catalog.sources.len(), BUILTIN_PROFILE_RESOURCES.len());
|
||||
assert_eq!(catalog.entrypoints.len() + 1, catalog.sources.len());
|
||||
assert_eq!(
|
||||
catalog.entrypoints.get(BUILTIN_DEFAULT_PROFILE),
|
||||
Some(&"profiles/default.dcdl".to_owned())
|
||||
);
|
||||
assert!(catalog.digest().starts_with("sha256:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_profile_evaluates_from_the_shared_resource_graph() {
|
||||
let value = resolve_builtin_profile_artifact(BUILTIN_DEFAULT_PROFILE)
|
||||
.expect("evaluate built-in default")
|
||||
.expect("default exists");
|
||||
assert_eq!(value["slug"], "default");
|
||||
assert_eq!(value["feature"]["task"]["enabled"], true);
|
||||
assert_eq!(value["feature"]["sub_worker"]["enabled"], true);
|
||||
assert_eq!(value["feature"]["memory"]["enabled"], false);
|
||||
assert_eq!(value["feature"]["ticket"]["enabled"], false);
|
||||
assert_eq!(value["feature"]["worker"]["enabled"], false);
|
||||
assert_eq!(value["feature"]["manage_workdir"]["enabled"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_cannot_escape_the_builtin_resource_catalog() {
|
||||
assert_eq!(
|
||||
resolve_import_path("profiles/default.dcdl", "./base.dcdl").as_deref(),
|
||||
Some("profiles/base.dcdl")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_import_path("profiles/default.dcdl", "../outside.dcdl"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_import_path("profiles/default.dcdl", "/outside.dcdl"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub worker: Option<WorkerFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub workspace_worker_discovery: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub objective: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
||||
@@ -119,6 +121,11 @@ impl FeatureConfigPartial {
|
||||
),
|
||||
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
|
||||
worker: merge_option(self.worker, other.worker, WorkerFeatureConfigPartial::merge),
|
||||
workspace_worker_discovery: merge_option(
|
||||
self.workspace_worker_discovery,
|
||||
other.workspace_worker_discovery,
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
objective: merge_option(
|
||||
self.objective,
|
||||
other.objective,
|
||||
@@ -265,6 +272,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.worker
|
||||
.map(WorkerFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
workspace_worker_discovery: value
|
||||
.workspace_worker_discovery
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
objective: value
|
||||
.objective
|
||||
.map(FeatureFlagConfig::from)
|
||||
@@ -394,6 +405,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
sub_worker: Some(value.sub_worker.into()),
|
||||
flow: Some(value.flow.into()),
|
||||
worker: Some(value.worker.into()),
|
||||
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
|
||||
objective: Some(value.objective.into()),
|
||||
manage_workdir: Some(value.manage_workdir.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
@@ -566,15 +578,16 @@ impl WorkerManifestConfig {
|
||||
})
|
||||
}
|
||||
|
||||
/// Base config populated with the in-code defaults listed in
|
||||
/// [`crate::defaults`]. Profile and one-file Manifest resolvers start
|
||||
/// from this layer so every per-field default lives at exactly one
|
||||
/// call site (the `defaults` module).
|
||||
/// Base config populated with the in-code per-field defaults listed in
|
||||
/// [`crate::defaults`]. This is not a selectable Profile and does not
|
||||
/// enable a launch capability surface. Profile and one-file Manifest
|
||||
/// resolvers start from this layer so every per-field default lives at
|
||||
/// exactly one call site (the `defaults` module).
|
||||
///
|
||||
/// `TryFrom<WorkerManifestConfig>` also reads the same constants as a
|
||||
/// belt-and-suspenders fallback, so a manually-constructed config
|
||||
/// that skips this layer still resolves to the same values.
|
||||
pub fn builtin_defaults() -> Self {
|
||||
pub fn resolution_defaults() -> Self {
|
||||
Self {
|
||||
engine: EngineManifestConfig {
|
||||
tool_output: ToolOutputLimitsPartial {
|
||||
@@ -1973,7 +1986,7 @@ enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults()
|
||||
.merge(cfg)
|
||||
.merge(WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
@@ -2074,7 +2087,7 @@ enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults()
|
||||
.merge(base)
|
||||
.merge(upper)
|
||||
.merge(WorkerManifestConfig {
|
||||
@@ -2137,7 +2150,7 @@ permission = "write"
|
||||
|
||||
#[test]
|
||||
fn builtin_defaults_populates_worker_limit_defaults() {
|
||||
let cfg = WorkerManifestConfig::builtin_defaults();
|
||||
let cfg = WorkerManifestConfig::resolution_defaults();
|
||||
assert_eq!(
|
||||
cfg.engine.tool_output.default_max_bytes,
|
||||
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
|
||||
@@ -2172,7 +2185,7 @@ permission = "write"
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let merged = WorkerManifestConfig::builtin_defaults().merge(overlay);
|
||||
let merged = WorkerManifestConfig::resolution_defaults().merge(overlay);
|
||||
let manifest: WorkerManifest = merged.try_into().unwrap();
|
||||
assert_eq!(
|
||||
manifest.engine.tool_output.default_max_bytes,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod builtin_profile;
|
||||
mod config;
|
||||
pub mod defaults;
|
||||
mod model;
|
||||
@@ -7,6 +8,11 @@ pub mod plugin;
|
||||
mod profile;
|
||||
mod scope;
|
||||
|
||||
pub use builtin_profile::{
|
||||
BUILTIN_DEFAULT_PROFILE, BUILTIN_PROFILE_CATALOG_ID, BUILTIN_PROFILE_RESOURCES,
|
||||
BuiltinProfileCatalogSnapshot, BuiltinProfileImport, BuiltinProfileResource,
|
||||
builtin_profile_catalog_snapshot, builtin_profile_entrypoints,
|
||||
};
|
||||
pub use config::{
|
||||
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
|
||||
PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
||||
@@ -17,10 +23,11 @@ pub use model::{
|
||||
};
|
||||
pub use paths::user_profiles_path;
|
||||
pub use profile::{
|
||||
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
||||
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||
ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact,
|
||||
resolve_profile_artifact_value,
|
||||
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
||||
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
||||
ProfileResolveOptions, ProfileResolver, ProfileSelector, ProfileSource, ResolvedProfile,
|
||||
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
|
||||
validate_profile_execution_target,
|
||||
};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||
@@ -118,6 +125,10 @@ pub struct FeatureConfig {
|
||||
pub flow: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub worker: WorkerFeatureConfig,
|
||||
/// Privileged read-only discovery of visible Workspace Workers. Backend
|
||||
/// source proof remains required for every listing operation.
|
||||
#[serde(default)]
|
||||
pub workspace_worker_discovery: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub objective: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
@@ -142,6 +153,7 @@ impl Default for FeatureConfig {
|
||||
sub_worker: FeatureFlagConfig::disabled(),
|
||||
flow: FeatureFlagConfig::disabled(),
|
||||
worker: WorkerFeatureConfig::disabled(),
|
||||
workspace_worker_discovery: FeatureFlagConfig::disabled(),
|
||||
objective: FeatureFlagConfig::disabled(),
|
||||
manage_workdir: FeatureFlagConfig::disabled(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
|
||||
+274
-257
@@ -6,9 +6,14 @@
|
||||
//! from launch context.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::builtin_profile::{
|
||||
BUILTIN_DEFAULT_PROFILE, builtin_profile_catalog_snapshot, builtin_profile_entrypoints,
|
||||
resolve_builtin_profile_artifact,
|
||||
};
|
||||
use crate::config::{
|
||||
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
|
||||
};
|
||||
@@ -23,45 +28,6 @@ use crate::{
|
||||
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
||||
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
|
||||
|
||||
struct BuiltinProfile {
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
const BUILTIN_PROFILES: &[BuiltinProfile] = &[
|
||||
BuiltinProfile {
|
||||
name: "companion",
|
||||
label: "builtin:companion",
|
||||
description: "Bundled Companion role profile",
|
||||
},
|
||||
BuiltinProfile {
|
||||
name: "intake",
|
||||
label: "builtin:intake",
|
||||
description: "Bundled Intake role profile",
|
||||
},
|
||||
BuiltinProfile {
|
||||
name: "orchestrator",
|
||||
label: "builtin:orchestrator",
|
||||
description: "Bundled Orchestrator role profile",
|
||||
},
|
||||
BuiltinProfile {
|
||||
name: "coder",
|
||||
label: "builtin:coder",
|
||||
description: "Bundled Coder role profile",
|
||||
},
|
||||
BuiltinProfile {
|
||||
name: "reviewer",
|
||||
label: "builtin:reviewer",
|
||||
description: "Bundled Reviewer role profile",
|
||||
},
|
||||
BuiltinProfile {
|
||||
name: "memory-consolidation",
|
||||
label: "builtin:memory-consolidation",
|
||||
description: "Bundled Memory staging consolidation profile",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProfileRegistrySource {
|
||||
@@ -159,6 +125,108 @@ impl ProfileSelector {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProfileExecutionTarget {
|
||||
Workspace,
|
||||
Standalone,
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileExecutionTarget {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Workspace => formatter.write_str("workspace"),
|
||||
Self::Standalone => formatter.write_str("standalone"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum WorkspaceAuthorityRequirement {
|
||||
Flow,
|
||||
ManageWorkdir,
|
||||
Memory,
|
||||
MergeRequest,
|
||||
Objective,
|
||||
Orchestration,
|
||||
Plugins,
|
||||
Ticket,
|
||||
Worker,
|
||||
}
|
||||
|
||||
impl fmt::Display for WorkspaceAuthorityRequirement {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Flow => formatter.write_str("feature.flow"),
|
||||
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
|
||||
Self::Memory => formatter.write_str("feature.memory"),
|
||||
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
||||
Self::Objective => formatter.write_str("feature.objective"),
|
||||
Self::Orchestration => formatter.write_str("feature.orchestration"),
|
||||
Self::Plugins => formatter.write_str("feature.plugins or plugin packages"),
|
||||
Self::Ticket => formatter.write_str("feature.ticket"),
|
||||
Self::Worker => formatter.write_str("feature.worker"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_profile_execution_target(
|
||||
manifest: &WorkerManifest,
|
||||
target: ProfileExecutionTarget,
|
||||
) -> Result<(), ProfileError> {
|
||||
if target == ProfileExecutionTarget::Workspace {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let feature = &manifest.feature;
|
||||
let mut requirements = BTreeSet::new();
|
||||
if feature.flow.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Flow);
|
||||
}
|
||||
if feature.manage_workdir.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
||||
}
|
||||
if feature.memory.enabled || feature.memory.staging {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
||||
}
|
||||
if feature.merge_request.show
|
||||
|| feature.merge_request.open
|
||||
|| feature.merge_request.review
|
||||
|| feature.merge_request.readiness_check
|
||||
|| feature.merge_request.complete
|
||||
{
|
||||
requirements.insert(WorkspaceAuthorityRequirement::MergeRequest);
|
||||
}
|
||||
if feature.objective.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Objective);
|
||||
}
|
||||
if feature.orchestration.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
|
||||
}
|
||||
if feature.plugins.enabled || !manifest.plugins.is_empty() {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Plugins);
|
||||
}
|
||||
if feature.ticket.enabled
|
||||
|| feature.ticket.authoring
|
||||
|| feature.ticket.thread
|
||||
|| feature.ticket.intake
|
||||
|| feature.ticket.workflow
|
||||
{
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Ticket);
|
||||
}
|
||||
if feature.worker.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Worker);
|
||||
}
|
||||
|
||||
if requirements.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ProfileError::UnsupportedExecutionTarget {
|
||||
target,
|
||||
requirements: requirements.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ProfileSource {
|
||||
@@ -217,13 +285,14 @@ impl ProfileRegistryEntry {
|
||||
source: ProfileRegistrySource,
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
provenance: String,
|
||||
description: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
name: name.to_string(),
|
||||
path: None,
|
||||
provenance: label.to_string(),
|
||||
provenance,
|
||||
description,
|
||||
is_default: false,
|
||||
artifact: ProfileRegistryArtifact::Builtin { label },
|
||||
@@ -321,12 +390,16 @@ pub struct ProfileDiscovery {
|
||||
}
|
||||
|
||||
impl ProfileDiscovery {
|
||||
pub fn for_cwd(_cwd: &Path) -> Self {
|
||||
pub fn user_settings() -> Self {
|
||||
Self {
|
||||
user_config: paths::user_profiles_path(),
|
||||
project_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_cwd(_cwd: &Path) -> Self {
|
||||
Self::user_settings()
|
||||
}
|
||||
pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
user_config,
|
||||
@@ -412,15 +485,22 @@ impl ProfileResolver {
|
||||
options,
|
||||
),
|
||||
ProfileSelector::Named { .. } | ProfileSelector::Default => {
|
||||
let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo {
|
||||
path: PathBuf::from("."),
|
||||
source,
|
||||
})?;
|
||||
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
|
||||
let registry = ProfileDiscovery::user_settings().discover()?;
|
||||
self.resolve_from_registry(selector, ®istry, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_for_target(
|
||||
&self,
|
||||
selector: &ProfileSelector,
|
||||
options: ProfileResolveOptions,
|
||||
target: ProfileExecutionTarget,
|
||||
) -> Result<ResolvedProfile, ProfileError> {
|
||||
let resolved = self.resolve(selector, options)?;
|
||||
validate_profile_execution_target(&resolved.manifest, target)?;
|
||||
Ok(resolved)
|
||||
}
|
||||
/// Resolve a registry/default selector against an already-discovered
|
||||
/// registry. Callers such as SubWorkerSpawn use this to bind discovery to the
|
||||
/// Worker's cwd instead of the process current directory.
|
||||
@@ -503,7 +583,7 @@ impl ProfileResolver {
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| Path::new(".")),
|
||||
)?;
|
||||
let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| {
|
||||
let raw_artifact = resolve_builtin_profile_artifact(label)?.ok_or_else(|| {
|
||||
ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`"))
|
||||
})?;
|
||||
resolve_profile_value(
|
||||
@@ -565,7 +645,8 @@ fn resolve_profile_value(
|
||||
memory: profile.memory.map(Into::into),
|
||||
skills: profile.skills,
|
||||
};
|
||||
let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||
let config =
|
||||
WorkerManifestConfig::resolution_defaults().merge(config.resolve_paths(profile_dir));
|
||||
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
||||
manifest.profile = Some(ProfileManifestSnapshot {
|
||||
source: source.clone(),
|
||||
@@ -759,14 +840,30 @@ fn load_profile_registry_file(
|
||||
}
|
||||
|
||||
fn add_builtin_profiles(registry: &mut ProfileRegistry) {
|
||||
for profile in BUILTIN_PROFILES {
|
||||
let catalog = builtin_profile_catalog_snapshot();
|
||||
let digest = catalog.digest();
|
||||
for profile in builtin_profile_entrypoints() {
|
||||
let label = profile
|
||||
.selector
|
||||
.expect("built-in Profile entrypoint must have a selector");
|
||||
let name = label
|
||||
.strip_prefix("builtin:")
|
||||
.expect("built-in Profile selector must be source-qualified");
|
||||
registry.push_entry(ProfileRegistryEntry::embedded(
|
||||
ProfileRegistrySource::Builtin,
|
||||
profile.name,
|
||||
profile.label,
|
||||
name,
|
||||
label,
|
||||
format!("{}#{digest}", profile.path),
|
||||
Some(profile.description.into()),
|
||||
));
|
||||
}
|
||||
registry.set_default(ProfileDefault {
|
||||
source: Some(ProfileRegistrySource::Builtin),
|
||||
name: BUILTIN_DEFAULT_PROFILE
|
||||
.strip_prefix("builtin:")
|
||||
.expect("built-in default selector must be source-qualified")
|
||||
.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) {
|
||||
@@ -804,201 +901,6 @@ fn read_profile_artifact_file(path: &Path) -> Result<serde_json::Value, ProfileE
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
||||
let mut value = builtin_base_profile_artifact();
|
||||
match label {
|
||||
"builtin:companion" | "companion" => {
|
||||
apply_role_profile(
|
||||
&mut value,
|
||||
"companion",
|
||||
"Workspace companion profile.",
|
||||
"workspace_write",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
"builtin:intake" | "intake" => {
|
||||
apply_role_profile(
|
||||
&mut value,
|
||||
"intake",
|
||||
"Ticket intake profile.",
|
||||
"workspace_write",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
"builtin:orchestrator" | "orchestrator" => {
|
||||
apply_role_profile(
|
||||
&mut value,
|
||||
"orchestrator",
|
||||
"Ticket orchestrator profile.",
|
||||
"workspace_write",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
"builtin:coder" | "coder" => {
|
||||
apply_role_profile(
|
||||
&mut value,
|
||||
"coder",
|
||||
"Ticket implementation coder profile.",
|
||||
"workspace_write",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
"builtin:reviewer" | "reviewer" => {
|
||||
apply_role_profile(
|
||||
&mut value,
|
||||
"reviewer",
|
||||
"Ticket review profile.",
|
||||
"workspace_read",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
"builtin:memory-consolidation" | "memory-consolidation" => {
|
||||
value["slug"] = serde_json::Value::String("memory-consolidation".to_string());
|
||||
value["description"] =
|
||||
serde_json::Value::String("Memory staging consolidation profile.".to_string());
|
||||
value["feature"]["task"] = serde_json::json!({ "enabled": false });
|
||||
value["feature"]["memory"] = serde_json::json!({ "enabled": true, "staging": true });
|
||||
value["feature"]["web"] = serde_json::json!({ "enabled": false });
|
||||
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": false });
|
||||
value["feature"]["objective"] = serde_json::json!({ "enabled": false });
|
||||
value["feature"]["ticket"] = serde_json::json!({ "enabled": false, "thread": false });
|
||||
Some(value)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_base_profile_artifact() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"slug": "default",
|
||||
"description": "Default Yoi coding profile.",
|
||||
"model": { "ref": "codex-oauth/gpt-5.5" },
|
||||
"session": { "record_event_trace": true },
|
||||
"engine": { "reasoning": "high" },
|
||||
"compaction": {
|
||||
"kind": "tokens",
|
||||
"threshold": 240000,
|
||||
"request_threshold": 270000,
|
||||
"worker_context_max_tokens": 100000
|
||||
},
|
||||
"feature": {
|
||||
"task": { "enabled": true },
|
||||
"memory": { "enabled": true },
|
||||
"web": { "enabled": true },
|
||||
"image": { "enabled": true },
|
||||
"sub_worker": { "enabled": true },
|
||||
"worker": { "enabled": false },
|
||||
"objective": { "enabled": true },
|
||||
"ticket": { "enabled": true, "authoring": true, "thread": true }
|
||||
},
|
||||
"memory": {
|
||||
"extract_threshold": 50000,
|
||||
"consolidation_threshold_files": 5,
|
||||
"consolidation_threshold_bytes": 50000
|
||||
},
|
||||
"web": {
|
||||
"enabled": true,
|
||||
"search": {
|
||||
"provider": "brave",
|
||||
"api_key_secret": "web/brave/default"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply_role_profile(
|
||||
value: &mut serde_json::Value,
|
||||
slug: &str,
|
||||
description: &str,
|
||||
_scope: &str,
|
||||
task: bool,
|
||||
memory: bool,
|
||||
web: bool,
|
||||
sub_worker: bool,
|
||||
) {
|
||||
value["slug"] = serde_json::Value::String(slug.to_string());
|
||||
value["description"] = serde_json::Value::String(description.to_string());
|
||||
value["feature"]["task"] = serde_json::json!({ "enabled": task });
|
||||
value["feature"]["memory"] = serde_json::json!({ "enabled": memory });
|
||||
value["feature"]["web"] = serde_json::json!({ "enabled": web });
|
||||
value["feature"]["image"] = serde_json::json!({ "enabled": true });
|
||||
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
||||
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
||||
value["feature"]["worker"] = serde_json::json!({
|
||||
"enabled": matches!(slug, "companion" | "orchestrator"),
|
||||
"direct_spawn": slug != "orchestrator"
|
||||
});
|
||||
value["feature"]["manage_workdir"] = serde_json::json!({
|
||||
"enabled": matches!(slug, "companion" | "orchestrator")
|
||||
});
|
||||
value["feature"]["orchestration"] = serde_json::json!({ "enabled": slug == "orchestrator" });
|
||||
let ticket = match slug {
|
||||
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||
"intake" => {
|
||||
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
|
||||
}
|
||||
"orchestrator" => {
|
||||
serde_json::json!({ "enabled": true, "thread": true, "workflow": true })
|
||||
}
|
||||
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||
_ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||
};
|
||||
value["feature"]["ticket"] = ticket;
|
||||
let merge_request = match slug {
|
||||
"coder" => serde_json::json!({
|
||||
"show": true,
|
||||
"open": true,
|
||||
"review": false,
|
||||
"readiness_check": false,
|
||||
"complete": false
|
||||
}),
|
||||
"reviewer" => serde_json::json!({
|
||||
"show": true,
|
||||
"open": false,
|
||||
"review": true,
|
||||
"readiness_check": false,
|
||||
"complete": false
|
||||
}),
|
||||
"orchestrator" => serde_json::json!({
|
||||
"show": true,
|
||||
"open": false,
|
||||
"review": false,
|
||||
"readiness_check": true,
|
||||
"complete": true
|
||||
}),
|
||||
_ => serde_json::json!({
|
||||
"show": false,
|
||||
"open": false,
|
||||
"review": false,
|
||||
"readiness_check": false,
|
||||
"complete": false
|
||||
}),
|
||||
};
|
||||
value["feature"]["merge_request"] = merge_request;
|
||||
}
|
||||
|
||||
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
|
||||
let Some(map) = value.as_object() else {
|
||||
return Err(ProfileError::InvalidProfile(
|
||||
@@ -1288,6 +1190,13 @@ pub enum ProfileError {
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
#[error("failed to evaluate built-in Profile `{selector}`: {message}")]
|
||||
BuiltinProfileEvaluation { selector: String, message: String },
|
||||
#[error("Profile requires unsupported {target} launch authorities: {requirements:?}")]
|
||||
UnsupportedExecutionTarget {
|
||||
target: ProfileExecutionTarget,
|
||||
requirements: Vec<WorkspaceAuthorityRequirement>,
|
||||
},
|
||||
#[error("no default profile is configured")]
|
||||
NoDefaultProfile,
|
||||
#[error("profile resolution requires an explicit runtime Worker name")]
|
||||
@@ -1341,18 +1250,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn builtin_profiles_do_not_define_an_implicit_default() {
|
||||
fn builtin_default_is_explicit_registry_authority() {
|
||||
let registry = ProfileDiscovery::with_sources(None, None)
|
||||
.discover()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
registry.default_entry(),
|
||||
Err(ProfileError::NoDefaultProfile)
|
||||
));
|
||||
assert!(matches!(
|
||||
registry.select(&ProfileSelector::Default),
|
||||
Err(ProfileError::NoDefaultProfile)
|
||||
));
|
||||
let default = registry.default_entry().unwrap();
|
||||
assert_eq!(default.source, ProfileRegistrySource::Builtin);
|
||||
assert_eq!(default.name, "default");
|
||||
assert_eq!(default.qualified_name(), BUILTIN_DEFAULT_PROFILE);
|
||||
assert!(default.is_default);
|
||||
assert!(
|
||||
default
|
||||
.provenance
|
||||
.starts_with("profiles/default.dcdl#sha256:")
|
||||
);
|
||||
assert_eq!(registry.select(&ProfileSelector::Default).unwrap(), default);
|
||||
}
|
||||
#[test]
|
||||
fn builtin_role_profiles_are_registered_and_resolve() {
|
||||
@@ -1408,7 +1320,108 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_companion_can_manage_workdirs() {
|
||||
fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve_for_target(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
|
||||
ProfileResolveOptions::with_worker_name("standalone-worker"),
|
||||
ProfileExecutionTarget::Standalone,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
&resolved.source,
|
||||
ProfileSource::Registry {
|
||||
source: ProfileRegistrySource::Builtin,
|
||||
name,
|
||||
path: None,
|
||||
provenance: Some(provenance),
|
||||
..
|
||||
} if name == "default" && provenance.starts_with("profiles/default.dcdl#sha256:")
|
||||
));
|
||||
assert!(resolved.manifest.feature.task.enabled);
|
||||
assert!(resolved.manifest.feature.web.enabled);
|
||||
assert!(resolved.manifest.feature.image.enabled);
|
||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||
assert!(resolved.manifest.scope.allow.iter().any(|rule| {
|
||||
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
|
||||
}));
|
||||
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
|
||||
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
|
||||
}));
|
||||
assert!(!resolved.manifest.feature.memory.enabled);
|
||||
assert!(!resolved.manifest.feature.ticket.enabled);
|
||||
assert!(!resolved.manifest.feature.objective.enabled);
|
||||
assert!(!resolved.manifest.feature.flow.enabled);
|
||||
assert!(!resolved.manifest.feature.worker.enabled);
|
||||
assert!(!resolved.manifest.feature.manage_workdir.enabled);
|
||||
assert!(!resolved.manifest.feature.plugins.enabled);
|
||||
assert!(resolved.manifest.plugins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_rejects_profiles_that_require_workspace_authority() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let error = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve_for_target(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
|
||||
ProfileResolveOptions::with_worker_name("standalone-worker"),
|
||||
ProfileExecutionTarget::Standalone,
|
||||
)
|
||||
.unwrap_err();
|
||||
let diagnostic = error.to_string();
|
||||
|
||||
let ProfileError::UnsupportedExecutionTarget {
|
||||
target,
|
||||
requirements,
|
||||
} = error
|
||||
else {
|
||||
panic!("unexpected error: {error}");
|
||||
};
|
||||
assert_eq!(target, ProfileExecutionTarget::Standalone);
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory));
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest));
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
|
||||
assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_markers_do_not_change_builtin_profile_authority() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let nested = tmp.path().join("repository/nested");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("repository/.yoi")).unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("repository/.yoi/profiles.toml"),
|
||||
"default = { source = 'project', name = 'shadow' }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let discovery = ProfileDiscovery::for_cwd(&nested);
|
||||
assert_eq!(discovery.user_config, paths::user_profiles_path());
|
||||
assert!(discovery.project_config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_coder_uses_sub_worker_control_without_worker_control() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
|
||||
ProfileResolveOptions::with_worker_name("coder-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||
assert!(!resolved.manifest.feature.worker.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_companion_combines_runtime_and_sub_worker_control_with_discovery() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
@@ -1419,6 +1432,10 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||
assert!(resolved.manifest.feature.worker.enabled);
|
||||
assert!(!resolved.manifest.feature.worker.direct_spawn);
|
||||
assert!(resolved.manifest.feature.workspace_worker_discovery.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
|
||||
use crate::schema::{EvidenceKind, EvidenceOrigin, SourceEvidenceRef, SourceRef};
|
||||
|
||||
/// Current flat staging schema version.
|
||||
pub const STAGING_SCHEMA_VERSION: u32 = 2;
|
||||
@@ -80,6 +80,8 @@ pub struct StagingEvidence {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_range: Option<[u64; 2]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<EvidenceOrigin>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub excerpt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
@@ -159,6 +161,7 @@ mod tests {
|
||||
id: "E001".into(),
|
||||
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
|
||||
entry_range: Some([10, 12]),
|
||||
origin: None,
|
||||
excerpt: Some("extract candidate taxonomy".into()),
|
||||
summary: Some("User and assistant discussed staging kinds".into()),
|
||||
};
|
||||
|
||||
@@ -67,6 +67,40 @@ impl EvidenceKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EvidenceOriginKind {
|
||||
HumanInput,
|
||||
WorkerInput,
|
||||
FlowInstruction,
|
||||
BackendInstruction,
|
||||
ModelOutput,
|
||||
ToolOutput,
|
||||
DerivedSummary,
|
||||
LegacyUnknown,
|
||||
}
|
||||
|
||||
/// Bounded origin snapshot attached to extraction evidence. This is audit
|
||||
/// metadata only and cannot authorize Workspace operations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct EvidenceOrigin {
|
||||
pub kind: EvidenceOriginKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_definition_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_definition_revision: Option<u64>,
|
||||
}
|
||||
|
||||
/// Host-resolved source/evidence metadata for an individual staging claim.
|
||||
///
|
||||
/// This deliberately stores only bounded anchor metadata: stable ids, entry
|
||||
@@ -86,6 +120,9 @@ pub struct SourceEvidenceRef {
|
||||
/// Host-assigned evidence id within the referenced evidence set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub evidence_id: Option<String>,
|
||||
/// Trusted typed origin snapshot for this logical evidence entry.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<EvidenceOrigin>,
|
||||
/// Extensible evidence kind tag.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub evidence_kind: Option<EvidenceKind>,
|
||||
|
||||
@@ -10,7 +10,10 @@ mod decision;
|
||||
mod request;
|
||||
mod summary;
|
||||
|
||||
pub use common::{EvidenceKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter};
|
||||
pub use common::{
|
||||
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, Frontmatter, SourceEvidenceRef, SourceRef,
|
||||
split_frontmatter,
|
||||
};
|
||||
pub use decision::{DecisionFrontmatter, DecisionStatus};
|
||||
pub use request::RequestFrontmatter;
|
||||
pub use summary::SummaryFrontmatter;
|
||||
|
||||
@@ -416,7 +416,7 @@ impl MergeRequestStore {
|
||||
let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?;
|
||||
if conflict {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket already has an open Merge Request".into(),
|
||||
"Ticket already has an open Merge Request; use ShowMergeRequest and advance the existing selector_from with a normal non-force push instead of opening a replacement Merge Request or adding a revision".into(),
|
||||
));
|
||||
}
|
||||
let now = i.now.to_rfc3339();
|
||||
@@ -575,12 +575,16 @@ impl MergeRequestStore {
|
||||
));
|
||||
};
|
||||
if subject != i.current_subject_ref {
|
||||
let reason = format!(
|
||||
"selector_from moved from requested subject {subject} to current subject {}; fresh review of the exact current source ref is required",
|
||||
i.current_subject_ref
|
||||
);
|
||||
let e = ReviewCancelledEvent {
|
||||
event_id: Uuid::now_v7().to_string(),
|
||||
sequence: next_seq(&t, &ws, &mr)?,
|
||||
request_event_id: req,
|
||||
subject_ref: subject,
|
||||
reason: "selector_from moved before submission".into(),
|
||||
reason,
|
||||
created_at: i.now,
|
||||
};
|
||||
insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?;
|
||||
@@ -667,9 +671,27 @@ impl MergeRequestStore {
|
||||
}
|
||||
match (&i.current_subject_ref, &review) {
|
||||
(None, _) => b.push("selector_from could not be resolved".into()),
|
||||
(Some(_), None) => b.push("current source ref has no valid review".into()),
|
||||
(_, Some(r)) if r.decision == ReviewDecision::RequestChanges => {
|
||||
b.push("current source ref requests changes".into())
|
||||
(Some(subject_ref), None) => {
|
||||
let previous_review_subject = mr.thread.iter().rev().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(value) => {
|
||||
Some(value.subject_ref.as_str())
|
||||
}
|
||||
MergeRequestThreadEvent::Review(value) => Some(value.subject_ref.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
match previous_review_subject.filter(|previous| *previous != subject_ref) {
|
||||
Some(previous) => b.push(format!(
|
||||
"selector_from moved from reviewed/requested subject {previous} to current subject {subject_ref}; request a fresh review for this exact source ref (selector_to movement alone does not invalidate source approval)"
|
||||
)),
|
||||
None => b.push(format!(
|
||||
"current source ref {subject_ref} has no valid review; request a fresh review for this exact source ref"
|
||||
)),
|
||||
}
|
||||
}
|
||||
(Some(subject_ref), Some(r)) if r.decision == ReviewDecision::RequestChanges => {
|
||||
b.push(format!(
|
||||
"current source ref {subject_ref} requests changes; advance the existing selector_from with a normal non-force push, then request a fresh review for the exact new source ref"
|
||||
))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -181,16 +181,85 @@ fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_retur
|
||||
.is_err()
|
||||
);
|
||||
let mr = s.get("W", "T").unwrap();
|
||||
let cancellation = mr.thread.iter().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewCancelled(value) => Some(value),
|
||||
_ => None,
|
||||
});
|
||||
assert!(
|
||||
mr.thread
|
||||
.iter()
|
||||
.any(|e| matches!(e, MergeRequestThreadEvent::ReviewCancelled(_)))
|
||||
cancellation
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.reason.contains("selector_from moved")
|
||||
&& value.reason.contains("fresh review"))
|
||||
);
|
||||
assert_eq!(
|
||||
mr.effective_review("source-a").map(|r| &r.event_id),
|
||||
Some(&approved.event_id)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_only_approval() {
|
||||
let (_d, s) = fixture();
|
||||
open(&s);
|
||||
let first = approve(&s, "source-1", "one");
|
||||
|
||||
let stale = s
|
||||
.readiness(ReadinessCheck {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("source-2".into()),
|
||||
auth: auth(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!stale.ready);
|
||||
assert!(stale.review.is_none());
|
||||
assert!(stale.blockers.iter().any(|blocker| {
|
||||
blocker.contains("selector_from moved from reviewed/requested subject source-1")
|
||||
&& blocker.contains("current subject source-2")
|
||||
&& blocker.contains("fresh review")
|
||||
}));
|
||||
assert_eq!(
|
||||
s.get("W", "T")
|
||||
.unwrap()
|
||||
.effective_review("source-1")
|
||||
.map(|review| &review.event_id),
|
||||
Some(&first.event_id)
|
||||
);
|
||||
|
||||
let second = approve(&s, "source-2", "two");
|
||||
let ready = s
|
||||
.readiness(ReadinessCheck {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("source-2".into()),
|
||||
auth: auth(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(ready.ready);
|
||||
assert_eq!(
|
||||
ready.review.as_ref().map(|review| &review.event_id),
|
||||
Some(&second.event_id)
|
||||
);
|
||||
|
||||
// The target can move from target-1 to target-2 without changing selector_from
|
||||
// or invalidating the exact-source approval. Completion consumes refreshed
|
||||
// integration evidence for the current target pair.
|
||||
let merged = s
|
||||
.complete(CompleteMergeRequest {
|
||||
operation_id: "target-moved".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "source-2".into(),
|
||||
target_ref_before: "target-2".into(),
|
||||
target_ref_after: "integrated-target-2".into(),
|
||||
approval_event_id: second.event_id,
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(5),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(merged.approved_source_ref, "source-2");
|
||||
assert_eq!(merged.target_ref_before, "target-2");
|
||||
assert_eq!(merged.target_ref_after, "integrated-target-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_revocation_invalidates_readiness() {
|
||||
let (_d, s) = fixture();
|
||||
|
||||
@@ -14,6 +14,7 @@ json-schema = ["dep:schemars"]
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2.workspace = true
|
||||
tokio = { workspace = true, features = ["io-util"], optional = true }
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
uuid = { workspace = true, features = ["serde"] }
|
||||
uuid = { workspace = true, features = ["serde", "v7"] }
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::{Uuid, Version};
|
||||
|
||||
/// Stable Worker identity independent of its current Runtime placement or
|
||||
/// conversation Session.
|
||||
///
|
||||
/// Workspace authority allocates this ID for managed Workers. A standalone
|
||||
/// Worker store allocates it locally when no Workspace authority is present.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct WorkerId(Uuid);
|
||||
|
||||
impl WorkerId {
|
||||
pub fn now_v7() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
||||
/// Converts a legacy Runtime-local numeric id into a syntactically valid
|
||||
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
|
||||
pub fn from_legacy_u64(value: u64) -> Self {
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes[8..].copy_from_slice(&value.to_be_bytes());
|
||||
bytes[6] = 0x70;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
Self(Uuid::from_bytes(bytes))
|
||||
}
|
||||
|
||||
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"yoi.workspace-worker-id.v1\0");
|
||||
hasher.update(workspace_id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(runtime_id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(value.to_be_bytes());
|
||||
let digest = hasher.finalize();
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes.copy_from_slice(&digest[..16]);
|
||||
// Migrated ids sort before normally allocated UUIDv7 values while retaining
|
||||
// deterministic collision-resistant payload bits.
|
||||
bytes[..6].fill(0);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
Self(Uuid::from_bytes(bytes))
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
let value = Uuid::parse_str(value).ok()?;
|
||||
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
|
||||
}
|
||||
|
||||
pub const fn as_uuid(self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn short(self) -> String {
|
||||
let simple = self.0.simple().to_string();
|
||||
simple[simple.len() - 12..].to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WorkerId {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for WorkerId {
|
||||
type Err = WorkerIdParseError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(value).ok_or(WorkerIdParseError)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for WorkerId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for WorkerId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WorkerIdParseError;
|
||||
|
||||
impl fmt::Display for WorkerIdParseError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("Worker id must be a UUIDv7")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorkerIdParseError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn worker_id_accepts_only_uuid_v7() {
|
||||
let worker_id = WorkerId::now_v7();
|
||||
assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
|
||||
assert!(WorkerId::parse("30").is_none());
|
||||
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_worker_id_mapping_is_stable() {
|
||||
assert_eq!(
|
||||
WorkerId::from_legacy_binding("workspace", "runtime", 42),
|
||||
WorkerId::from_legacy_binding("workspace", "runtime", 42)
|
||||
);
|
||||
assert_ne!(
|
||||
WorkerId::from_legacy_binding("workspace", "runtime", 42),
|
||||
WorkerId::from_legacy_binding("workspace", "runtime", 43)
|
||||
);
|
||||
}
|
||||
}
|
||||
+352
-56
@@ -1,3 +1,4 @@
|
||||
pub mod identity;
|
||||
#[cfg(feature = "stream")]
|
||||
pub mod stream;
|
||||
pub mod subscription;
|
||||
@@ -8,6 +9,8 @@ use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use identity::{WorkerId, WorkerIdParseError};
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -190,6 +193,64 @@ impl WorkerEvent {
|
||||
/// variants — emits an alert and inserts a `[unknown input segment]`
|
||||
/// placeholder into the LLM context so neither user nor LLM is blind to
|
||||
/// the dropped intent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PasteArtifactMediaType {
|
||||
TextPlainUtf8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PasteArtifactAvailability {
|
||||
Available,
|
||||
Unavailable,
|
||||
IntegrityFailed,
|
||||
}
|
||||
|
||||
impl PasteArtifactMediaType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::TextPlainUtf8 => "text/plain; charset=utf-8",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PasteArtifactAvailability {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Available => "available",
|
||||
Self::Unavailable => "unavailable",
|
||||
Self::IntegrityFailed => "integrity_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session-owned reference to a large pasted-input artifact.
|
||||
///
|
||||
/// The reference contains only bounded integrity and provenance metadata. The
|
||||
/// artifact body remains in session storage and is available to the model only
|
||||
/// through the scoped paste-artifact tools installed by Worker.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct PasteArtifactRef {
|
||||
pub artifact_id: String,
|
||||
pub created_at_ms: u64,
|
||||
pub media_type: PasteArtifactMediaType,
|
||||
/// Availability observed when this immutable reference was committed.
|
||||
/// Reads revalidate storage and integrity rather than trusting this field.
|
||||
pub availability: PasteArtifactAvailability,
|
||||
pub byte_len: u64,
|
||||
pub char_count: u64,
|
||||
pub line_count: u64,
|
||||
pub sha256: String,
|
||||
pub source_entry_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
@@ -207,6 +268,10 @@ pub enum Segment {
|
||||
lines: u32,
|
||||
content: String,
|
||||
},
|
||||
/// Internal reference produced when Worker stores a large `Paste` before
|
||||
/// committing input. Clients may receive this in history/event projections;
|
||||
/// the body is intentionally absent.
|
||||
PasteArtifact { artifact: PasteArtifactRef },
|
||||
/// `@<path>` file-system reference. Worker resolves readable files to
|
||||
/// `[File: <path>]` attachments and readable normal directories to shallow
|
||||
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
|
||||
@@ -247,6 +312,21 @@ impl Segment {
|
||||
match seg {
|
||||
Segment::Text { content } => out.push_str(content),
|
||||
Segment::Paste { content, .. } => out.push_str(content),
|
||||
Segment::PasteArtifact { artifact } => {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(
|
||||
out,
|
||||
"[Large paste stored as artifact {}: {} bytes, {} chars, {} lines, {}, {}, created at {} ms, sha256 {}; use SearchInputArtifact and ReadInputArtifact to inspect it]",
|
||||
artifact.artifact_id,
|
||||
artifact.byte_len,
|
||||
artifact.char_count,
|
||||
artifact.line_count,
|
||||
artifact.media_type.as_str(),
|
||||
artifact.availability.as_str(),
|
||||
artifact.created_at_ms,
|
||||
artifact.sha256
|
||||
);
|
||||
}
|
||||
Segment::FileRef { path } => {
|
||||
out.push('@');
|
||||
out.push_str(path);
|
||||
@@ -281,11 +361,44 @@ impl Method {
|
||||
/// Presentation category for an Internal Worker exposed through its parent's
|
||||
/// protocol stream. Internal Workers never become independently addressable
|
||||
/// protocol subjects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InternalWorkerKind {
|
||||
SubWorker,
|
||||
Service { kind: String },
|
||||
}
|
||||
|
||||
/// Stable parent-owned lifecycle for one compaction run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct CompactionLifecycle {
|
||||
pub schema_version: u32,
|
||||
pub compaction_id: String,
|
||||
pub revision: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub internal_worker: Option<InternalWorkerRef>,
|
||||
pub state: CompactionLifecycleState,
|
||||
/// Milliseconds since the Unix epoch.
|
||||
pub started_at_ms: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ended_at_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub new_segment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompactionLifecycleState {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
/// Stable presentation identity for one parent-owned Internal Worker session.
|
||||
@@ -307,8 +420,7 @@ pub struct InternalWorkerRef {
|
||||
pub struct InternalWorkerSnapshot {
|
||||
pub worker: InternalWorkerRef,
|
||||
pub revision: u64,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
pub session: SessionSnapshot,
|
||||
#[serde(default)]
|
||||
pub status: WorkerStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -319,12 +431,126 @@ pub struct InternalWorkerSnapshot {
|
||||
pub internal_workers: Vec<InternalWorkerSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolResultDisposition {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
Interrupted,
|
||||
Cancelled,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
/// Canonical, storage-independent projection of committed session history.
|
||||
///
|
||||
/// Worker protocols expose this DTO instead of append-log records. New
|
||||
/// storage variants can therefore be added without teaching every client how
|
||||
/// to replay the durable log format.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct SessionSnapshot {
|
||||
pub entries: Vec<SessionSnapshotEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionEntryProvenance {
|
||||
HumanInput,
|
||||
WorkerInput,
|
||||
FlowInstruction,
|
||||
BackendInstruction,
|
||||
ModelOutput,
|
||||
ToolOutput,
|
||||
DerivedSummary,
|
||||
LegacyUnknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct SessionSnapshotEntry {
|
||||
/// Stable identity from durable history metadata, or a deterministic
|
||||
/// identity derived from the legacy segment and log position.
|
||||
pub entry_id: String,
|
||||
/// Timestamp copied from the durable log record that commits this entry.
|
||||
pub timestamp: u64,
|
||||
pub provenance: SessionEntryProvenance,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub derived_from: Vec<String>,
|
||||
#[serde(flatten)]
|
||||
pub data: SessionSnapshotEntryData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum SessionSnapshotEntryData {
|
||||
UserInput {
|
||||
segments: Vec<Segment>,
|
||||
},
|
||||
Message {
|
||||
role: SessionMessageRole,
|
||||
content: Vec<SessionContentPart>,
|
||||
},
|
||||
ToolCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
ToolResult {
|
||||
call_id: String,
|
||||
summary: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<SessionToolAttachment>,
|
||||
},
|
||||
SystemItem {
|
||||
item_kind: String,
|
||||
content: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||
data: Option<serde_json::Value>,
|
||||
},
|
||||
RunError {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionMessageRole {
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum SessionContentPart {
|
||||
Text { text: String },
|
||||
Refusal { refusal: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct SessionToolAttachment {
|
||||
pub media_type: String,
|
||||
/// Base64-encoded durable attachment body. Public snapshots preserve the
|
||||
/// committed multimodal value instead of replacing it with placeholder text.
|
||||
pub data_base64: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
/// A user input message was accepted, persisted as
|
||||
/// `LogEntry::UserInput`, and is about to start a new turn.
|
||||
/// `LogEntry::AnnotatedUserInput`, and is about to start a new turn.
|
||||
/// Broadcast to every subscribed client so TUI / GUI instances show
|
||||
/// the same user line that reconnect snapshots would replay from
|
||||
/// history; clients must not synthesize a separate pending/fake
|
||||
@@ -345,7 +571,7 @@ pub enum Event {
|
||||
/// of parsing free-text prefixes like `[Notification] …` or
|
||||
/// `[File: …]`.
|
||||
///
|
||||
/// One event per `LogEntry::SystemItem` commit. Disk-side and
|
||||
/// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and
|
||||
/// wire-side are 1:1.
|
||||
SystemItem {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||
@@ -468,6 +694,8 @@ pub enum Event {
|
||||
/// summary-only, or when the result was pruned.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
output: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
disposition: Option<ToolResultDisposition>,
|
||||
#[serde(default)]
|
||||
is_error: bool,
|
||||
},
|
||||
@@ -508,8 +736,7 @@ pub enum Event {
|
||||
/// role-specific entry events (`SegmentRotated` / `SystemItem`) —
|
||||
/// there is no generic "every committed entry" broadcast.
|
||||
Snapshot {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
|
||||
entries: Vec<serde_json::Value>,
|
||||
session: SessionSnapshot,
|
||||
greeting: Greeting,
|
||||
#[serde(default)]
|
||||
status: WorkerStatus,
|
||||
@@ -542,14 +769,10 @@ pub enum Event {
|
||||
/// Server-side segment log rotated to a fresh `SegmentStart`.
|
||||
///
|
||||
/// Fires on compaction and on auto-fork when the store head drifts
|
||||
/// from the live writer's cached head. Clients drop their derived
|
||||
/// view and reseed from `entry.history` exactly the way they would
|
||||
/// from a connect-time `Snapshot`.
|
||||
///
|
||||
/// Payload is the JSON form of `session_store::LogEntry::SegmentStart`.
|
||||
/// A compaction/fork has replaced the authoritative segment. Clients drop
|
||||
/// their derived view and reseed from the canonical committed snapshot.
|
||||
SegmentRotated {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||
entry: serde_json::Value,
|
||||
session: SessionSnapshot,
|
||||
},
|
||||
/// Current Worker controller status. Broadcast on every controller-level
|
||||
/// transition and included in `History` snapshots for late attach.
|
||||
@@ -576,11 +799,10 @@ pub enum Event {
|
||||
head_entries: usize,
|
||||
targets: Vec<RewindTarget>,
|
||||
},
|
||||
/// A rewind has truncated the authoritative session. `entries` is the
|
||||
/// retained session-log prefix clients should use to reseed display state.
|
||||
/// A rewind has truncated the authoritative session. `session` is the
|
||||
/// retained canonical snapshot clients should use to reseed display state.
|
||||
RewindApplied {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
|
||||
entries: Vec<serde_json::Value>,
|
||||
session: SessionSnapshot,
|
||||
input: Vec<Segment>,
|
||||
summary: RewindSummary,
|
||||
},
|
||||
@@ -607,23 +829,18 @@ pub enum Event {
|
||||
/// This is not part of LLM history or prompt context; clients may display it
|
||||
/// briefly as operational status.
|
||||
MemoryWorker(MemoryWorkerEvent),
|
||||
/// Worker has started compacting the current session.
|
||||
///
|
||||
/// Fired immediately before a compaction run. Success is signalled by
|
||||
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
|
||||
/// Broadcast to all clients; not replayed to late subscribers.
|
||||
CompactStart,
|
||||
/// Compaction completed and the session was rotated.
|
||||
///
|
||||
/// `new_segment_id` is the UUID of the freshly created session that
|
||||
/// replaced the old history.
|
||||
CompactDone {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "string"))]
|
||||
new_segment_id: uuid::Uuid,
|
||||
/// Worker has started compacting the current session, or bound the run to its
|
||||
/// observable Internal Worker. Revisions upsert one stable lifecycle item.
|
||||
CompactStart {
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
/// Compaction failed. The session is unchanged.
|
||||
/// Compaction completed and the session was rotated.
|
||||
CompactDone {
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
/// Compaction failed or was cancelled. The session is unchanged.
|
||||
CompactFailed {
|
||||
error: String,
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
@@ -895,6 +1112,7 @@ pub enum WorkerStatus {
|
||||
Idle,
|
||||
Running,
|
||||
Paused,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -1061,6 +1279,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_artifact_segment_roundtrips_without_body() {
|
||||
let artifact = PasteArtifactRef {
|
||||
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b2".to_string(),
|
||||
created_at_ms: 1_700_000_000_000,
|
||||
media_type: PasteArtifactMediaType::TextPlainUtf8,
|
||||
availability: PasteArtifactAvailability::Available,
|
||||
byte_len: 65_536,
|
||||
char_count: 65_530,
|
||||
line_count: 200,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: "entry-1".to_string(),
|
||||
};
|
||||
let segment = Segment::PasteArtifact {
|
||||
artifact: artifact.clone(),
|
||||
};
|
||||
let json = serde_json::to_string(&segment).unwrap();
|
||||
assert!(!json.contains("pasted body"));
|
||||
assert_eq!(serde_json::from_str::<Segment>(&json).unwrap(), segment);
|
||||
let projected = Segment::flatten_to_text(&[segment]);
|
||||
assert!(projected.contains(&artifact.artifact_id));
|
||||
assert!(projected.contains("SearchInputArtifact"));
|
||||
assert!(projected.contains("ReadInputArtifact"));
|
||||
assert!(!projected.contains("pasted body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_run_flow_segment_roundtrip() {
|
||||
let method = Method::Run {
|
||||
@@ -1377,7 +1621,7 @@ mod tests {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "completions");
|
||||
assert_eq!(parsed["data"]["kind"], "file");
|
||||
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
|
||||
assert_eq!(parsed["data"]["entries"][0]["value"], "src/main.rs");
|
||||
|
||||
// is_dir defaults to false on inbound payloads that omit it.
|
||||
let inbound =
|
||||
@@ -1397,7 +1641,17 @@ mod tests {
|
||||
#[test]
|
||||
fn event_snapshot_format() {
|
||||
let event = Event::Snapshot {
|
||||
entries: vec![serde_json::json!({"kind": "user_input", "ts": 1, "segments": []})],
|
||||
session: SessionSnapshot {
|
||||
entries: vec![SessionSnapshotEntry {
|
||||
entry_id: "entry-1".into(),
|
||||
timestamp: 1,
|
||||
provenance: SessionEntryProvenance::HumanInput,
|
||||
derived_from: Vec::new(),
|
||||
data: SessionSnapshotEntryData::UserInput {
|
||||
segments: Vec::new(),
|
||||
},
|
||||
}],
|
||||
},
|
||||
greeting: Greeting {
|
||||
worker_name: "test".into(),
|
||||
cwd: "/tmp".into(),
|
||||
@@ -1415,8 +1669,12 @@ mod tests {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "snapshot");
|
||||
assert!(parsed["data"]["entries"].is_array());
|
||||
assert_eq!(parsed["data"]["entries"][0]["kind"], "user_input");
|
||||
assert!(parsed["data"]["session"]["entries"].is_array());
|
||||
assert_eq!(
|
||||
parsed["data"]["session"]["entries"][0]["kind"],
|
||||
"user_input"
|
||||
);
|
||||
assert_eq!(parsed["data"]["session"]["entries"][0]["timestamp"], 1);
|
||||
assert_eq!(parsed["data"]["greeting"]["worker_name"], "test");
|
||||
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
||||
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
||||
@@ -1426,7 +1684,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn event_snapshot_in_flight_roundtrip_and_default() {
|
||||
let inbound = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
|
||||
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
|
||||
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||
match decoded {
|
||||
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
||||
@@ -1434,7 +1692,9 @@ mod tests {
|
||||
}
|
||||
|
||||
let event = Event::Snapshot {
|
||||
session: SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: Greeting {
|
||||
worker_name: "test".into(),
|
||||
cwd: "/tmp".into(),
|
||||
@@ -1500,15 +1760,17 @@ mod tests {
|
||||
#[test]
|
||||
fn event_segment_rotated_roundtrip() {
|
||||
let event = Event::SegmentRotated {
|
||||
entry: serde_json::json!({"kind": "segment_start", "ts": 1, "history": []}),
|
||||
session: SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "segment_rotated");
|
||||
assert_eq!(parsed["data"]["entry"]["kind"], "segment_start");
|
||||
assert!(parsed["data"]["session"]["entries"].is_array());
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::SegmentRotated { entry } => assert_eq!(entry["kind"], "segment_start"),
|
||||
Event::SegmentRotated { session } => assert!(session.entries.is_empty()),
|
||||
other => panic!("expected SegmentRotated, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -1584,8 +1846,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_snapshot_legacy_without_status_defaults_to_idle() {
|
||||
let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
|
||||
fn event_snapshot_without_status_defaults_to_idle() {
|
||||
let json = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
|
||||
let decoded: Event = serde_json::from_str(json).unwrap();
|
||||
match decoded {
|
||||
Event::Snapshot {
|
||||
@@ -1732,45 +1994,74 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64);
|
||||
}
|
||||
|
||||
fn test_compaction_lifecycle(state: CompactionLifecycleState) -> CompactionLifecycle {
|
||||
CompactionLifecycle {
|
||||
schema_version: 2,
|
||||
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
|
||||
revision: 1,
|
||||
internal_worker: None,
|
||||
state,
|
||||
started_at_ms: 1_700_000_000_000,
|
||||
ended_at_ms: None,
|
||||
summary: None,
|
||||
error: None,
|
||||
new_segment_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_start_roundtrip() {
|
||||
let event = Event::CompactStart;
|
||||
let event = Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(CompactionLifecycleState::Running),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(json, r#"{"event":"compact_start"}"#);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_start");
|
||||
assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(decoded, Event::CompactStart));
|
||||
assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_done_roundtrip() {
|
||||
let id = uuid::Uuid::parse_str("0192f0e8-4d84-7d6e-a000-000000000001").unwrap();
|
||||
let event = Event::CompactDone { new_segment_id: id };
|
||||
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Done);
|
||||
lifecycle.new_segment_id = Some(id.to_string());
|
||||
lifecycle.summary = Some("accepted summary".into());
|
||||
let event = Event::CompactDone { lifecycle };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_done");
|
||||
assert_eq!(
|
||||
parsed["data"]["new_segment_id"],
|
||||
parsed["data"]["lifecycle"]["new_segment_id"],
|
||||
"0192f0e8-4d84-7d6e-a000-000000000001"
|
||||
);
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::CompactDone { new_segment_id } => assert_eq!(new_segment_id, id),
|
||||
Event::CompactDone { lifecycle } => {
|
||||
assert_eq!(
|
||||
lifecycle.new_segment_id.as_deref(),
|
||||
Some(id.to_string().as_str())
|
||||
)
|
||||
}
|
||||
other => panic!("expected CompactDone, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_failed_roundtrip() {
|
||||
let event = Event::CompactFailed {
|
||||
error: "provider 429".into(),
|
||||
};
|
||||
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Failed);
|
||||
lifecycle.error = Some("provider 429".into());
|
||||
let event = Event::CompactFailed { lifecycle };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_failed");
|
||||
assert_eq!(parsed["data"]["error"], "provider 429");
|
||||
assert_eq!(parsed["data"]["lifecycle"]["error"], "provider 429");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::CompactFailed { error } => assert_eq!(error, "provider 429"),
|
||||
Event::CompactFailed { lifecycle } => {
|
||||
assert_eq!(lifecycle.error.as_deref(), Some("provider 429"))
|
||||
}
|
||||
other => panic!("expected CompactFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -1781,6 +2072,7 @@ mod tests {
|
||||
id: "call_1".into(),
|
||||
summary: "Read 128 bytes".into(),
|
||||
output: Some("hello world".into()),
|
||||
disposition: Some(ToolResultDisposition::Success),
|
||||
is_error: false,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
@@ -1797,11 +2089,13 @@ mod tests {
|
||||
id,
|
||||
summary,
|
||||
output,
|
||||
disposition,
|
||||
is_error,
|
||||
} => {
|
||||
assert_eq!(id, "call_1");
|
||||
assert_eq!(summary, "Read 128 bytes");
|
||||
assert_eq!(output.as_deref(), Some("hello world"));
|
||||
assert_eq!(disposition, Some(ToolResultDisposition::Success));
|
||||
assert!(!is_error);
|
||||
}
|
||||
other => panic!("expected ToolResult, got {other:?}"),
|
||||
@@ -1814,6 +2108,7 @@ mod tests {
|
||||
id: "call_2".into(),
|
||||
summary: "ok".into(),
|
||||
output: None,
|
||||
disposition: Some(ToolResultDisposition::Success),
|
||||
is_error: false,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
@@ -1829,6 +2124,7 @@ mod tests {
|
||||
id: "call_3".into(),
|
||||
summary: "invalid argument".into(),
|
||||
output: None,
|
||||
disposition: Some(ToolResultDisposition::Error),
|
||||
is_error: true,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
@@ -1962,11 +2258,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_snapshot_defaults_internal_workers_to_empty() {
|
||||
fn snapshot_defaults_internal_workers_to_empty() {
|
||||
let snapshot: Event = serde_json::from_value(serde_json::json!({
|
||||
"event": "snapshot",
|
||||
"data": {
|
||||
"entries": [],
|
||||
"session": { "entries": [] },
|
||||
"greeting": {
|
||||
"worker_name": "parent",
|
||||
"cwd": ".",
|
||||
|
||||
@@ -4,11 +4,14 @@ use ts_rs::{Config, TS};
|
||||
|
||||
use crate::{
|
||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||
CommandStreamSlice, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
||||
InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef,
|
||||
InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary,
|
||||
RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent,
|
||||
WorkerStatus,
|
||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||
PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
@@ -45,6 +48,7 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<TurnResult>(&cfg, &mut output);
|
||||
push_decl::<InvokeKind>(&cfg, &mut output);
|
||||
push_decl::<RunResult>(&cfg, &mut output);
|
||||
push_decl::<ToolResultDisposition>(&cfg, &mut output);
|
||||
push_decl::<ErrorCode>(&cfg, &mut output);
|
||||
push_decl::<Permission>(&cfg, &mut output);
|
||||
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
||||
@@ -53,6 +57,8 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<CommandStreamSlice>(&cfg, &mut output);
|
||||
push_decl::<CommandSnapshot>(&cfg, &mut output);
|
||||
push_decl::<CommandEvent>(&cfg, &mut output);
|
||||
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
|
||||
push_decl::<CompactionLifecycle>(&cfg, &mut output);
|
||||
push_decl::<ScopeRule>(&cfg, &mut output);
|
||||
push_decl::<CompletionEntry>(&cfg, &mut output);
|
||||
push_decl::<RewindTargetId>(&cfg, &mut output);
|
||||
@@ -60,12 +66,22 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<RewindSummary>(&cfg, &mut output);
|
||||
push_decl::<InFlightBlock>(&cfg, &mut output);
|
||||
push_decl::<InFlightSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
|
||||
push_decl::<SessionMessageRole>(&cfg, &mut output);
|
||||
push_decl::<SessionContentPart>(&cfg, &mut output);
|
||||
push_decl::<SessionToolAttachment>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshot>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerKind>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
||||
push_decl::<Greeting>(&cfg, &mut output);
|
||||
push_decl::<Alert>(&cfg, &mut output);
|
||||
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
||||
push_decl::<PasteArtifactMediaType>(&cfg, &mut output);
|
||||
push_decl::<PasteArtifactAvailability>(&cfg, &mut output);
|
||||
push_decl::<PasteArtifactRef>(&cfg, &mut output);
|
||||
push_decl::<Segment>(&cfg, &mut output);
|
||||
push_decl::<WorkerEvent>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionRequestId>(&cfg, &mut output);
|
||||
|
||||
@@ -8,8 +8,10 @@ license.workspace = true
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
agen = { workspace = true }
|
||||
fs4.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2.workspace = true
|
||||
uuid = { workspace = true, features = ["v7", "serde"] }
|
||||
thiserror = { workspace = true }
|
||||
protocol = { workspace = true }
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
//! enumerable by the picker.
|
||||
|
||||
use crate::event_trace::TraceEntry;
|
||||
use crate::paste_artifact::{read_from_dir, write_to_dir};
|
||||
use crate::segment_log::LogEntry;
|
||||
use crate::store::{Store, StoreError};
|
||||
use crate::{SegmentId, SessionId};
|
||||
use crate::{PasteArtifactLimits, SegmentId, SessionId};
|
||||
use protocol::PasteArtifactRef;
|
||||
use std::fs;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -109,6 +111,16 @@ impl FsStore {
|
||||
.join(format!("{segment_id}.trace.jsonl"))
|
||||
}
|
||||
|
||||
fn paste_artifact_dir(&self, session_id: SessionId) -> PathBuf {
|
||||
self.session_dir(session_id).join("artifacts").join("paste")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn paste_artifact_path(&self, session_id: SessionId, artifact_id: &str) -> PathBuf {
|
||||
self.paste_artifact_dir(session_id)
|
||||
.join(format!("{artifact_id}.json"))
|
||||
}
|
||||
|
||||
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
@@ -350,6 +362,33 @@ impl Store for FsStore {
|
||||
Ok(complete.lines().filter(|l| !l.trim().is_empty()).count())
|
||||
}
|
||||
|
||||
fn write_paste_artifact(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
source_entry_id: &str,
|
||||
content: &str,
|
||||
limits: PasteArtifactLimits,
|
||||
) -> Result<PasteArtifactRef, StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
write_to_dir(
|
||||
&self.paste_artifact_dir(session_id),
|
||||
source_entry_id,
|
||||
content,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
|
||||
fn read_paste_artifact(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
artifact_id: &str,
|
||||
) -> Result<(PasteArtifactRef, String), StoreError> {
|
||||
read_from_dir(&self.paste_artifact_dir(session_id), artifact_id)
|
||||
}
|
||||
|
||||
fn append_trace(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
@@ -398,4 +437,146 @@ mod tests {
|
||||
store.create_segment(session_id, segment_id, &[]).unwrap();
|
||||
assert!(store.session_modified_at(session_id).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_artifacts_are_atomic_integrity_checked_and_session_scoped() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let owner = new_session_id();
|
||||
let other = new_session_id();
|
||||
let content = "αβγ\nsecond line\n";
|
||||
let reference = store
|
||||
.write_paste_artifact(owner, "entry-1", content, PasteArtifactLimits::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reference.byte_len, content.len() as u64);
|
||||
assert!(reference.created_at_ms > 0);
|
||||
assert_eq!(
|
||||
reference.media_type,
|
||||
protocol::PasteArtifactMediaType::TextPlainUtf8
|
||||
);
|
||||
assert_eq!(
|
||||
reference.availability,
|
||||
protocol::PasteArtifactAvailability::Available
|
||||
);
|
||||
assert_eq!(reference.char_count, content.chars().count() as u64);
|
||||
assert_eq!(reference.source_entry_id, "entry-1");
|
||||
assert_eq!(
|
||||
store
|
||||
.read_paste_artifact(owner, &reference.artifact_id)
|
||||
.unwrap()
|
||||
.1,
|
||||
content
|
||||
);
|
||||
assert!(matches!(
|
||||
store.read_paste_artifact(other, &reference.artifact_id),
|
||||
Err(StoreError::PasteArtifactNotFound(_))
|
||||
));
|
||||
assert!(
|
||||
self::fs::read_dir(store.paste_artifact_dir(owner))
|
||||
.unwrap()
|
||||
.all(|entry| !entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.ends_with(".tmp"))
|
||||
);
|
||||
let very_large = "z".repeat(1024 * 1024);
|
||||
let very_large_ref = store
|
||||
.write_paste_artifact(
|
||||
owner,
|
||||
"entry-2",
|
||||
&very_large,
|
||||
PasteArtifactLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.read_paste_artifact(owner, &very_large_ref.artifact_id)
|
||||
.unwrap()
|
||||
.1,
|
||||
very_large
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_paste_writes_atomically_enforce_aggregate_caps() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
|
||||
let limits = PasteArtifactLimits {
|
||||
max_artifact_bytes: 4,
|
||||
max_session_bytes: 8,
|
||||
max_session_artifacts: 1,
|
||||
};
|
||||
let mut handles = Vec::new();
|
||||
for entry_id in ["entry-1", "entry-2"] {
|
||||
let root = tmp.path().to_path_buf();
|
||||
let barrier = barrier.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
let store = FsStore::new(root).unwrap();
|
||||
barrier.wait();
|
||||
store.write_paste_artifact(session_id, entry_id, "1234", limits)
|
||||
}));
|
||||
}
|
||||
barrier.wait();
|
||||
let results = handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
assert_eq!(
|
||||
results
|
||||
.iter()
|
||||
.filter(|result| matches!(result, Err(StoreError::PasteArtifactLimit(_))))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(
|
||||
FsStore::new(tmp.path())
|
||||
.unwrap()
|
||||
.paste_artifact_dir(session_id)
|
||||
)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter(
|
||||
|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("json")
|
||||
)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_artifact_limits_and_corruption_fail_closed() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let limits = PasteArtifactLimits {
|
||||
max_artifact_bytes: 5,
|
||||
max_session_bytes: 8,
|
||||
max_session_artifacts: 2,
|
||||
};
|
||||
let first = store
|
||||
.write_paste_artifact(session_id, "entry-1", "1234", limits)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.write_paste_artifact(session_id, "entry-2", "56789", limits),
|
||||
Err(StoreError::PasteArtifactLimit(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.write_paste_artifact(session_id, "entry-2", "5678", limits),
|
||||
Ok(_)
|
||||
));
|
||||
std::fs::write(
|
||||
store.paste_artifact_path(session_id, &first.artifact_id),
|
||||
b"{}",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.read_paste_artifact(session_id, &first.artifact_id),
|
||||
Err(StoreError::Serde(_)) | Err(StoreError::PasteArtifactIntegrity(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Serializable history entries with restore-authoritative logical identity and origin.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::LoggedItem;
|
||||
|
||||
/// Stable logical identity of one model-visible history entry.
|
||||
///
|
||||
/// This value is generated at the trusted Worker session boundary and copied
|
||||
/// unchanged across fork, rewind, compaction retention, restore, and reboot.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct LoggedSessionHistoryEntryId(pub String);
|
||||
|
||||
impl LoggedSessionHistoryEntryId {
|
||||
pub fn new() -> Self {
|
||||
Self(uuid::Uuid::now_v7().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LoggedSessionHistoryEntryId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded subject snapshot. It is evidence, not a live authorization handle.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoggedWorkerSubject {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_id: Option<String>,
|
||||
pub worker_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LoggedSessionHistoryOrigin {
|
||||
HumanInput {
|
||||
account_id: String,
|
||||
},
|
||||
WorkerInput {
|
||||
actor: LoggedWorkerSubject,
|
||||
},
|
||||
FlowInstruction {
|
||||
selector: String,
|
||||
definition_id: String,
|
||||
definition_revision: u64,
|
||||
instance_id: String,
|
||||
state_id: String,
|
||||
},
|
||||
BackendInstruction {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
operation_id: Option<String>,
|
||||
},
|
||||
ModelOutput {
|
||||
worker: LoggedWorkerSubject,
|
||||
},
|
||||
ToolOutput {
|
||||
worker: LoggedWorkerSubject,
|
||||
},
|
||||
DerivedSummary,
|
||||
LegacyUnknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoggedHistoryDerivation {
|
||||
pub sources: Vec<LoggedSessionHistoryEntryId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoggedSessionHistoryMetadata {
|
||||
pub entry_id: LoggedSessionHistoryEntryId,
|
||||
pub origin: LoggedSessionHistoryOrigin,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub derivation: Option<LoggedHistoryDerivation>,
|
||||
}
|
||||
|
||||
impl LoggedSessionHistoryMetadata {
|
||||
pub fn legacy_unknown() -> Self {
|
||||
Self {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted item and metadata are one value so transforms cannot reorder or
|
||||
/// truncate one without the other.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LoggedHistoryEntry {
|
||||
pub item: LoggedItem,
|
||||
pub metadata: LoggedSessionHistoryMetadata,
|
||||
}
|
||||
|
||||
/// Typed system-item history record. The typed system event remains available
|
||||
/// to client replay while its model-visible projection carries the same stable
|
||||
/// metadata used by live history.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LoggedSystemHistoryEntry {
|
||||
pub item: crate::SystemItem,
|
||||
pub metadata: LoggedSessionHistoryMetadata,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::LoggedRole;
|
||||
use agen::llm_client::RequestConfig;
|
||||
|
||||
#[test]
|
||||
fn logged_history_entry_round_trip_preserves_id_origin_and_derivation() {
|
||||
let source_id = LoggedSessionHistoryEntryId::new();
|
||||
let entry = LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::User,
|
||||
content: vec![crate::LoggedContentPart::Text {
|
||||
text: "preference".into(),
|
||||
}],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::HumanInput {
|
||||
account_id: "account-1".into(),
|
||||
},
|
||||
derivation: Some(LoggedHistoryDerivation {
|
||||
sources: vec![source_id.clone()],
|
||||
}),
|
||||
},
|
||||
};
|
||||
let encoded = serde_json::to_vec(&entry).unwrap();
|
||||
let decoded: LoggedHistoryEntry = serde_json::from_slice(&encoded).unwrap();
|
||||
assert_eq!(decoded, entry);
|
||||
assert_eq!(
|
||||
decoded.metadata.derivation.unwrap().sources,
|
||||
vec![source_id]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
|
||||
let session_id = uuid::Uuid::now_v7();
|
||||
let history_entry = LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::Assistant,
|
||||
content: vec![crate::LoggedContentPart::Text {
|
||||
text: "answer".into(),
|
||||
}],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
|
||||
};
|
||||
let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![history_entry],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}]);
|
||||
assert_eq!(state.history[0].as_text(), Some("answer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Versioned decoder for Session schemas that predate canonical annotated history.
|
||||
//!
|
||||
//! These types are intentionally private to `session-store`. Current writers,
|
||||
//! replay, and public projections use [`crate::LogEntry`] exclusively; only the
|
||||
//! Worker Session schema migration is allowed to deserialize these shapes.
|
||||
|
||||
use agen::llm_client::types::RequestConfig;
|
||||
use protocol::Segment;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
|
||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, SegmentId,
|
||||
SegmentOrigin, SessionExtension, SessionId, SystemItem,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
enum LegacyHistoryLogEntry {
|
||||
SegmentStart {
|
||||
ts: u64,
|
||||
session_id: SessionId,
|
||||
system_prompt: Option<String>,
|
||||
config: RequestConfig,
|
||||
history: Vec<LoggedItem>,
|
||||
#[serde(default)]
|
||||
forked_from: Option<SegmentOrigin>,
|
||||
#[serde(default)]
|
||||
compacted_from: Option<SegmentOrigin>,
|
||||
},
|
||||
UserInput {
|
||||
ts: u64,
|
||||
segments: Vec<Segment>,
|
||||
#[serde(default)]
|
||||
extensions: Vec<SessionExtension>,
|
||||
},
|
||||
AssistantItem {
|
||||
ts: u64,
|
||||
item: LoggedItem,
|
||||
},
|
||||
ToolResult {
|
||||
ts: u64,
|
||||
item: LoggedItem,
|
||||
},
|
||||
SystemItem {
|
||||
ts: u64,
|
||||
item: SystemItem,
|
||||
},
|
||||
}
|
||||
|
||||
/// Schema-v1 decoder. Non-history records already had their current shape, so
|
||||
/// they pass through `LogEntry`; legacy history records are converted below.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum LegacySessionLogEntryV1 {
|
||||
History(LegacyHistoryLogEntry),
|
||||
Current(LogEntry),
|
||||
}
|
||||
|
||||
/// Schema v2 retained the v1 history shapes while adding non-history records.
|
||||
/// Keep a distinct type so supported source versions remain explicit rather
|
||||
/// than turning migration compatibility into the current `LogEntry` contract.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum LegacySessionLogEntryV2 {
|
||||
History(LegacyHistoryLogEntry),
|
||||
Current(LogEntry),
|
||||
}
|
||||
|
||||
pub(crate) fn decode_entry(
|
||||
schema_version: u32,
|
||||
line: &str,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
line_index: usize,
|
||||
) -> Result<LogEntry, serde_json::Error> {
|
||||
let entry = match schema_version {
|
||||
1 => match serde_json::from_str::<LegacySessionLogEntryV1>(line)? {
|
||||
LegacySessionLogEntryV1::History(entry) => Entry::History(entry),
|
||||
LegacySessionLogEntryV1::Current(entry) => Entry::Current(entry),
|
||||
},
|
||||
2 => match serde_json::from_str::<LegacySessionLogEntryV2>(line)? {
|
||||
LegacySessionLogEntryV2::History(entry) => Entry::History(entry),
|
||||
LegacySessionLogEntryV2::Current(entry) => Entry::Current(entry),
|
||||
},
|
||||
_ => unreachable!("legacy decoder called for unsupported schema {schema_version}"),
|
||||
};
|
||||
Ok(match entry {
|
||||
Entry::History(entry) => {
|
||||
canonicalize_history_entry(session_id, segment_id, line_index, entry)
|
||||
}
|
||||
Entry::Current(entry) => entry,
|
||||
})
|
||||
}
|
||||
|
||||
enum Entry {
|
||||
History(LegacyHistoryLogEntry),
|
||||
Current(LogEntry),
|
||||
}
|
||||
|
||||
fn legacy_metadata(
|
||||
segment_id: SegmentId,
|
||||
line_index: usize,
|
||||
item_index: usize,
|
||||
) -> LoggedSessionHistoryMetadata {
|
||||
let mut identity = Vec::with_capacity(32);
|
||||
identity.extend_from_slice(segment_id.as_bytes());
|
||||
identity.extend_from_slice(&(line_index as u64).to_be_bytes());
|
||||
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
|
||||
LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId(format!(
|
||||
"l-{}",
|
||||
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, identity)
|
||||
)),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_history_entry(
|
||||
_session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
line_index: usize,
|
||||
entry: LegacyHistoryLogEntry,
|
||||
) -> LogEntry {
|
||||
match entry {
|
||||
LegacyHistoryLogEntry::SegmentStart {
|
||||
ts,
|
||||
session_id,
|
||||
system_prompt,
|
||||
config,
|
||||
history,
|
||||
forked_from,
|
||||
compacted_from,
|
||||
} => LogEntry::AnnotatedSegmentStart {
|
||||
ts,
|
||||
session_id,
|
||||
system_prompt,
|
||||
config,
|
||||
history: history
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(item_index, item)| LoggedHistoryEntry {
|
||||
item,
|
||||
metadata: legacy_metadata(segment_id, line_index, item_index),
|
||||
})
|
||||
.collect(),
|
||||
forked_from,
|
||||
compacted_from,
|
||||
},
|
||||
LegacyHistoryLogEntry::UserInput {
|
||||
ts,
|
||||
segments,
|
||||
extensions,
|
||||
} => LogEntry::AnnotatedUserInput {
|
||||
ts,
|
||||
history: vec![LoggedHistoryEntry {
|
||||
item: LoggedItem::from(agen::Item::user_message(Segment::flatten_to_text(
|
||||
&segments,
|
||||
))),
|
||||
metadata: legacy_metadata(segment_id, line_index, 0),
|
||||
}],
|
||||
segments,
|
||||
extensions,
|
||||
},
|
||||
LegacyHistoryLogEntry::AssistantItem { ts, item } => LogEntry::AnnotatedAssistantItem {
|
||||
ts,
|
||||
entry: LoggedHistoryEntry {
|
||||
item,
|
||||
metadata: legacy_metadata(segment_id, line_index, 0),
|
||||
},
|
||||
},
|
||||
LegacyHistoryLogEntry::ToolResult { ts, item } => LogEntry::AnnotatedToolResult {
|
||||
ts,
|
||||
entry: LoggedHistoryEntry {
|
||||
item,
|
||||
metadata: legacy_metadata(segment_id, line_index, 0),
|
||||
},
|
||||
},
|
||||
LegacyHistoryLogEntry::SystemItem { ts, item } => LogEntry::AnnotatedSystemItem {
|
||||
ts,
|
||||
entry: LoggedSystemHistoryEntry {
|
||||
item,
|
||||
metadata: legacy_metadata(segment_id, line_index, 0),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,17 @@
|
||||
//! let (session_id, segment_id) = create_segment(&store, SegmentStartState {
|
||||
//! system_prompt: None,
|
||||
//! config: &config,
|
||||
//! history: &[],
|
||||
//! history: Vec::new(),
|
||||
//! })?;
|
||||
//! ```
|
||||
|
||||
pub mod event_trace;
|
||||
pub mod fs_store;
|
||||
pub mod history;
|
||||
mod legacy_session_log;
|
||||
pub mod logged_item;
|
||||
mod paste_artifact;
|
||||
pub mod public_snapshot;
|
||||
pub mod segment;
|
||||
pub mod segment_log;
|
||||
pub mod store;
|
||||
@@ -44,9 +48,15 @@ pub use agen::UsageRecord;
|
||||
pub use agen::llm_client::types::{ContentPart, Item, Role};
|
||||
pub use event_trace::{TraceEntry, TracePayload};
|
||||
pub use fs_store::FsStore;
|
||||
pub use history::{
|
||||
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
|
||||
LoggedWorkerSubject,
|
||||
};
|
||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||
pub use paste_artifact::PasteArtifactLimits;
|
||||
pub use segment::{
|
||||
SegmentStartState, append_entry, append_system_item, classify_history_item,
|
||||
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
||||
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
|
||||
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
|
||||
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use agen::{
|
||||
llm_client::types::{ContentPart, Item, Role},
|
||||
tool::{Attachment, ImageAttachment},
|
||||
tool::{Attachment, ImageAttachment, ToolResultDisposition},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
@@ -61,6 +61,8 @@ pub enum LoggedItem {
|
||||
content: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<LoggedAttachment>,
|
||||
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||
disposition: ToolResultDisposition,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
},
|
||||
@@ -128,6 +130,7 @@ impl From<&Item> for LoggedItem {
|
||||
summary,
|
||||
content,
|
||||
attachments,
|
||||
disposition,
|
||||
is_error,
|
||||
..
|
||||
} => Self::ToolResult {
|
||||
@@ -135,6 +138,7 @@ impl From<&Item> for LoggedItem {
|
||||
summary: summary.clone(),
|
||||
content: content.clone(),
|
||||
attachments: attachments.iter().map(LoggedAttachment::from).collect(),
|
||||
disposition: *disposition,
|
||||
is_error: *is_error,
|
||||
},
|
||||
Item::Reasoning {
|
||||
@@ -184,15 +188,24 @@ impl From<LoggedItem> for Item {
|
||||
summary,
|
||||
content,
|
||||
attachments,
|
||||
disposition,
|
||||
is_error,
|
||||
} => Item::ToolResult {
|
||||
} => {
|
||||
let disposition = if is_error && disposition.is_success() {
|
||||
ToolResultDisposition::Error
|
||||
} else {
|
||||
disposition
|
||||
};
|
||||
Item::ToolResult {
|
||||
id: None,
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
disposition,
|
||||
is_error,
|
||||
attachments: attachments.into_iter().map(Attachment::from).collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
LoggedItem::Reasoning {
|
||||
text,
|
||||
summary,
|
||||
@@ -430,6 +443,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_unknown_tool_result_round_trips_as_terminal() {
|
||||
let original = Item::tool_result_item_with_disposition_and_attachments(
|
||||
"call_unknown",
|
||||
"outcome unknown",
|
||||
Some("bounded progress".to_string()),
|
||||
ToolResultDisposition::OutcomeUnknown,
|
||||
Vec::new(),
|
||||
);
|
||||
let logged: LoggedItem = (&original).into();
|
||||
let json = serde_json::to_string(&logged).unwrap();
|
||||
assert!(json.contains(r#""disposition":"outcome_unknown""#));
|
||||
match Item::from(serde_json::from_str::<LoggedItem>(&json).unwrap()) {
|
||||
Item::ToolResult {
|
||||
disposition,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(disposition, ToolResultDisposition::OutcomeUnknown);
|
||||
assert!(is_error);
|
||||
}
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_error_tool_result_infers_error_disposition() {
|
||||
let legacy = r#"{"kind":"tool_result","call_id":"call_old","summary":"failed","content":null,"is_error":true}"#;
|
||||
match Item::from(serde_json::from_str::<LoggedItem>(legacy).unwrap()) {
|
||||
Item::ToolResult { disposition, .. } => {
|
||||
assert_eq!(disposition, ToolResultDisposition::Error)
|
||||
}
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_persistence_round_trips_binary_attachments() {
|
||||
let original = Item::tool_result_item_with_attachments(
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Session-owned storage for large pasted-input artifacts.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use fs4::fs_std::FileExt;
|
||||
use protocol::{PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::StoreError;
|
||||
|
||||
/// Bounded storage policy applied before a large paste becomes durable input.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PasteArtifactLimits {
|
||||
pub max_artifact_bytes: u64,
|
||||
pub max_session_bytes: u64,
|
||||
pub max_session_artifacts: u64,
|
||||
}
|
||||
|
||||
impl Default for PasteArtifactLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_artifact_bytes: 8 * 1024 * 1024,
|
||||
max_session_bytes: 64 * 1024 * 1024,
|
||||
max_session_artifacts: 1_024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrity-bearing on-disk record. The body and metadata are committed in one
|
||||
/// atomic file replacement so readers never observe a half-written artifact.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct StoredPasteArtifact {
|
||||
pub reference: PasteArtifactRef,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub(crate) fn write_to_dir(
|
||||
artifact_dir: &Path,
|
||||
source_entry_id: &str,
|
||||
content: &str,
|
||||
limits: PasteArtifactLimits,
|
||||
) -> Result<PasteArtifactRef, StoreError> {
|
||||
let byte_len = content.len() as u64;
|
||||
if byte_len > limits.max_artifact_bytes {
|
||||
return Err(StoreError::PasteArtifactLimit(format!(
|
||||
"artifact has {byte_len} bytes; maximum is {}",
|
||||
limits.max_artifact_bytes
|
||||
)));
|
||||
}
|
||||
fs::create_dir_all(artifact_dir)?;
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(artifact_dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let mut aggregate = 0_u64;
|
||||
let mut artifact_count = 0_u64;
|
||||
for entry in fs::read_dir(artifact_dir)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let stored: StoredPasteArtifact = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
verify(&stored, &stored.reference.artifact_id)?;
|
||||
artifact_count = artifact_count.checked_add(1).ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
|
||||
})?;
|
||||
aggregate = aggregate
|
||||
.checked_add(stored.reference.byte_len)
|
||||
.ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
||||
})?;
|
||||
}
|
||||
let projected = aggregate.checked_add(byte_len).ok_or_else(|| {
|
||||
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
||||
})?;
|
||||
if projected > limits.max_session_bytes {
|
||||
return Err(StoreError::PasteArtifactLimit(format!(
|
||||
"session artifacts would use {projected} bytes; maximum is {}",
|
||||
limits.max_session_bytes
|
||||
)));
|
||||
}
|
||||
if artifact_count >= limits.max_session_artifacts {
|
||||
return Err(StoreError::PasteArtifactLimit(format!(
|
||||
"session already has {artifact_count} artifacts; maximum is {}",
|
||||
limits.max_session_artifacts
|
||||
)));
|
||||
}
|
||||
|
||||
let artifact_id = uuid::Uuid::now_v7().to_string();
|
||||
let created_at_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| StoreError::PasteArtifactIntegrity(error.to_string()))?
|
||||
.as_millis() as u64;
|
||||
let reference = PasteArtifactRef {
|
||||
artifact_id: artifact_id.clone(),
|
||||
created_at_ms,
|
||||
media_type: PasteArtifactMediaType::TextPlainUtf8,
|
||||
availability: PasteArtifactAvailability::Available,
|
||||
byte_len,
|
||||
char_count: content.chars().count() as u64,
|
||||
line_count: line_count(content),
|
||||
sha256: sha256_hex(content),
|
||||
source_entry_id: source_entry_id.to_string(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&StoredPasteArtifact {
|
||||
reference: reference.clone(),
|
||||
content: content.to_string(),
|
||||
})?;
|
||||
let target = artifact_dir.join(format!("{artifact_id}.json"));
|
||||
let temporary = artifact_dir.join(format!(".{artifact_id}.tmp"));
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&temporary)?;
|
||||
if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Err(error) = fs::rename(&temporary, &target) {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Ok(directory) = fs::File::open(artifact_dir) {
|
||||
directory.sync_all()?;
|
||||
}
|
||||
Ok(reference)
|
||||
}
|
||||
|
||||
pub(crate) fn read_from_dir(
|
||||
artifact_dir: &Path,
|
||||
artifact_id: &str,
|
||||
) -> Result<(PasteArtifactRef, String), StoreError> {
|
||||
let parsed = uuid::Uuid::parse_str(artifact_id)
|
||||
.map_err(|_| StoreError::PasteArtifactNotFound(artifact_id.to_string()))?;
|
||||
if parsed.to_string() != artifact_id {
|
||||
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
|
||||
}
|
||||
let path = artifact_dir.join(format!("{artifact_id}.json"));
|
||||
let bytes = match fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let stored: StoredPasteArtifact = serde_json::from_slice(&bytes)?;
|
||||
verify(&stored, artifact_id)?;
|
||||
Ok((stored.reference, stored.content))
|
||||
}
|
||||
|
||||
fn verify(stored: &StoredPasteArtifact, artifact_id: &str) -> Result<(), StoreError> {
|
||||
let actual_digest = sha256_hex(&stored.content);
|
||||
if stored.reference.artifact_id != artifact_id
|
||||
|| stored.reference.created_at_ms == 0
|
||||
|| stored.reference.media_type != PasteArtifactMediaType::TextPlainUtf8
|
||||
|| stored.reference.availability != PasteArtifactAvailability::Available
|
||||
|| stored.reference.byte_len != stored.content.len() as u64
|
||||
|| stored.reference.char_count != stored.content.chars().count() as u64
|
||||
|| stored.reference.line_count != line_count(&stored.content)
|
||||
|| stored.reference.sha256 != actual_digest
|
||||
{
|
||||
return Err(StoreError::PasteArtifactIntegrity(artifact_id.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sha256_hex(content: &str) -> String {
|
||||
Sha256::digest(content.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn line_count(content: &str) -> u64 {
|
||||
if content.is_empty() {
|
||||
0
|
||||
} else {
|
||||
content.lines().count().max(1) as u64
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
use base64::{
|
||||
Engine as _,
|
||||
engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD},
|
||||
};
|
||||
use protocol::{
|
||||
Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
|
||||
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
|
||||
LoggedSessionHistoryOrigin, SessionId, SystemItem,
|
||||
};
|
||||
|
||||
/// Project a complete current-segment log. A valid segment starts with one
|
||||
/// canonical annotated SegmentStart record; malformed partial input uses the
|
||||
/// nil session only to keep the public failure projection deterministic.
|
||||
pub fn project_current_session_snapshot(log: &[LogEntry]) -> SessionSnapshot {
|
||||
let session_id = log.iter().find_map(|entry| match entry {
|
||||
LogEntry::AnnotatedSegmentStart { session_id, .. } => Some(*session_id),
|
||||
_ => None,
|
||||
});
|
||||
project_session_snapshot(session_id.unwrap_or_else(SessionId::nil), log)
|
||||
}
|
||||
|
||||
/// Project the current durable segment into the only public session-history
|
||||
/// representation. Append-log records remain an internal persistence format.
|
||||
pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> SessionSnapshot {
|
||||
let mut session_key = session_id;
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for (log_index, record) in log.iter().enumerate() {
|
||||
match record {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts,
|
||||
session_id,
|
||||
history,
|
||||
..
|
||||
} => {
|
||||
session_key = *session_id;
|
||||
entries.clear();
|
||||
extend_history(&mut entries, history, None, *ts);
|
||||
}
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts,
|
||||
segments,
|
||||
history,
|
||||
..
|
||||
} => extend_history(&mut entries, history, Some(segments), *ts),
|
||||
LogEntry::AnnotatedAssistantItem { ts, entry }
|
||||
| LogEntry::AnnotatedToolResult { ts, entry } => {
|
||||
if let Some(data) = project_item(&entry.item) {
|
||||
entries.push(history_entry(entry, *ts, data));
|
||||
}
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { ts, entry } => entries.push(system_entry(
|
||||
&entry.item,
|
||||
entry.metadata.entry_id.0.clone(),
|
||||
*ts,
|
||||
provenance(&entry.metadata.origin),
|
||||
derivation_ids(entry),
|
||||
)),
|
||||
LogEntry::RunErrored { ts, message, .. } => entries.push(legacy_entry(
|
||||
&session_key,
|
||||
log_index,
|
||||
0,
|
||||
*ts,
|
||||
SessionSnapshotEntryData::RunError {
|
||||
message: message.clone(),
|
||||
},
|
||||
)),
|
||||
// Run checkpoints, configuration, usage, and extension state are
|
||||
// controller/storage authority rather than committed conversation.
|
||||
LogEntry::Invoke { .. }
|
||||
| LogEntry::TurnEnd { .. }
|
||||
| LogEntry::RunCompleted { .. }
|
||||
| LogEntry::ActiveRunCheckpoint { .. }
|
||||
| LogEntry::PausedTurnAbandoned { .. }
|
||||
| LogEntry::ConfigChanged { .. }
|
||||
| LogEntry::LlmUsage { .. }
|
||||
| LogEntry::Extension { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
SessionSnapshot { entries }
|
||||
}
|
||||
|
||||
fn extend_history(
|
||||
output: &mut Vec<SessionSnapshotEntry>,
|
||||
history: &[LoggedHistoryEntry],
|
||||
input_segments: Option<&Vec<Segment>>,
|
||||
timestamp: u64,
|
||||
) {
|
||||
let mut attached_segments = false;
|
||||
for entry in history {
|
||||
let data = if !attached_segments
|
||||
&& input_segments.is_some()
|
||||
&& matches!(
|
||||
&entry.item,
|
||||
LoggedItem::Message {
|
||||
role: LoggedRole::User,
|
||||
..
|
||||
}
|
||||
) {
|
||||
attached_segments = true;
|
||||
SessionSnapshotEntryData::UserInput {
|
||||
segments: input_segments.cloned().unwrap_or_default(),
|
||||
}
|
||||
} else {
|
||||
let Some(data) = project_item(&entry.item) else {
|
||||
continue;
|
||||
};
|
||||
data
|
||||
};
|
||||
output.push(history_entry(entry, timestamp, data));
|
||||
}
|
||||
}
|
||||
|
||||
fn history_entry(
|
||||
entry: &LoggedHistoryEntry,
|
||||
timestamp: u64,
|
||||
data: SessionSnapshotEntryData,
|
||||
) -> SessionSnapshotEntry {
|
||||
SessionSnapshotEntry {
|
||||
entry_id: entry.metadata.entry_id.0.clone(),
|
||||
timestamp,
|
||||
provenance: provenance(&entry.metadata.origin),
|
||||
derived_from: entry
|
||||
.metadata
|
||||
.derivation
|
||||
.as_ref()
|
||||
.map(|derivation| {
|
||||
derivation
|
||||
.sources
|
||||
.iter()
|
||||
.map(|source| source.0.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec<String> {
|
||||
entry
|
||||
.metadata
|
||||
.derivation
|
||||
.as_ref()
|
||||
.map(|derivation| {
|
||||
derivation
|
||||
.sources
|
||||
.iter()
|
||||
.map(|source| source.0.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn legacy_entry(
|
||||
session_key: &SessionId,
|
||||
log_index: usize,
|
||||
item_index: usize,
|
||||
timestamp: u64,
|
||||
data: SessionSnapshotEntryData,
|
||||
) -> SessionSnapshotEntry {
|
||||
SessionSnapshotEntry {
|
||||
entry_id: legacy_entry_id(session_key, log_index, item_index),
|
||||
timestamp,
|
||||
provenance: SessionEntryProvenance::LegacyUnknown,
|
||||
derived_from: Vec::new(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_entry_id(session_key: &SessionId, log_index: usize, item_index: usize) -> String {
|
||||
let mut identity = Vec::with_capacity(32);
|
||||
identity.extend_from_slice(session_key.as_bytes());
|
||||
identity.extend_from_slice(&(log_index as u64).to_be_bytes());
|
||||
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
|
||||
format!("l-{}", URL_SAFE_NO_PAD.encode(identity))
|
||||
}
|
||||
|
||||
fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
|
||||
match origin {
|
||||
LoggedSessionHistoryOrigin::HumanInput { .. } => SessionEntryProvenance::HumanInput,
|
||||
LoggedSessionHistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
|
||||
LoggedSessionHistoryOrigin::FlowInstruction { .. } => {
|
||||
SessionEntryProvenance::FlowInstruction
|
||||
}
|
||||
LoggedSessionHistoryOrigin::BackendInstruction { .. } => {
|
||||
SessionEntryProvenance::BackendInstruction
|
||||
}
|
||||
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
||||
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
||||
LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
||||
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
||||
match item {
|
||||
LoggedItem::Message { role, content } => {
|
||||
let role = match role {
|
||||
LoggedRole::User => SessionMessageRole::User,
|
||||
LoggedRole::Assistant => SessionMessageRole::Assistant,
|
||||
// System prompts and instruction history never cross the public
|
||||
// snapshot boundary. Typed SystemItems have separate records.
|
||||
LoggedRole::System => return None,
|
||||
};
|
||||
Some(SessionSnapshotEntryData::Message {
|
||||
role,
|
||||
content: content
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
LoggedContentPart::Text { text } => {
|
||||
SessionContentPart::Text { text: text.clone() }
|
||||
}
|
||||
LoggedContentPart::Refusal { refusal } => SessionContentPart::Refusal {
|
||||
refusal: refusal.clone(),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
LoggedItem::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
} => Some(SessionSnapshotEntryData::ToolCall {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: arguments.clone(),
|
||||
}),
|
||||
LoggedItem::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
attachments,
|
||||
..
|
||||
} => Some(SessionSnapshotEntryData::ToolResult {
|
||||
call_id: call_id.clone(),
|
||||
summary: summary.clone(),
|
||||
content: content.clone(),
|
||||
is_error: *is_error,
|
||||
attachments: attachments
|
||||
.iter()
|
||||
.map(|attachment| match attachment {
|
||||
crate::logged_item::LoggedAttachment::Image { mime_type, data } => {
|
||||
SessionToolAttachment {
|
||||
media_type: mime_type.clone(),
|
||||
data_base64: BASE64.encode(data),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
// Hidden model reasoning is never observable.
|
||||
LoggedItem::Reasoning { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn system_entry(
|
||||
item: &SystemItem,
|
||||
entry_id: String,
|
||||
timestamp: u64,
|
||||
provenance: SessionEntryProvenance,
|
||||
derived_from: Vec<String>,
|
||||
) -> SessionSnapshotEntry {
|
||||
let mut data = serde_json::to_value(item).ok();
|
||||
if let Some(serde_json::Value::Object(object)) = data.as_mut() {
|
||||
object.remove("prompt_provenance");
|
||||
}
|
||||
let item_kind = data
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("kind"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("system_item")
|
||||
.to_owned();
|
||||
SessionSnapshotEntry {
|
||||
entry_id,
|
||||
timestamp,
|
||||
provenance,
|
||||
derived_from,
|
||||
data: SessionSnapshotEntryData::SystemItem {
|
||||
item_kind,
|
||||
content: item.history_text(),
|
||||
data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use agen::llm_client::RequestConfig;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
LoggedHistoryDerivation, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
|
||||
LoggedWorkerSubject,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn current_projection_is_stable_and_hides_reasoning_and_system_prompts() {
|
||||
let session_id = crate::new_session_id();
|
||||
let log = vec![LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![
|
||||
LoggedItem::Message {
|
||||
role: LoggedRole::System,
|
||||
content: vec![LoggedContentPart::Text {
|
||||
text: "secret prompt".into(),
|
||||
}],
|
||||
},
|
||||
LoggedItem::Reasoning {
|
||||
text: "secret reasoning".into(),
|
||||
summary: Vec::new(),
|
||||
encrypted_content: None,
|
||||
signature: None,
|
||||
},
|
||||
LoggedItem::Message {
|
||||
role: LoggedRole::Assistant,
|
||||
content: vec![LoggedContentPart::Text {
|
||||
text: "visible".into(),
|
||||
}],
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.map(|item| LoggedHistoryEntry {
|
||||
item,
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}];
|
||||
|
||||
let first = project_session_snapshot(session_id, &log);
|
||||
let second = project_session_snapshot(session_id, &log);
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.entries.len(), 1);
|
||||
assert_eq!(first.entries[0].timestamp, 1);
|
||||
assert_eq!(
|
||||
first.entries[0].provenance,
|
||||
SessionEntryProvenance::LegacyUnknown
|
||||
);
|
||||
let json = serde_json::to_string(&first).unwrap();
|
||||
assert!(!json.contains("secret prompt"));
|
||||
assert!(!json.contains("secret reasoning"));
|
||||
assert!(json.contains("visible"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_user_input_attaches_segments_to_first_user_role_entry_for_any_origin() {
|
||||
let session_id = crate::new_session_id();
|
||||
let segments = vec![Segment::Text {
|
||||
content: "normal submit".into(),
|
||||
}];
|
||||
|
||||
for origin in [
|
||||
LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
LoggedSessionHistoryOrigin::FlowInstruction {
|
||||
selector: "builtin:coder-review".into(),
|
||||
definition_id: "flow-definition".into(),
|
||||
definition_revision: 7,
|
||||
instance_id: "flow-instance".into(),
|
||||
state_id: "implement".into(),
|
||||
},
|
||||
] {
|
||||
let user_entry_id = LoggedSessionHistoryEntryId::new();
|
||||
let source_entry_id = LoggedSessionHistoryEntryId::new();
|
||||
let log = vec![
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: Vec::new(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2,
|
||||
segments: segments.clone(),
|
||||
history: vec![
|
||||
LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::System,
|
||||
content: vec![LoggedContentPart::Text {
|
||||
text: "flow instruction".into(),
|
||||
}],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::FlowInstruction {
|
||||
selector: "builtin:coder-review".into(),
|
||||
definition_id: "flow-definition".into(),
|
||||
definition_revision: 7,
|
||||
instance_id: "flow-instance".into(),
|
||||
state_id: "implement".into(),
|
||||
},
|
||||
derivation: None,
|
||||
},
|
||||
},
|
||||
LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::User,
|
||||
content: vec![LoggedContentPart::Text {
|
||||
text: "normal submit".into(),
|
||||
}],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: user_entry_id.clone(),
|
||||
origin: origin.clone(),
|
||||
derivation: Some(LoggedHistoryDerivation {
|
||||
sources: vec![source_entry_id.clone()],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
extensions: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
let snapshot = project_current_session_snapshot(&log);
|
||||
assert_eq!(snapshot.entries.len(), 1);
|
||||
assert_eq!(snapshot.entries[0].entry_id, user_entry_id.0);
|
||||
assert_eq!(snapshot.entries[0].provenance, provenance(&origin));
|
||||
assert_eq!(snapshot.entries[0].derived_from, vec![source_entry_id.0]);
|
||||
assert_eq!(
|
||||
snapshot.entries[0].data,
|
||||
SessionSnapshotEntryData::UserInput {
|
||||
segments: segments.clone(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_projection_preserves_identity_and_provenance() {
|
||||
let session_id = crate::new_session_id();
|
||||
let metadata = LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::ModelOutput {
|
||||
worker: LoggedWorkerSubject {
|
||||
workspace_id: None,
|
||||
runtime_id: None,
|
||||
worker_id: "worker".into(),
|
||||
},
|
||||
},
|
||||
derivation: None,
|
||||
};
|
||||
let expected_id = metadata.entry_id.0.clone();
|
||||
let log = vec![LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::Assistant,
|
||||
content: vec![LoggedContentPart::Text { text: "ok".into() }],
|
||||
},
|
||||
metadata,
|
||||
}],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}];
|
||||
|
||||
let snapshot = project_session_snapshot(session_id, &log);
|
||||
assert_eq!(snapshot.entries[0].entry_id, expected_id);
|
||||
assert_eq!(
|
||||
snapshot.entries[0].provenance,
|
||||
SessionEntryProvenance::ModelOutput
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,9 @@
|
||||
//! The caller (typically Worker) holds the Engine directly and calls these
|
||||
//! functions after state-mutating operations.
|
||||
|
||||
use crate::logged_item::{LoggedItem, to_logged};
|
||||
use crate::segment_log::{self, LogEntry, SegmentOrigin};
|
||||
use crate::store::{Store, StoreError};
|
||||
use crate::system_item::SystemItem;
|
||||
use crate::{SegmentId, SessionId};
|
||||
use crate::{LoggedHistoryEntry, LoggedSystemHistoryEntry, SegmentId, SessionId};
|
||||
use agen::EngineResult;
|
||||
use agen::llm_client::RequestConfig;
|
||||
use agen::llm_client::types::Item;
|
||||
@@ -18,7 +16,7 @@ use protocol::Segment;
|
||||
pub struct SegmentStartState<'a> {
|
||||
pub system_prompt: Option<&'a str>,
|
||||
pub config: &'a RequestConfig,
|
||||
pub history: &'a [Item],
|
||||
pub history: Vec<LoggedHistoryEntry>,
|
||||
}
|
||||
|
||||
/// Create a new session + initial segment, writing the initial
|
||||
@@ -44,12 +42,12 @@ pub fn create_segment_with_ids(
|
||||
segment_id: SegmentId,
|
||||
state: SegmentStartState<'_>,
|
||||
) -> Result<(), StoreError> {
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: to_logged(state.history),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
@@ -70,12 +68,12 @@ pub fn create_compacted_segment(
|
||||
source_turn_count: usize,
|
||||
) -> Result<SegmentId, StoreError> {
|
||||
let segment_id = crate::new_segment_id();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: source_session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: to_logged(state.history),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: None,
|
||||
compacted_from: Some(SegmentOrigin {
|
||||
segment_id: source_segment_id,
|
||||
@@ -154,12 +152,12 @@ pub fn ensure_head_or_fork(
|
||||
}
|
||||
let source_segment_id = *segment_id;
|
||||
let fork_id = crate::new_segment_id();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: to_logged(state.history),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: Some(SegmentOrigin {
|
||||
segment_id: source_segment_id,
|
||||
at_turn_index,
|
||||
@@ -183,8 +181,9 @@ pub fn save_user_input(
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
segments: Vec<Segment>,
|
||||
history: Vec<LoggedHistoryEntry>,
|
||||
) -> Result<(), StoreError> {
|
||||
save_user_input_with_extensions(store, session_id, segment_id, segments, Vec::new())
|
||||
save_user_input_with_extensions(store, session_id, segment_id, segments, history, Vec::new())
|
||||
}
|
||||
|
||||
/// Atomically persist one typed user submission and Runtime-owned session
|
||||
@@ -194,15 +193,17 @@ pub fn save_user_input_with_extensions(
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
segments: Vec<Segment>,
|
||||
history: Vec<LoggedHistoryEntry>,
|
||||
extensions: Vec<segment_log::SessionExtension>,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: segment_log::now_millis(),
|
||||
segments,
|
||||
history,
|
||||
extensions,
|
||||
},
|
||||
)
|
||||
@@ -220,64 +221,57 @@ pub fn save_delta(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
new_items: &[Item],
|
||||
new_items: &[LoggedHistoryEntry],
|
||||
) -> Result<(), StoreError> {
|
||||
if new_items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ts = segment_log::now_millis();
|
||||
for item in new_items {
|
||||
for entry in new_items {
|
||||
let item = Item::from(entry.item.clone());
|
||||
if item.is_user_message() {
|
||||
// Already persisted by save_user_input at submit time.
|
||||
continue;
|
||||
}
|
||||
let entry = classify_history_item(item, ts);
|
||||
let entry = classify_logged_history_entry(entry.clone(), ts);
|
||||
append_entry(store, session_id, segment_id, entry)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map one history item to its singular `LogEntry` form. Used by the
|
||||
/// fallback `save_delta` path and the controller's worker-callback
|
||||
/// classifier so write classification lives in one place.
|
||||
pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
|
||||
/// Map one annotated history entry to its singular `LogEntry` form. Used by
|
||||
/// the fallback `save_delta` path and the controller's worker-callback
|
||||
/// classifier so write classification lives in one place without discarding
|
||||
/// identity or provenance.
|
||||
/// Map one already-annotated history entry to its singular canonical record
|
||||
/// without changing its identity or provenance.
|
||||
pub fn classify_logged_history_entry(entry: LoggedHistoryEntry, ts: u64) -> LogEntry {
|
||||
let item = Item::from(entry.item.clone());
|
||||
if item.is_tool_result() {
|
||||
LogEntry::ToolResult {
|
||||
ts,
|
||||
item: LoggedItem::from(item),
|
||||
}
|
||||
} else if item.is_assistant_message() || item.is_tool_call() || item.is_reasoning() {
|
||||
LogEntry::AssistantItem {
|
||||
ts,
|
||||
item: LoggedItem::from(item),
|
||||
}
|
||||
LogEntry::AnnotatedToolResult { ts, entry }
|
||||
} else {
|
||||
// Defensive: anything else (future Item kinds) routes through
|
||||
// AssistantItem rather than getting silently dropped.
|
||||
LogEntry::AssistantItem {
|
||||
ts,
|
||||
item: LoggedItem::from(item),
|
||||
}
|
||||
// Assistant messages, tool calls, reasoning, and future non-user
|
||||
// items all use the assistant-side canonical record.
|
||||
LogEntry::AnnotatedAssistantItem { ts, entry }
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a single typed system item as `LogEntry::SystemItem`. Helper
|
||||
/// for the Worker-side interceptor commit path; mirrors the per-item
|
||||
/// commit shape used for assistant / tool result entries.
|
||||
/// Append one typed system item and its history metadata as a canonical
|
||||
/// `LogEntry::AnnotatedSystemItem`.
|
||||
pub fn append_system_item(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
item: SystemItem,
|
||||
entry: LoggedSystemHistoryEntry,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
LogEntry::SystemItem {
|
||||
LogEntry::AnnotatedSystemItem {
|
||||
ts: segment_log::now_millis(),
|
||||
item,
|
||||
entry,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -307,6 +301,7 @@ pub fn save_run_completed(
|
||||
segment_id: SegmentId,
|
||||
result: EngineResult,
|
||||
interrupted: bool,
|
||||
active_run_turn_count: Option<usize>,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
@@ -316,6 +311,7 @@ pub fn save_run_completed(
|
||||
ts: segment_log::now_millis(),
|
||||
interrupted,
|
||||
result,
|
||||
active_run_turn_count,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -428,12 +424,12 @@ pub fn fork(
|
||||
) -> Result<(SessionId, SegmentId), StoreError> {
|
||||
let session_id = crate::new_session_id();
|
||||
let fork_id = crate::new_segment_id();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: to_logged(state.history),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
@@ -468,7 +464,7 @@ pub fn fork_at(
|
||||
// segment), before any turn completes.
|
||||
entries
|
||||
.iter()
|
||||
.position(|e| !matches!(e, LogEntry::SegmentStart { .. }))
|
||||
.position(|e| !matches!(e, LogEntry::AnnotatedSegmentStart { .. }))
|
||||
.unwrap_or(entries.len())
|
||||
} else {
|
||||
entries
|
||||
@@ -480,12 +476,12 @@ pub fn fork_at(
|
||||
let state = segment_log::collect_state(&entries[..cut]);
|
||||
|
||||
let fork_id = crate::new_segment_id();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: source_session_id,
|
||||
system_prompt: state.system_prompt,
|
||||
config: state.config,
|
||||
history: to_logged(&state.history),
|
||||
history: state.annotated_history,
|
||||
forked_from: Some(SegmentOrigin {
|
||||
segment_id: source_id,
|
||||
at_turn_index,
|
||||
|
||||
@@ -14,8 +14,8 @@ use agen::{EngineResult, UsageRecord};
|
||||
use protocol::{InvokeKind, Segment};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
|
||||
use crate::logged_item::LoggedItem;
|
||||
use crate::system_item::SystemItem;
|
||||
|
||||
/// A single segment log entry, serialized as one JSONL line.
|
||||
///
|
||||
@@ -49,23 +49,16 @@ impl SessionExtension {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LogEntry {
|
||||
/// Segment start. Always the first entry in a segment log.
|
||||
/// For forked segments, `history` contains the seed state from the parent.
|
||||
SegmentStart {
|
||||
/// Canonical segment seed. Retained entries keep their stable logical
|
||||
/// identity and origin across fork/compaction/restore.
|
||||
AnnotatedSegmentStart {
|
||||
ts: u64,
|
||||
/// Session this segment belongs to. Compaction / fork inherits
|
||||
/// the source segment's session_id; only fresh "new conversation"
|
||||
/// segments mint a new session_id.
|
||||
session_id: crate::SessionId,
|
||||
system_prompt: Option<String>,
|
||||
config: RequestConfig,
|
||||
history: Vec<LoggedItem>,
|
||||
/// Origin: forked from a sibling segment at a specific turn boundary.
|
||||
/// The referenced segment is guaranteed to share `session_id`.
|
||||
history: Vec<LoggedHistoryEntry>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
forked_from: Option<SegmentOrigin>,
|
||||
/// Origin: compacted from a sibling segment at a specific turn boundary.
|
||||
/// The referenced segment is guaranteed to share `session_id`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
compacted_from: Option<SegmentOrigin>,
|
||||
},
|
||||
@@ -90,46 +83,43 @@ pub enum LogEntry {
|
||||
/// restore conservatively instead of re-running a dangling tool call.
|
||||
Invoke { ts: u64, trigger: InvokeKind },
|
||||
|
||||
/// User input accepted at submit time. Carries the original typed
|
||||
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
|
||||
/// file refs) on segment restore.
|
||||
/// Replay flattens these into a `Item::user_message` for the worker
|
||||
/// history; the worker layer never sees segments directly.
|
||||
UserInput {
|
||||
/// Canonical user submission with its exact model-visible entries. Typed
|
||||
/// Flow instructions and caller-attributed input remain separate entries.
|
||||
AnnotatedUserInput {
|
||||
ts: u64,
|
||||
segments: Vec<Segment>,
|
||||
/// Typed durable state committed atomically with this input record.
|
||||
/// Runtime-owned Flow invocation uses this to avoid a Backend-instance
|
||||
/// commit that can get ahead of Worker history.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
extensions: Vec<SessionExtension>,
|
||||
history: Vec<LoggedHistoryEntry>,
|
||||
},
|
||||
|
||||
/// One assistant-side item appended to history — assistant message,
|
||||
/// reasoning, or tool call. Singular: one entry per history item so
|
||||
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
|
||||
AssistantItem { ts: u64, item: LoggedItem },
|
||||
/// Canonical model output and metadata committed as one journal record.
|
||||
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
|
||||
|
||||
/// One tool-execution result appended to history.
|
||||
ToolResult { ts: u64, item: LoggedItem },
|
||||
/// Canonical tool output and metadata committed as one journal record.
|
||||
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
|
||||
|
||||
/// One typed agent-injected system item: notification, child-Worker
|
||||
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
|
||||
/// `SystemItem` carries kind metadata that the LLM
|
||||
/// itself never sees (the LLM gets `Item::system_message` with the
|
||||
/// item's denormalised `body`), but live clients and replay paths
|
||||
/// dispatch on `kind` for typed rendering.
|
||||
SystemItem { ts: u64, item: SystemItem },
|
||||
/// Canonical typed system event and model-visible metadata committed
|
||||
/// together.
|
||||
AnnotatedSystemItem {
|
||||
ts: u64,
|
||||
entry: LoggedSystemHistoryEntry,
|
||||
},
|
||||
|
||||
/// Turn boundary. Records the turn count after increment.
|
||||
TurnEnd { ts: u64, turn_count: usize },
|
||||
|
||||
/// `run()` / `resume()` が `EngineResult` で正常終了した。
|
||||
/// Audit-only metadata: replay は `interrupted` のみ反映する。
|
||||
/// Replay restores both interruption state and any resumable logical-run
|
||||
/// turn budget.
|
||||
RunCompleted {
|
||||
ts: u64,
|
||||
interrupted: bool,
|
||||
result: EngineResult,
|
||||
/// AgentTurns consumed by a paused/yielded logical run. Terminal
|
||||
/// outcomes persist `None`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
active_run_turn_count: Option<usize>,
|
||||
},
|
||||
|
||||
/// `run()` / `resume()` が `EngineError` で終了した。
|
||||
@@ -141,6 +131,15 @@ pub enum LogEntry {
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Restores an active logical-run budget at a segment boundary, notably
|
||||
/// after compaction replaced the segment that held the original Invoke and
|
||||
/// RunCompleted entries.
|
||||
ActiveRunCheckpoint {
|
||||
ts: u64,
|
||||
active_turn_count: usize,
|
||||
total_turn_count: usize,
|
||||
},
|
||||
|
||||
/// A paused interrupted turn was explicitly abandoned without calling
|
||||
/// `run()` or `resume()` again. Replay clears the interrupted marker so
|
||||
/// the restored Worker is idle and future user input starts a normal new turn.
|
||||
@@ -208,7 +207,13 @@ pub struct RestoredState {
|
||||
pub system_prompt: Option<String>,
|
||||
pub config: RequestConfig,
|
||||
pub history: Vec<Item>,
|
||||
/// Canonical persisted history with stable identity and provenance. This is
|
||||
/// the authority for rewrites, forks, and annotated restore; `history` is
|
||||
/// retained as the model-facing item projection.
|
||||
pub annotated_history: Vec<LoggedHistoryEntry>,
|
||||
pub turn_count: usize,
|
||||
/// AgentTurns consumed by the active paused/yielded logical run.
|
||||
pub active_run_turn_count: Option<usize>,
|
||||
pub last_run_interrupted: bool,
|
||||
/// Number of entries replayed. `0` means the segment log was empty.
|
||||
/// Writers track their own append count via the same counter so
|
||||
@@ -222,7 +227,7 @@ pub struct RestoredState {
|
||||
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
|
||||
pub extensions: Vec<(String, serde_json::Value)>,
|
||||
/// User submissions in original typed form, in submit order.
|
||||
/// One entry per `LogEntry::UserInput`; the K-th entry corresponds to
|
||||
/// One entry per `LogEntry::AnnotatedUserInput`; the K-th entry corresponds to
|
||||
/// the K-th `Item::user_message` derived during replay (modulo
|
||||
/// pre-compaction history seeded via `SegmentStart.history`, whose
|
||||
/// original segments are not preserved). Used by clients to re-render
|
||||
@@ -237,7 +242,9 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: Vec::new(),
|
||||
annotated_history: Vec::new(),
|
||||
turn_count: 0,
|
||||
active_run_turn_count: None,
|
||||
last_run_interrupted: false,
|
||||
entries_count: 0,
|
||||
usage_history: Vec::new(),
|
||||
@@ -249,7 +256,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
state.entries_count += 1;
|
||||
|
||||
match entry {
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
session_id,
|
||||
system_prompt,
|
||||
config,
|
||||
@@ -259,20 +266,29 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
state.session_id = Some(*session_id);
|
||||
state.system_prompt = system_prompt.clone();
|
||||
state.config = config.clone();
|
||||
state.history = history.iter().cloned().map(Item::from).collect();
|
||||
state.annotated_history = history.clone();
|
||||
state.history = history
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|entry| Item::from(entry.item))
|
||||
.collect();
|
||||
}
|
||||
LogEntry::Invoke { .. } => {
|
||||
// A terminal run record below clears or refines this. If the
|
||||
// log ends first, restore must treat the turn as interrupted.
|
||||
state.last_run_interrupted = true;
|
||||
state.active_run_turn_count = Some(0);
|
||||
}
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
segments,
|
||||
extensions,
|
||||
history,
|
||||
..
|
||||
} => {
|
||||
let text = Segment::flatten_to_text(segments);
|
||||
state.history.push(Item::user_message(text));
|
||||
state.annotated_history.extend(history.iter().cloned());
|
||||
state
|
||||
.history
|
||||
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
|
||||
state.user_segments.push(segments.clone());
|
||||
state.extensions.extend(
|
||||
extensions
|
||||
@@ -280,26 +296,57 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
||||
);
|
||||
}
|
||||
LogEntry::AssistantItem { item, .. } => {
|
||||
state.history.push(Item::from(item.clone()));
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
||||
state.annotated_history.push(entry.clone());
|
||||
state.history.push(Item::from(entry.item.clone()));
|
||||
}
|
||||
LogEntry::ToolResult { item, .. } => {
|
||||
state.history.push(Item::from(item.clone()));
|
||||
}
|
||||
LogEntry::SystemItem { item, .. } => {
|
||||
state.history.push(item.to_history_item());
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
state.annotated_history.push(LoggedHistoryEntry {
|
||||
item: LoggedItem::from(entry.item.to_history_item()),
|
||||
metadata: entry.metadata.clone(),
|
||||
});
|
||||
state.history.push(entry.item.to_history_item());
|
||||
}
|
||||
LogEntry::TurnEnd { turn_count, .. } => {
|
||||
if let Some(active_turn_count) = &mut state.active_run_turn_count {
|
||||
*active_turn_count += turn_count.saturating_sub(state.turn_count);
|
||||
}
|
||||
state.turn_count = *turn_count;
|
||||
}
|
||||
LogEntry::RunCompleted { interrupted, .. } => {
|
||||
LogEntry::RunCompleted {
|
||||
interrupted,
|
||||
result,
|
||||
active_run_turn_count,
|
||||
..
|
||||
} => {
|
||||
state.last_run_interrupted = *interrupted;
|
||||
if *interrupted && matches!(result, EngineResult::Paused | EngineResult::Yielded) {
|
||||
// Legacy entries omit the explicit field; retain the
|
||||
// Invoke/TurnEnd-derived count in that case.
|
||||
if let Some(turn_count) = active_run_turn_count {
|
||||
state.active_run_turn_count = Some(*turn_count);
|
||||
}
|
||||
} else {
|
||||
state.active_run_turn_count = None;
|
||||
}
|
||||
}
|
||||
LogEntry::RunErrored { interrupted, .. } => {
|
||||
state.last_run_interrupted = *interrupted;
|
||||
state.active_run_turn_count = None;
|
||||
}
|
||||
LogEntry::ActiveRunCheckpoint {
|
||||
active_turn_count,
|
||||
total_turn_count,
|
||||
..
|
||||
} => {
|
||||
state.active_run_turn_count = Some(*active_turn_count);
|
||||
state.turn_count = *total_turn_count;
|
||||
state.last_run_interrupted = true;
|
||||
}
|
||||
LogEntry::PausedTurnAbandoned { .. } => {
|
||||
state.last_run_interrupted = false;
|
||||
state.active_run_turn_count = None;
|
||||
}
|
||||
LogEntry::ConfigChanged { config, .. } => {
|
||||
state.config = config.clone();
|
||||
@@ -342,6 +389,20 @@ pub fn now_millis() -> u64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
|
||||
};
|
||||
|
||||
fn annotated(item: Item) -> LoggedHistoryEntry {
|
||||
LoggedHistoryEntry {
|
||||
item: LoggedItem::from(item),
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_empty() {
|
||||
@@ -353,12 +414,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_segment_start_sets_initial_state() {
|
||||
let state = collect_state(&[LogEntry::SegmentStart {
|
||||
let state = collect_state(&[LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
config: RequestConfig::default().with_max_tokens(1024),
|
||||
history: vec![Item::user_message("seed").into()],
|
||||
history: vec![annotated(Item::user_message("seed"))],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}]);
|
||||
@@ -371,7 +432,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_full_turn() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -380,14 +441,15 @@ mod tests {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("Hello")],
|
||||
history: vec![annotated(Item::user_message("Hello"))],
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 3000,
|
||||
item: Item::assistant_message("Hi!").into(),
|
||||
entry: annotated(Item::assistant_message("Hi!")),
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 3100,
|
||||
@@ -397,6 +459,7 @@ mod tests {
|
||||
ts: 3200,
|
||||
interrupted: false,
|
||||
result: EngineResult::Finished,
|
||||
active_run_turn_count: None,
|
||||
},
|
||||
]);
|
||||
assert_eq!(state.history.len(), 2);
|
||||
@@ -407,7 +470,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_incomplete_invoke_is_interrupted() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -420,14 +483,15 @@ mod tests {
|
||||
ts: 2000,
|
||||
trigger: InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2001,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("run a tool")],
|
||||
history: vec![annotated(Item::user_message("run a tool"))],
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 3000,
|
||||
item: Item::tool_call("call_1", "side_effect", "{}").into(),
|
||||
entry: annotated(Item::tool_call("call_1", "side_effect", "{}")),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -437,7 +501,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_with_tool_calls() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -446,22 +510,27 @@ mod tests {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("Check weather")],
|
||||
history: vec![annotated(Item::user_message("Check weather"))],
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 3000,
|
||||
item: Item::tool_call("call_1", "get_weather", r#"{"city":"Tokyo"}"#).into(),
|
||||
entry: annotated(Item::tool_call(
|
||||
"call_1",
|
||||
"get_weather",
|
||||
r#"{"city":"Tokyo"}"#,
|
||||
)),
|
||||
},
|
||||
LogEntry::ToolResult {
|
||||
LogEntry::AnnotatedToolResult {
|
||||
ts: 3500,
|
||||
item: Item::tool_result("call_1", "Sunny, 25C").into(),
|
||||
entry: annotated(Item::tool_result("call_1", "Sunny, 25C")),
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 4000,
|
||||
item: Item::assistant_message("It's sunny in Tokyo!").into(),
|
||||
entry: annotated(Item::assistant_message("It's sunny in Tokyo!")),
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 4100,
|
||||
@@ -475,9 +544,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_restores_durable_tool_image_detail() {
|
||||
let entry = LogEntry::ToolResult {
|
||||
let entry = LogEntry::AnnotatedToolResult {
|
||||
ts: 3500,
|
||||
item: Item::tool_result_item_with_attachments(
|
||||
entry: annotated(Item::tool_result_item_with_attachments(
|
||||
"call_image",
|
||||
"attached",
|
||||
None,
|
||||
@@ -485,8 +554,7 @@ mod tests {
|
||||
vec![agen::tool::Attachment::Image(
|
||||
agen::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()),
|
||||
)],
|
||||
)
|
||||
.into(),
|
||||
)),
|
||||
};
|
||||
let persisted = serde_json::to_string(&entry).unwrap();
|
||||
let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap();
|
||||
@@ -506,7 +574,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_config_changed() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -526,7 +594,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_llm_usage_appends_to_usage_history() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -535,10 +603,11 @@ mod tests {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("hi")],
|
||||
history: vec![annotated(Item::user_message("hi"))],
|
||||
},
|
||||
LogEntry::LlmUsage {
|
||||
ts: 2100,
|
||||
@@ -548,9 +617,9 @@ mod tests {
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 10,
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 2200,
|
||||
item: Item::assistant_message("yo").into(),
|
||||
entry: annotated(Item::assistant_message("yo")),
|
||||
},
|
||||
LogEntry::LlmUsage {
|
||||
ts: 3100,
|
||||
@@ -574,7 +643,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_without_llm_usage_keeps_usage_history_empty() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -583,10 +652,11 @@ mod tests {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("hi")],
|
||||
history: vec![annotated(Item::user_message("hi"))],
|
||||
},
|
||||
]);
|
||||
assert!(state.usage_history.is_empty());
|
||||
@@ -647,7 +717,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_invoke_marker_only_mutates_interrupted_state() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -660,10 +730,11 @@ mod tests {
|
||||
ts: 100,
|
||||
trigger: InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 101,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("hi")],
|
||||
history: vec![annotated(Item::user_message("hi"))],
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 200,
|
||||
@@ -682,7 +753,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_paused_turn_abandoned_clears_interrupted_marker() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -695,10 +766,93 @@ mod tests {
|
||||
ts: 100,
|
||||
interrupted: true,
|
||||
result: EngineResult::Paused,
|
||||
active_run_turn_count: Some(1),
|
||||
},
|
||||
LogEntry::PausedTurnAbandoned { ts: 200 },
|
||||
]);
|
||||
assert!(!state.last_run_interrupted);
|
||||
assert_eq!(state.active_run_turn_count, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_restores_active_run_budget_across_compaction_checkpoint() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::ActiveRunCheckpoint {
|
||||
ts: 100,
|
||||
active_turn_count: 3,
|
||||
total_turn_count: 9,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(state.turn_count, 9);
|
||||
assert_eq!(state.active_run_turn_count, Some(3));
|
||||
assert!(state.last_run_interrupted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_interrupted_run_derives_budget_from_invoke_and_turn_end() {
|
||||
let entry: LogEntry = serde_json::from_value(serde_json::json!({
|
||||
"kind": "run_completed",
|
||||
"ts": 300,
|
||||
"interrupted": true,
|
||||
"result": "paused"
|
||||
}))
|
||||
.expect("legacy run-completed entry");
|
||||
let state = collect_state(&[
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::Invoke {
|
||||
ts: 100,
|
||||
trigger: InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 200,
|
||||
turn_count: 2,
|
||||
},
|
||||
entry,
|
||||
]);
|
||||
|
||||
assert_eq!(state.active_run_turn_count, Some(2));
|
||||
assert!(state.last_run_interrupted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_resumable_interruption_clears_the_active_run_budget() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::Invoke {
|
||||
ts: 100,
|
||||
trigger: InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 200,
|
||||
turn_count: 2,
|
||||
},
|
||||
LogEntry::RunCompleted {
|
||||
ts: 300,
|
||||
interrupted: true,
|
||||
result: EngineResult::LimitReached,
|
||||
active_run_turn_count: None,
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(state.last_run_interrupted);
|
||||
assert_eq!(state.active_run_turn_count, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -717,7 +871,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_extension_collects_domain_payload_pairs() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -776,9 +930,12 @@ mod tests {
|
||||
#[test]
|
||||
fn user_input_extensions_restore_with_the_same_committed_input() {
|
||||
let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")];
|
||||
let entry = LogEntry::UserInput {
|
||||
let entry = LogEntry::AnnotatedUserInput {
|
||||
ts: 9999,
|
||||
segments: segments.clone(),
|
||||
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
|
||||
&segments,
|
||||
)))],
|
||||
extensions: vec![SessionExtension::new(
|
||||
"flow.runtime.v1",
|
||||
serde_json::json!({ "state": "implement", "revision": 0 }),
|
||||
@@ -793,7 +950,7 @@ mod tests {
|
||||
assert_eq!(state.extensions[0].1["state"], "implement");
|
||||
}
|
||||
|
||||
/// Mixed segments survive a JSON round-trip through `LogEntry::UserInput`,
|
||||
/// Mixed segments survive a JSON round-trip through `LogEntry::AnnotatedUserInput`,
|
||||
/// and `collect_state` derives `Item::user_message` from the flattened
|
||||
/// text while preserving the original segments separately. This covers
|
||||
/// the segments → flatten → Item replay path from the ticket.
|
||||
@@ -813,16 +970,19 @@ mod tests {
|
||||
path: "src/main.rs".into(),
|
||||
},
|
||||
];
|
||||
let entry = LogEntry::UserInput {
|
||||
let entry = LogEntry::AnnotatedUserInput {
|
||||
ts: 4242,
|
||||
extensions: vec![],
|
||||
segments: segments.clone(),
|
||||
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
|
||||
&segments,
|
||||
)))],
|
||||
};
|
||||
// JSON round-trip preserves the variant byte-for-byte.
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
|
||||
use crate::event_trace::TraceEntry;
|
||||
use crate::segment_log::LogEntry;
|
||||
use crate::{SegmentId, SessionId};
|
||||
use crate::{PasteArtifactLimits, SegmentId, SessionId};
|
||||
use protocol::PasteArtifactRef;
|
||||
|
||||
/// Errors from the persistence store.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -29,6 +30,18 @@ pub enum StoreError {
|
||||
|
||||
#[error("log corrupted at line {line}: {message}")]
|
||||
Corrupt { line: usize, message: String },
|
||||
|
||||
#[error("paste artifact storage is unavailable")]
|
||||
PasteArtifactUnsupported,
|
||||
|
||||
#[error("paste artifact not found: {0}")]
|
||||
PasteArtifactNotFound(String),
|
||||
|
||||
#[error("paste artifact integrity check failed: {0}")]
|
||||
PasteArtifactIntegrity(String),
|
||||
|
||||
#[error("paste artifact size limit exceeded: {0}")]
|
||||
PasteArtifactLimit(String),
|
||||
}
|
||||
|
||||
/// Sync persistence backend for segment logs.
|
||||
@@ -117,6 +130,26 @@ pub trait Store: Send + Sync {
|
||||
segment_id: SegmentId,
|
||||
) -> Result<usize, StoreError>;
|
||||
|
||||
/// Store a large paste before its reference is committed to history.
|
||||
fn write_paste_artifact(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_source_entry_id: &str,
|
||||
_content: &str,
|
||||
_limits: PasteArtifactLimits,
|
||||
) -> Result<PasteArtifactRef, StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
/// Read and verify one artifact owned by `session_id`.
|
||||
fn read_paste_artifact(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_artifact_id: &str,
|
||||
) -> Result<(PasteArtifactRef, String), StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
/// Append a trace entry to the debug event trace file.
|
||||
fn append_trace(
|
||||
&self,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
||||
//! `[File: …]`.
|
||||
//!
|
||||
//! Persisted as the payload of [`crate::LogEntry::SystemItem`] (one
|
||||
//! Persisted as the payload of [`crate::LogEntry::AnnotatedSystemItem`] (one
|
||||
//! entry per item), and broadcast live as the payload of
|
||||
//! `Event::SystemItem` on the wire.
|
||||
//!
|
||||
|
||||
@@ -608,6 +608,24 @@ where
|
||||
) -> Result<usize, crate::StoreError> {
|
||||
self.session_store.read_entry_count(session_id, segment_id)
|
||||
}
|
||||
fn write_paste_artifact(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
source_entry_id: &str,
|
||||
content: &str,
|
||||
limits: crate::PasteArtifactLimits,
|
||||
) -> Result<protocol::PasteArtifactRef, crate::StoreError> {
|
||||
self.session_store
|
||||
.write_paste_artifact(session_id, source_entry_id, content, limits)
|
||||
}
|
||||
fn read_paste_artifact(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
artifact_id: &str,
|
||||
) -> Result<(protocol::PasteArtifactRef, String), crate::StoreError> {
|
||||
self.session_store
|
||||
.read_paste_artifact(session_id, artifact_id)
|
||||
}
|
||||
fn append_trace(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
//! every later operation must use that same ID.
|
||||
|
||||
use crate::event_trace::TraceEntry;
|
||||
use crate::paste_artifact::{read_from_dir, write_to_dir};
|
||||
use crate::segment_log::LogEntry;
|
||||
use crate::store::{Store, StoreError};
|
||||
use crate::{SegmentId, SessionId};
|
||||
use crate::{PasteArtifactLimits, SegmentId, SessionId};
|
||||
use protocol::PasteArtifactRef;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
@@ -20,9 +22,12 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const SESSION_SCHEMA_VERSION: u32 = 1;
|
||||
const SESSION_SCHEMA_VERSION: u32 = 3;
|
||||
const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2;
|
||||
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
|
||||
const SESSION_FILE: &str = "session.json";
|
||||
const SEGMENTS_DIR: &str = "segments";
|
||||
const PASTE_ARTIFACTS_DIR: &str = "artifacts/paste";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkerSessionStore {
|
||||
@@ -44,16 +49,29 @@ impl WorkerSessionStore {
|
||||
fs::create_dir_all(root.join(SEGMENTS_DIR))?;
|
||||
let session_id = match fs::read(root.join(SESSION_FILE)) {
|
||||
Ok(bytes) => {
|
||||
let manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||
if manifest.schema_version != SESSION_SCHEMA_VERSION {
|
||||
let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||
match manifest.schema_version {
|
||||
SESSION_SCHEMA_VERSION => {
|
||||
validate_canonical_segment_logs(&root)?;
|
||||
}
|
||||
PREVIOUS_SESSION_SCHEMA_VERSION | LEGACY_SESSION_SCHEMA_VERSION => {
|
||||
migrate_segment_logs_to_v3(
|
||||
&root,
|
||||
manifest.session_id,
|
||||
manifest.schema_version,
|
||||
)?;
|
||||
manifest.schema_version = SESSION_SCHEMA_VERSION;
|
||||
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
|
||||
}
|
||||
version => {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: format!(
|
||||
"unsupported Worker Session schema version {}, expected {}",
|
||||
manifest.schema_version, SESSION_SCHEMA_VERSION
|
||||
"unsupported Worker Session schema version {version}, expected {SESSION_SCHEMA_VERSION}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(manifest.session_id)
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
@@ -136,6 +154,41 @@ impl WorkerSessionStore {
|
||||
.join(format!("{segment_id}.trace.jsonl"))
|
||||
}
|
||||
|
||||
fn append_log_entry(&self, path: &Path, entry: &LogEntry) -> Result<(), StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
let committed_len = truncate_uncommitted_tail(&mut file)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let mut existing = Vec::new();
|
||||
file.read_to_end(&mut existing)?;
|
||||
parse_jsonl::<LogEntry>(&existing)?;
|
||||
let line = serde_json::to_string(entry)?;
|
||||
let mut record = Vec::with_capacity(line.len() + 1);
|
||||
record.extend_from_slice(line.as_bytes());
|
||||
record.push(b'\n');
|
||||
if let Err(write_error) = file.write_all(&record) {
|
||||
return match file.set_len(committed_len) {
|
||||
Ok(()) => Err(write_error.into()),
|
||||
Err(rollback_error) => Err(std::io::Error::new(
|
||||
rollback_error.kind(),
|
||||
format!(
|
||||
"session append failed ({write_error}) and rollback failed: {rollback_error}"
|
||||
),
|
||||
)
|
||||
.into()),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
@@ -175,7 +228,7 @@ impl Store for WorkerSessionStore {
|
||||
entry: &LogEntry,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ensure_session(session_id, true)?;
|
||||
self.append_line(&self.log_path(segment_id), &serde_json::to_string(entry)?)
|
||||
self.append_log_entry(&self.log_path(segment_id), entry)
|
||||
}
|
||||
|
||||
fn read_all(
|
||||
@@ -267,6 +320,35 @@ impl Store for WorkerSessionStore {
|
||||
.count())
|
||||
}
|
||||
|
||||
fn write_paste_artifact(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
source_entry_id: &str,
|
||||
content: &str,
|
||||
limits: PasteArtifactLimits,
|
||||
) -> Result<PasteArtifactRef, StoreError> {
|
||||
self.ensure_session(session_id, true)?;
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
|
||||
write_to_dir(
|
||||
&self.root.join(PASTE_ARTIFACTS_DIR),
|
||||
source_entry_id,
|
||||
content,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
|
||||
fn read_paste_artifact(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
artifact_id: &str,
|
||||
) -> Result<(PasteArtifactRef, String), StoreError> {
|
||||
self.ensure_session(session_id, false)?;
|
||||
read_from_dir(&self.root.join(PASTE_ARTIFACTS_DIR), artifact_id)
|
||||
}
|
||||
|
||||
fn append_trace(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
@@ -278,6 +360,138 @@ impl Store for WorkerSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> {
|
||||
let segments = root.join(SEGMENTS_DIR);
|
||||
if !segments.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for entry in fs::read_dir(&segments)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let metadata = fs::symlink_metadata(&path)?;
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: format!("non-UTF-8 Worker Session segment path: {}", path.display()),
|
||||
});
|
||||
};
|
||||
if name.ends_with(".trace.jsonl") || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if !name.ends_with(".jsonl") {
|
||||
continue;
|
||||
}
|
||||
if !metadata.file_type().is_file() {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: format!(
|
||||
"Worker Session segment is not a regular file: {}",
|
||||
path.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
let segment_id =
|
||||
name.trim_end_matches(".jsonl")
|
||||
.parse()
|
||||
.map_err(|_| StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: format!("invalid Worker Session segment name: {name}"),
|
||||
})?;
|
||||
paths.push((segment_id, path));
|
||||
}
|
||||
paths.sort_by_key(|(segment_id, _)| *segment_id);
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn migrate_segment_logs_to_v3(
|
||||
root: &Path,
|
||||
session_id: SessionId,
|
||||
source_schema_version: u32,
|
||||
) -> Result<(), StoreError> {
|
||||
struct MigrationPlan {
|
||||
path: PathBuf,
|
||||
source: Vec<u8>,
|
||||
output: Vec<u8>,
|
||||
}
|
||||
|
||||
// Phase 1 is strictly read-only. Every segment must parse and canonicalize
|
||||
// successfully before the first authoritative byte is replaced.
|
||||
let mut plans = Vec::new();
|
||||
for (segment_id, path) in segment_log_paths(root)? {
|
||||
let source = fs::read(&path)?;
|
||||
let canonical = parse_legacy_jsonl(source_schema_version, session_id, segment_id, &source)
|
||||
.map_err(|error| StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: format!(
|
||||
"cannot migrate Worker Session log {}: {error}",
|
||||
path.display()
|
||||
),
|
||||
})?;
|
||||
let mut output = Vec::new();
|
||||
for entry in canonical {
|
||||
serde_json::to_writer(&mut output, &entry)?;
|
||||
output.push(b'\n');
|
||||
}
|
||||
plans.push(MigrationPlan {
|
||||
path,
|
||||
source,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
// Fence the complete preflight snapshot before starting phase 2. Session
|
||||
// open is the exclusive restore boundary; this additionally fails closed
|
||||
// if an unexpected writer raced the preflight.
|
||||
for plan in &plans {
|
||||
if fs::read(&plan.path)? != plan.source {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: format!(
|
||||
"Worker Session segment changed during migration: {}",
|
||||
plan.path.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for plan in plans {
|
||||
atomic_write_bytes(&plan.path, &plan.output)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> {
|
||||
for (_, path) in segment_log_paths(root)? {
|
||||
let _: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_legacy_jsonl(
|
||||
schema_version: u32,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
bytes: &[u8],
|
||||
) -> Result<Vec<LogEntry>, serde_json::Error> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|error| {
|
||||
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
|
||||
})?;
|
||||
text.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| !line.trim().is_empty())
|
||||
.map(|(line_index, line)| {
|
||||
crate::legacy_session_log::decode_entry(
|
||||
schema_version,
|
||||
line,
|
||||
session_id,
|
||||
segment_id,
|
||||
line_index,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
|
||||
let mut bytes = serde_json::to_vec_pretty(value)?;
|
||||
bytes.push(b'\n');
|
||||
@@ -379,7 +593,21 @@ fn truncate_uncommitted_tail(file: &mut File) -> std::io::Result<u64> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Store, new_segment_id, new_session_id};
|
||||
use crate::{
|
||||
LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
|
||||
LoggedSessionHistoryOrigin, Store, new_segment_id, new_session_id,
|
||||
};
|
||||
|
||||
fn annotated(item: agen::Item) -> LoggedHistoryEntry {
|
||||
LoggedHistoryEntry {
|
||||
item: LoggedItem::from(item),
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_layout_and_single_session_invariant() {
|
||||
@@ -405,6 +633,367 @@ mod tests {
|
||||
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_session_store_keeps_paste_artifacts_inside_retention_root() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = WorkerSessionStore::new(root.path().join("session")).unwrap();
|
||||
let session_id = new_session_id();
|
||||
store
|
||||
.create_segment(session_id, new_segment_id(), &[])
|
||||
.unwrap();
|
||||
let content = "large paste body\n終端\n";
|
||||
let reference = store
|
||||
.write_paste_artifact(
|
||||
session_id,
|
||||
"entry-1",
|
||||
content,
|
||||
PasteArtifactLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
root.path()
|
||||
.join(format!(
|
||||
"session/{PASTE_ARTIFACTS_DIR}/{}.json",
|
||||
reference.artifact_id
|
||||
))
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.read_paste_artifact(session_id, &reference.artifact_id)
|
||||
.unwrap()
|
||||
.1,
|
||||
content
|
||||
);
|
||||
assert!(matches!(
|
||||
store.read_paste_artifact(new_session_id(), &reference.artifact_id),
|
||||
Err(StoreError::Corrupt { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v1_logs_are_rewritten_and_promoted_to_v3() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let segment_id = new_segment_id();
|
||||
WorkerSessionStore::new(root.path())
|
||||
.unwrap()
|
||||
.create_segment(session_id, segment_id, &[])
|
||||
.unwrap();
|
||||
let manifest_path = root.path().join(SESSION_FILE);
|
||||
let mut manifest: SessionManifest =
|
||||
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
|
||||
manifest.schema_version = LEGACY_SESSION_SCHEMA_VERSION;
|
||||
atomic_write_json(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let reopened = WorkerSessionStore::new(root.path()).unwrap();
|
||||
assert_eq!(reopened.session_id().unwrap(), Some(session_id));
|
||||
let migrated: SessionManifest =
|
||||
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
|
||||
assert_eq!(migrated.schema_version, SESSION_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v1_migration_rejects_corrupt_log_before_v3_manifest_update() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let manifest = SessionManifest {
|
||||
schema_version: LEGACY_SESSION_SCHEMA_VERSION,
|
||||
session_id,
|
||||
};
|
||||
atomic_write_json(&root.path().join(SESSION_FILE), &manifest).unwrap();
|
||||
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
|
||||
fs::write(
|
||||
root.path().join(SEGMENTS_DIR).join("broken.jsonl"),
|
||||
"{not-json}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = match WorkerSessionStore::new(root.path()) {
|
||||
Ok(_) => panic!("corrupt legacy Session log must reject migration"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(matches!(error, StoreError::Corrupt { .. }));
|
||||
let persisted: SessionManifest =
|
||||
serde_json::from_slice(&fs::read(root.path().join(SESSION_FILE)).unwrap()).unwrap();
|
||||
assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v2_migration_rewrites_legacy_records_with_stable_unknown_provenance() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let segment_id = new_segment_id();
|
||||
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
|
||||
atomic_write_json(
|
||||
&root.path().join(SESSION_FILE),
|
||||
&SessionManifest {
|
||||
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
|
||||
session_id,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let source = vec![
|
||||
serde_json::json!({
|
||||
"kind": "segment_start",
|
||||
"ts": 1,
|
||||
"session_id": session_id,
|
||||
"system_prompt": null,
|
||||
"config": agen::llm_client::RequestConfig::default(),
|
||||
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
|
||||
"forked_from": null,
|
||||
"compacted_from": null
|
||||
}),
|
||||
serde_json::json!({
|
||||
"kind": "user_input",
|
||||
"ts": 2,
|
||||
"segments": [{ "kind": "text", "content": "hello" }],
|
||||
"extensions": []
|
||||
}),
|
||||
serde_json::json!({
|
||||
"kind": "assistant_item",
|
||||
"ts": 3,
|
||||
"item": LoggedItem::from(agen::Item::assistant_message("reply"))
|
||||
}),
|
||||
];
|
||||
let path = root
|
||||
.path()
|
||||
.join(SEGMENTS_DIR)
|
||||
.join(format!("{segment_id}.jsonl"));
|
||||
let mut bytes = Vec::new();
|
||||
for entry in source {
|
||||
serde_json::to_writer(&mut bytes, &entry).unwrap();
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
fs::write(&path, bytes).unwrap();
|
||||
|
||||
let store = WorkerSessionStore::new(root.path()).unwrap();
|
||||
let first = store.read_all(session_id, segment_id).unwrap();
|
||||
assert!(matches!(first[0], LogEntry::AnnotatedSegmentStart { .. }));
|
||||
assert!(matches!(first[1], LogEntry::AnnotatedUserInput { .. }));
|
||||
assert!(matches!(first[2], LogEntry::AnnotatedAssistantItem { .. }));
|
||||
let first_bytes = fs::read(&path).unwrap();
|
||||
drop(store);
|
||||
|
||||
let reopened = WorkerSessionStore::new(root.path()).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), first_bytes);
|
||||
let snapshot = crate::public_snapshot::project_current_session_snapshot(
|
||||
&reopened.read_all(session_id, segment_id).unwrap(),
|
||||
);
|
||||
assert_eq!(snapshot.entries.len(), 3);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.timestamp)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2, 3]
|
||||
);
|
||||
assert!(snapshot.entries.iter().all(|entry| {
|
||||
entry.provenance == protocol::SessionEntryProvenance::LegacyUnknown
|
||||
&& entry.entry_id.len() <= 64
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v2_preflight_keeps_earlier_segments_unchanged_when_later_is_corrupt() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let valid_segment = uuid::Uuid::from_u128(1);
|
||||
let corrupt_segment = uuid::Uuid::from_u128(2);
|
||||
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
|
||||
atomic_write_json(
|
||||
&root.path().join(SESSION_FILE),
|
||||
&SessionManifest {
|
||||
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
|
||||
session_id,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let manifest_before = fs::read(root.path().join(SESSION_FILE)).unwrap();
|
||||
|
||||
let valid_path = root
|
||||
.path()
|
||||
.join(SEGMENTS_DIR)
|
||||
.join(format!("{valid_segment}.jsonl"));
|
||||
let valid_entry = serde_json::json!({
|
||||
"kind": "segment_start",
|
||||
"ts": 1,
|
||||
"session_id": session_id,
|
||||
"system_prompt": null,
|
||||
"config": agen::llm_client::RequestConfig::default(),
|
||||
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
|
||||
"forked_from": null,
|
||||
"compacted_from": null
|
||||
});
|
||||
let mut valid_bytes = serde_json::to_vec(&valid_entry).unwrap();
|
||||
valid_bytes.push(b'\n');
|
||||
fs::write(&valid_path, &valid_bytes).unwrap();
|
||||
let corrupt_path = root
|
||||
.path()
|
||||
.join(SEGMENTS_DIR)
|
||||
.join(format!("{corrupt_segment}.jsonl"));
|
||||
fs::write(&corrupt_path, b"{not-json}\n").unwrap();
|
||||
let corrupt_before = fs::read(&corrupt_path).unwrap();
|
||||
|
||||
let error = match WorkerSessionStore::new(root.path()) {
|
||||
Ok(_) => panic!("later corrupt segment must fail migration preflight"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(matches!(error, StoreError::Corrupt { .. }));
|
||||
assert_eq!(fs::read(&valid_path).unwrap(), valid_bytes);
|
||||
assert_eq!(fs::read(&corrupt_path).unwrap(), corrupt_before);
|
||||
assert_eq!(
|
||||
fs::read(root.path().join(SESSION_FILE)).unwrap(),
|
||||
manifest_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_jsonl_requires_annotations_across_append_rewrite_and_reopen() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let segment_id = new_segment_id();
|
||||
let store = WorkerSessionStore::new(root.path()).unwrap();
|
||||
store
|
||||
.create_segment(
|
||||
session_id,
|
||||
segment_id,
|
||||
&[LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: agen::llm_client::RequestConfig::default(),
|
||||
history: vec![annotated(agen::Item::user_message("seed"))],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.append(
|
||||
session_id,
|
||||
segment_id,
|
||||
&LogEntry::AnnotatedAssistantItem {
|
||||
ts: 2,
|
||||
entry: annotated(agen::Item::assistant_message("reply")),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let before_rewrite = store.read_all(session_id, segment_id).unwrap();
|
||||
store
|
||||
.create_segment(session_id, segment_id, &before_rewrite)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
|
||||
let reopened = WorkerSessionStore::new(root.path()).unwrap();
|
||||
let restored = reopened.read_all(session_id, segment_id).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&restored).unwrap(),
|
||||
serde_json::to_value(&before_rewrite).unwrap()
|
||||
);
|
||||
for entry in &restored {
|
||||
match entry {
|
||||
LogEntry::AnnotatedSegmentStart { history, .. } => assert!(history.iter().all(
|
||||
|entry| !entry.metadata.entry_id.0.is_empty()
|
||||
&& matches!(
|
||||
entry.metadata.origin,
|
||||
LoggedSessionHistoryOrigin::LegacyUnknown
|
||||
)
|
||||
)),
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. } => {
|
||||
assert!(!entry.metadata.entry_id.0.is_empty());
|
||||
assert!(matches!(
|
||||
entry.metadata.origin,
|
||||
LoggedSessionHistoryOrigin::LegacyUnknown
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let log = fs::read_to_string(reopened.log_path(segment_id)).unwrap();
|
||||
for line in log.lines() {
|
||||
let value: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
let kind = value["kind"].as_str().unwrap();
|
||||
assert!(
|
||||
!matches!(
|
||||
kind,
|
||||
"segment_start"
|
||||
| "user_input"
|
||||
| "assistant_item"
|
||||
| "tool_result"
|
||||
| "system_item"
|
||||
),
|
||||
"current-schema JSONL contains legacy history record: {kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v3_rejects_legacy_records_and_new_writes_are_canonical() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let segment_id = new_segment_id();
|
||||
let store = WorkerSessionStore::new(root.path()).unwrap();
|
||||
store
|
||||
.create_segment(
|
||||
session_id,
|
||||
segment_id,
|
||||
&[LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: agen::llm_client::RequestConfig::default(),
|
||||
history: vec![annotated(agen::Item::assistant_message("seed"))],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.append(
|
||||
session_id,
|
||||
segment_id,
|
||||
&LogEntry::AnnotatedUserInput {
|
||||
ts: 2,
|
||||
segments: vec![protocol::Segment::Text {
|
||||
content: "new".into(),
|
||||
}],
|
||||
history: vec![annotated(agen::Item::user_message("new"))],
|
||||
extensions: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let entries = store.read_all(session_id, segment_id).unwrap();
|
||||
assert!(matches!(entries[0], LogEntry::AnnotatedSegmentStart { .. }));
|
||||
assert!(matches!(entries[1], LogEntry::AnnotatedUserInput { .. }));
|
||||
drop(store);
|
||||
|
||||
let path = root
|
||||
.path()
|
||||
.join(SEGMENTS_DIR)
|
||||
.join(format!("{segment_id}.jsonl"));
|
||||
let mut file = OpenOptions::new().append(true).open(path).unwrap();
|
||||
serde_json::to_writer(
|
||||
&mut file,
|
||||
&serde_json::json!({
|
||||
"kind": "system_item",
|
||||
"ts": 3,
|
||||
"item": { "kind": "legacy_ignored", "slug": "legacy" }
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
file.write_all(b"\n").unwrap();
|
||||
let error = match WorkerSessionStore::new(root.path()) {
|
||||
Ok(_) => panic!("schema v3 must reject a legacy history record"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(matches!(error, StoreError::Corrupt { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_preserves_session_and_segment_ids() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
use agen::EngineResult;
|
||||
use agen::llm_client::types::{Item, RequestConfig};
|
||||
use session_store::{
|
||||
FsStore, LogEntry, Store, TraceEntry, collect_state, new_segment_id, new_session_id,
|
||||
FsStore, LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
|
||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, Store, TraceEntry, collect_state,
|
||||
new_segment_id, new_session_id,
|
||||
};
|
||||
use std::io::Write;
|
||||
|
||||
fn annotated(item: Item) -> LoggedHistoryEntry {
|
||||
LoggedHistoryEntry {
|
||||
item: LoggedItem::from(item),
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn nil_session_start(ts: u64, session_id: uuid::Uuid) -> LogEntry {
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
@@ -25,7 +38,7 @@ fn round_trip_write_and_read() {
|
||||
let segid = new_segment_id();
|
||||
|
||||
let entries = vec![
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: sid,
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
@@ -34,14 +47,15 @@ fn round_trip_write_and_read() {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![protocol::Segment::text("Hello")],
|
||||
history: vec![annotated(Item::user_message("Hello"))],
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 3000,
|
||||
item: Item::assistant_message("Hi there!").into(),
|
||||
entry: annotated(Item::assistant_message("Hi there!")),
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 3100,
|
||||
@@ -51,6 +65,7 @@ fn round_trip_write_and_read() {
|
||||
ts: 3200,
|
||||
interrupted: false,
|
||||
result: EngineResult::Finished,
|
||||
active_run_turn_count: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -78,14 +93,14 @@ fn create_segment_writes_all_entries() {
|
||||
let sid = new_session_id();
|
||||
let segid = new_segment_id();
|
||||
|
||||
let entries = [LogEntry::SegmentStart {
|
||||
let entries = [LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: sid,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![
|
||||
Item::user_message("seed").into(),
|
||||
Item::assistant_message("ok").into(),
|
||||
annotated(Item::user_message("seed")),
|
||||
annotated(Item::assistant_message("ok")),
|
||||
],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
@@ -204,7 +219,7 @@ fn read_entry_count_matches_append_tally() {
|
||||
let segid = new_segment_id();
|
||||
|
||||
let entries = [
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: sid,
|
||||
system_prompt: None,
|
||||
@@ -213,10 +228,11 @@ fn read_entry_count_matches_append_tally() {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![protocol::Segment::text("Hello")],
|
||||
history: vec![annotated(Item::user_message("Hello"))],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -253,10 +269,11 @@ fn unterminated_utf8_tail_is_ignored_and_replaced_on_append() {
|
||||
assert_eq!(store.read_all(sid, segid).unwrap().len(), 1);
|
||||
assert_eq!(store.read_entry_count(sid, segid).unwrap(), 1);
|
||||
|
||||
let next = LogEntry::UserInput {
|
||||
let next = LogEntry::AnnotatedUserInput {
|
||||
ts: 2,
|
||||
extensions: vec![],
|
||||
segments: vec![protocol::Segment::text("recovered")],
|
||||
history: vec![annotated(Item::user_message("recovered"))],
|
||||
};
|
||||
store.append(sid, segid, &next).unwrap();
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
mod common;
|
||||
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::Engine;
|
||||
use agen::interceptor::{Interceptor, TurnEndAction};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::types::{Item, RequestConfig};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, History};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
||||
@@ -15,6 +16,21 @@ use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
fn annotated(items: &[Item]) -> Vec<session_store::LoggedHistoryEntry> {
|
||||
items
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|item| session_store::LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::from(item),
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn simple_text_events() -> Vec<Event> {
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
@@ -94,15 +110,47 @@ fn make_store() -> (tempfile::TempDir, FsStore) {
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
struct TestWorker {
|
||||
engine: Engine<MockLlmClient>,
|
||||
history: History,
|
||||
}
|
||||
|
||||
impl TestWorker {
|
||||
fn new(engine: Engine<MockLlmClient>) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
history: History::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn history(&self) -> Vec<Item> {
|
||||
self.history.items_cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TestWorker {
|
||||
type Target = Engine<MockLlmClient>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.engine
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for TestWorker {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.engine
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a worker turn and persist via session-store functions.
|
||||
/// Takes ownership of the worker (needed for lock/unlock) and returns it.
|
||||
async fn run_and_persist(
|
||||
worker: Engine<MockLlmClient>,
|
||||
mut worker: TestWorker,
|
||||
store: &FsStore,
|
||||
session_id: session_store::SessionId,
|
||||
segment_id: session_store::SegmentId,
|
||||
input: &str,
|
||||
) -> (Engine<MockLlmClient>, agen::EngineResult) {
|
||||
) -> (TestWorker, agen::EngineRunExit) {
|
||||
// Mirror Worker's run-entry contract: log the user input as segments
|
||||
// before the worker pushes its flattened user_message; save_delta
|
||||
// skips the resulting user_message item to avoid double-write.
|
||||
@@ -111,44 +159,65 @@ async fn run_and_persist(
|
||||
session_id,
|
||||
segment_id,
|
||||
vec![protocol::Segment::text(input)],
|
||||
annotated(&[Item::user_message(input)]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let history_before = worker.history().len();
|
||||
let history_before = worker.history.len();
|
||||
|
||||
let mut locked = worker.lock();
|
||||
let result = locked.run(input).await;
|
||||
let worker = locked.unlock();
|
||||
let mut locked = worker.engine.lock(&worker.history);
|
||||
let result = locked.run(&mut worker.history, input).await;
|
||||
worker.engine = locked.unlock();
|
||||
|
||||
let new_items = &worker.history()[history_before..];
|
||||
session_store::save_delta(store, session_id, segment_id, new_items).unwrap();
|
||||
let projected = worker.history();
|
||||
let new_items = annotated(&projected[history_before..]);
|
||||
session_store::save_delta(store, session_id, segment_id, &new_items).unwrap();
|
||||
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
|
||||
|
||||
match &result {
|
||||
Ok(r) => {
|
||||
agen::EngineRunExit::Finished
|
||||
| agen::EngineRunExit::Paused
|
||||
| agen::EngineRunExit::Yielded => {
|
||||
let (legacy_result, interrupted) = match &result {
|
||||
agen::EngineRunExit::Finished => (agen::EngineResult::Finished, false),
|
||||
agen::EngineRunExit::Paused => (agen::EngineResult::Paused, true),
|
||||
agen::EngineRunExit::Yielded => (agen::EngineResult::Yielded, true),
|
||||
agen::EngineRunExit::Interrupted(_) => unreachable!(),
|
||||
};
|
||||
session_store::save_run_completed(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
r.clone(),
|
||||
worker.last_run_interrupted(),
|
||||
legacy_result,
|
||||
interrupted,
|
||||
worker.active_run_turn_count(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
agen::EngineRunExit::Interrupted(agen::StopReason::LimitReached) => {
|
||||
session_store::save_run_completed(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
agen::EngineResult::LimitReached,
|
||||
false,
|
||||
worker.active_run_turn_count(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
agen::EngineRunExit::Interrupted(reason) => {
|
||||
session_store::save_run_errored(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
e.to_string(),
|
||||
worker.last_run_interrupted(),
|
||||
format!("{reason:?}"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let r = result.unwrap();
|
||||
(worker, r)
|
||||
(worker, result)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -159,14 +228,14 @@ async fn run_and_persist(
|
||||
async fn session_run_logs_entries() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let worker = Engine::new(client);
|
||||
let worker = TestWorker::new(Engine::new(client));
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -184,7 +253,10 @@ async fn session_run_logs_entries() {
|
||||
);
|
||||
|
||||
// First entry is SegmentStart
|
||||
assert!(matches!(&entries[0], LogEntry::SegmentStart { .. }));
|
||||
assert!(matches!(
|
||||
&entries[0],
|
||||
LogEntry::AnnotatedSegmentStart { .. }
|
||||
));
|
||||
|
||||
// Has a RunCompleted with Finished
|
||||
let has_finished = entries.iter().any(|e| {
|
||||
@@ -203,7 +275,7 @@ async fn session_run_logs_entries() {
|
||||
async fn session_restore_round_trip() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
worker.set_system_prompt("You are helpful.");
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
@@ -211,7 +283,7 @@ async fn session_restore_round_trip() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -242,7 +314,7 @@ async fn session_restore_round_trip() {
|
||||
async fn session_run_with_tool_call() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::with_responses(tool_call_events());
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
worker.register_tool(weather_tool_definition());
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
@@ -250,7 +322,7 @@ async fn session_run_with_tool_call() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -261,12 +333,12 @@ async fn session_run_with_tool_call() {
|
||||
|
||||
let has_tool_results = entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, LogEntry::ToolResult { .. }));
|
||||
.any(|e| matches!(e, LogEntry::AnnotatedToolResult { .. }));
|
||||
assert!(has_tool_results, "should have ToolResult entry");
|
||||
|
||||
let has_assistant = entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, LogEntry::AssistantItem { .. }));
|
||||
.any(|e| matches!(e, LogEntry::AnnotatedAssistantItem { .. }));
|
||||
assert!(has_assistant, "should have AssistantItem entry");
|
||||
}
|
||||
|
||||
@@ -276,7 +348,7 @@ async fn session_resume_after_pause() {
|
||||
|
||||
// First run: tool call with pause policy → Paused
|
||||
let client = MockLlmClient::with_responses(tool_call_events());
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
worker.register_tool(weather_tool_definition());
|
||||
worker.set_interceptor(PausePolicy);
|
||||
|
||||
@@ -285,13 +357,13 @@ async fn session_resume_after_pause() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (_worker, result) = run_and_persist(worker, &store, sid, segid, "Weather?").await;
|
||||
assert!(matches!(result, agen::EngineResult::Paused));
|
||||
assert!(matches!(result, agen::EngineRunExit::Paused));
|
||||
|
||||
// Check RunCompleted is Paused
|
||||
let entries = store.read_all(sid, segid).unwrap();
|
||||
@@ -309,13 +381,14 @@ async fn session_resume_after_pause() {
|
||||
// Restore state and verify
|
||||
let state = session_store::restore(&store, sid, segid).unwrap();
|
||||
assert!(state.last_run_interrupted);
|
||||
assert_eq!(state.active_run_turn_count, Some(2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_fork_creates_new_session() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
worker.set_system_prompt("System prompt");
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
@@ -323,7 +396,7 @@ async fn session_fork_creates_new_session() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -336,7 +409,7 @@ async fn session_fork_creates_new_session() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -345,7 +418,10 @@ async fn session_fork_creates_new_session() {
|
||||
// Fork should have a SegmentStart with the current history
|
||||
let fork_entries = store.read_all(fork_sid, fork_segid).unwrap();
|
||||
assert_eq!(fork_entries.len(), 1);
|
||||
assert!(matches!(&fork_entries[0], LogEntry::SegmentStart { .. }));
|
||||
assert!(matches!(
|
||||
&fork_entries[0],
|
||||
LogEntry::AnnotatedSegmentStart { .. }
|
||||
));
|
||||
|
||||
let fork_state = collect_state(&fork_entries);
|
||||
assert_eq!(fork_state.session_id, Some(fork_sid));
|
||||
@@ -357,14 +433,14 @@ async fn session_fork_creates_new_session() {
|
||||
async fn session_fork_at_truncates_within_session() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let worker = Engine::new(client);
|
||||
let worker = TestWorker::new(Engine::new(client));
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -391,6 +467,23 @@ async fn session_fork_at_truncates_within_session() {
|
||||
.expect("source segment has the matching TurnEnd");
|
||||
let source_state_at_fork = collect_state(&all_entries[..=turn_end_pos]);
|
||||
assert_eq!(fork_state.history.len(), source_state_at_fork.history.len());
|
||||
assert_eq!(
|
||||
fork_state.annotated_history, source_state_at_fork.annotated_history,
|
||||
"fork_at must preserve every retained history entry identity and provenance",
|
||||
);
|
||||
assert!(fork_state.annotated_history.iter().all(|entry| {
|
||||
!entry.metadata.entry_id.0.is_empty()
|
||||
&& matches!(
|
||||
entry.metadata.origin,
|
||||
session_store::LoggedSessionHistoryOrigin::LegacyUnknown
|
||||
| session_store::LoggedSessionHistoryOrigin::HumanInput { .. }
|
||||
| session_store::LoggedSessionHistoryOrigin::WorkerInput { .. }
|
||||
| session_store::LoggedSessionHistoryOrigin::BackendInstruction { .. }
|
||||
| session_store::LoggedSessionHistoryOrigin::ModelOutput { .. }
|
||||
| session_store::LoggedSessionHistoryOrigin::ToolOutput { .. }
|
||||
| session_store::LoggedSessionHistoryOrigin::DerivedSummary
|
||||
)
|
||||
}));
|
||||
|
||||
// list_segments should show both source and fork in the same Session.
|
||||
let segs = store.list_segments(sid).unwrap();
|
||||
@@ -402,14 +495,14 @@ async fn session_fork_at_truncates_within_session() {
|
||||
async fn session_config_changed_logged() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -435,14 +528,14 @@ async fn session_auto_forks_on_conflict() {
|
||||
|
||||
// Create a segment
|
||||
let client_a = MockLlmClient::new(simple_text_events());
|
||||
let worker_a = Engine::new(client_a);
|
||||
let worker_a = TestWorker::new(Engine::new(client_a));
|
||||
|
||||
let (sid, original_segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker_a.get_system_prompt(),
|
||||
config: worker_a.request_config(),
|
||||
history: worker_a.history(),
|
||||
history: annotated(&worker_a.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -451,12 +544,14 @@ async fn session_auto_forks_on_conflict() {
|
||||
let mut entries_written: usize = 1;
|
||||
|
||||
// Simulate another Worker writing to the same segment behind our back.
|
||||
let extra_entry = LogEntry::UserInput {
|
||||
ts: 9999,
|
||||
extensions: vec![],
|
||||
segments: vec![protocol::Segment::text("Interloper")],
|
||||
};
|
||||
store.append(sid, original_segid, &extra_entry).unwrap();
|
||||
session_store::save_user_input(
|
||||
&store,
|
||||
sid,
|
||||
original_segid,
|
||||
vec![protocol::Segment::text("Interloper")],
|
||||
annotated(&[Item::user_message("Interloper")]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Now the on-disk count exceeds our tally — ensure_head_or_fork should auto-fork.
|
||||
session_store::ensure_head_or_fork(
|
||||
@@ -468,7 +563,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker_a.get_system_prompt(),
|
||||
config: worker_a.request_config(),
|
||||
history: worker_a.history(),
|
||||
history: annotated(&worker_a.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -489,7 +584,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
// The new segment records its lineage forward via forked_from; the
|
||||
// source segment is left immutable (no terminal marker written back).
|
||||
match &fork_entries[0] {
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
forked_from: Some(origin),
|
||||
..
|
||||
} => {
|
||||
@@ -509,7 +604,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
);
|
||||
let has_interloper = original_entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, LogEntry::UserInput { .. }));
|
||||
.any(|e| matches!(e, LogEntry::AnnotatedUserInput { .. }));
|
||||
assert!(has_interloper);
|
||||
}
|
||||
|
||||
@@ -520,14 +615,14 @@ async fn session_auto_forks_on_conflict() {
|
||||
async fn nested_past_fork_leaves_ancestors_immutable() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let worker = Engine::new(client);
|
||||
let worker = TestWorker::new(Engine::new(client));
|
||||
|
||||
let (sid, root_segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -564,7 +659,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
|
||||
|
||||
// fork2's lineage points at fork1, not the root.
|
||||
match &store.read_all(sid, fork2).unwrap()[0] {
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
forked_from: Some(origin),
|
||||
..
|
||||
} => assert_eq!(origin.segment_id, fork1),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "standalone"
|
||||
description = "In-process standalone Worker host"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
agen.workspace = true
|
||||
client.workspace = true
|
||||
fs4.workspace = true
|
||||
manifest.workspace = true
|
||||
protocol.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
session-store.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "sync", "time"] }
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
worker.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait.workspace = true
|
||||
futures.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
|
||||
@@ -0,0 +1,544 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::llm_client::client::LlmClient;
|
||||
use client::Client;
|
||||
use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket};
|
||||
use manifest::ScopeRule;
|
||||
use protocol::stream::{decode_method, encode_event};
|
||||
use protocol::{Event, Method, WorkerId};
|
||||
use session_store::{
|
||||
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use worker::bootstrap::{
|
||||
WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id,
|
||||
};
|
||||
use worker::controller::WorkerControllerTransport;
|
||||
use worker::ipc::protocol_session::{
|
||||
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
|
||||
subscribe_worker_protocol_session,
|
||||
};
|
||||
use worker::runtime::worker_allocation::ScopeLockError;
|
||||
use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
|
||||
|
||||
use crate::launch::ResolvedStandaloneLaunch;
|
||||
use crate::store::{
|
||||
StaleLeasePolicy, StandaloneShutdownReason, StandaloneStoreError, StandaloneWorkerLease,
|
||||
StandaloneWorkerRecord, StandaloneWorkerStore,
|
||||
};
|
||||
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
type StandaloneBackingStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||
|
||||
/// One client-owned top-level Worker and its standalone Worker authority.
|
||||
///
|
||||
/// The host deliberately exposes the existing typed Worker protocol rather than owning an
|
||||
/// HTTP/WebSocket server or creating Runtime/Workspace/Ticket/Workdir domain records.
|
||||
pub struct StandaloneHost {
|
||||
handle: worker::WorkerHandle,
|
||||
shutdown: Option<worker::controller::ShutdownReceiver>,
|
||||
shutdown_timeout: Duration,
|
||||
store: StandaloneWorkerStore,
|
||||
worker_store: FsWorkerStore,
|
||||
record: StandaloneWorkerRecord,
|
||||
lease: Option<StandaloneWorkerLease>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum StandaloneStartupError {
|
||||
#[error("the standalone state store could not be opened or validated")]
|
||||
StateStore,
|
||||
#[error("the standalone Worker is already active")]
|
||||
WorkerActive,
|
||||
#[error("the standalone Worker lease cannot be observed safely; recovery is rejected")]
|
||||
LeaseLivenessUnknown,
|
||||
#[error("the standalone Worker working directory is unavailable or changed")]
|
||||
WorkingDirectoryUnavailable,
|
||||
#[error(
|
||||
"requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`",
|
||||
requested_rule.target.display(),
|
||||
competitor_rule.target.display()
|
||||
)]
|
||||
ScopeConflict {
|
||||
competitor: String,
|
||||
requested_rule: ScopeRule,
|
||||
competitor_rule: ScopeRule,
|
||||
},
|
||||
#[error("the resolved Worker configuration or persisted history is invalid")]
|
||||
WorkerConfiguration,
|
||||
#[error("the configured model provider is unavailable")]
|
||||
ModelProvider,
|
||||
#[error("the fixed standalone feature composition could not be installed")]
|
||||
FeatureComposition,
|
||||
#[error("the in-process Worker controller could not start")]
|
||||
Controller,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum StandaloneShutdownError {
|
||||
#[error("the standalone Worker did not stop before the shutdown deadline")]
|
||||
DeadlineExceeded,
|
||||
#[error("the standalone Worker shutdown confirmation was lost")]
|
||||
ConfirmationLost,
|
||||
#[error("the standalone Worker final state could not be committed")]
|
||||
StateStore,
|
||||
}
|
||||
|
||||
impl StandaloneHost {
|
||||
pub async fn start(launch: ResolvedStandaloneLaunch) -> Result<Self, StandaloneStartupError> {
|
||||
Self::start_with_optional_model_client(launch, None).await
|
||||
}
|
||||
|
||||
pub async fn start_with_model_client<C>(
|
||||
launch: ResolvedStandaloneLaunch,
|
||||
model_client: C,
|
||||
) -> Result<Self, StandaloneStartupError>
|
||||
where
|
||||
C: LlmClient + 'static,
|
||||
{
|
||||
Self::start_with_optional_model_client(launch, Some(Box::new(model_client))).await
|
||||
}
|
||||
|
||||
async fn start_with_optional_model_client(
|
||||
launch: ResolvedStandaloneLaunch,
|
||||
model_client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<Self, StandaloneStartupError> {
|
||||
let store =
|
||||
StandaloneWorkerStore::open(&launch.state_dir).map_err(classify_store_startup_error)?;
|
||||
let allocation = store
|
||||
.allocate(&launch.cwd, StaleLeasePolicy::Reject)
|
||||
.map_err(classify_store_startup_error)?;
|
||||
let worker_id = allocation.worker_id();
|
||||
|
||||
// WorkerId is the stable identity. The current Worker store remains
|
||||
// name-keyed, so keep its derived storage key separate from the
|
||||
// user-facing profile name.
|
||||
let manifest = launch.profile.manifest.clone();
|
||||
let storage_key = format!("standalone-{worker_id}");
|
||||
let mut bootstrap_manifest = manifest.clone();
|
||||
bootstrap_manifest.worker.name = storage_key.clone();
|
||||
let (backing_store, worker_store) = match backing_store(&store, worker_id) {
|
||||
Ok(stores) => stores,
|
||||
Err(error) => {
|
||||
let _ = store.abandon_allocation(allocation);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let filesystem_authority =
|
||||
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
|
||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||
let runtime_base = store.runtime_dir(worker_id);
|
||||
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
||||
|
||||
let mut bootstrap = WorkerBootstrap::new(
|
||||
bootstrap_manifest,
|
||||
backing_store,
|
||||
launch.prompt_catalog,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
);
|
||||
if let Some(model_client) = model_client {
|
||||
bootstrap = bootstrap.with_model_client(model_client);
|
||||
}
|
||||
let started = match bootstrap.start().await {
|
||||
Ok(started) => started,
|
||||
Err(error) => {
|
||||
let _ = store.abandon_allocation(allocation);
|
||||
return Err(classify_startup_error(error));
|
||||
}
|
||||
};
|
||||
let active = match active_pointer(&worker_store, &storage_key) {
|
||||
Ok(active) => active,
|
||||
Err(error) => {
|
||||
stop_started_worker(started).await;
|
||||
let _ = store.abandon_allocation(allocation);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let record = match store.commit_created(
|
||||
&allocation,
|
||||
manifest,
|
||||
storage_key,
|
||||
active.session_id,
|
||||
active.segment_id,
|
||||
) {
|
||||
Ok(record) => record,
|
||||
Err(_) => {
|
||||
stop_started_worker(started).await;
|
||||
let _ = store.abandon_allocation(allocation);
|
||||
return Err(StandaloneStartupError::StateStore);
|
||||
}
|
||||
};
|
||||
Ok(Self::from_started(
|
||||
started,
|
||||
store,
|
||||
worker_store,
|
||||
record,
|
||||
allocation.into_lease(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn restore(
|
||||
state_dir: PathBuf,
|
||||
worker_id: WorkerId,
|
||||
) -> Result<Self, StandaloneStartupError> {
|
||||
Self::restore_with_optional_model_client(state_dir, worker_id, None).await
|
||||
}
|
||||
|
||||
pub async fn restore_with_model_client<C>(
|
||||
state_dir: PathBuf,
|
||||
worker_id: WorkerId,
|
||||
model_client: C,
|
||||
) -> Result<Self, StandaloneStartupError>
|
||||
where
|
||||
C: LlmClient + 'static,
|
||||
{
|
||||
Self::restore_with_optional_model_client(state_dir, worker_id, Some(Box::new(model_client)))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restore_with_optional_model_client(
|
||||
state_dir: PathBuf,
|
||||
worker_id: WorkerId,
|
||||
model_client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<Self, StandaloneStartupError> {
|
||||
let store = StandaloneWorkerStore::open(state_dir).map_err(classify_store_startup_error)?;
|
||||
let record = store
|
||||
.load(worker_id)
|
||||
.map_err(classify_store_startup_error)?;
|
||||
record.cwd.verify().map_err(classify_store_startup_error)?;
|
||||
let lease = store
|
||||
.acquire_lease(worker_id, StaleLeasePolicy::Recover)
|
||||
.map_err(classify_store_startup_error)?;
|
||||
let (backing_store, worker_store) = backing_store(&store, worker_id)?;
|
||||
let storage_key = record.storage_key.clone();
|
||||
let mut manifest = record.manifest.clone();
|
||||
manifest.worker.name = storage_key.clone();
|
||||
let filesystem_authority = WorkerFilesystemAuthority::local(
|
||||
record.cwd.canonical_path.clone(),
|
||||
record.cwd.canonical_path.clone(),
|
||||
);
|
||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||
let runtime_base = store.runtime_dir(worker_id);
|
||||
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
||||
|
||||
let mut bootstrap = WorkerBootstrap::new(
|
||||
manifest,
|
||||
backing_store,
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
);
|
||||
if let Some(model_client) = model_client {
|
||||
bootstrap = bootstrap.with_model_client(model_client);
|
||||
}
|
||||
let prepared = bootstrap
|
||||
.prepare_restored(&storage_key)
|
||||
.await
|
||||
.map_err(classify_startup_error)?;
|
||||
let started = prepared.start().await.map_err(classify_startup_error)?;
|
||||
let active = match active_pointer(&worker_store, &storage_key) {
|
||||
Ok(active) => active,
|
||||
Err(error) => {
|
||||
stop_started_worker(started).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let record =
|
||||
match store.update_active_pointer(&record, active.session_id, active.segment_id) {
|
||||
Ok(record) => record,
|
||||
Err(_) => {
|
||||
stop_started_worker(started).await;
|
||||
lease.retain();
|
||||
return Err(StandaloneStartupError::StateStore);
|
||||
}
|
||||
};
|
||||
Ok(Self::from_started(
|
||||
started,
|
||||
store,
|
||||
worker_store,
|
||||
record,
|
||||
lease,
|
||||
))
|
||||
}
|
||||
|
||||
fn from_started(
|
||||
started: BootstrappedWorker,
|
||||
store: StandaloneWorkerStore,
|
||||
worker_store: FsWorkerStore,
|
||||
record: StandaloneWorkerRecord,
|
||||
lease: StandaloneWorkerLease,
|
||||
) -> Self {
|
||||
Self {
|
||||
handle: started.handle,
|
||||
shutdown: Some(started.shutdown),
|
||||
shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
|
||||
store,
|
||||
worker_store,
|
||||
record,
|
||||
lease: Some(lease),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn worker_id(&self) -> WorkerId {
|
||||
self.record.worker_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn record(&self) -> &StandaloneWorkerRecord {
|
||||
&self.record
|
||||
}
|
||||
|
||||
/// Open one complete client-side Worker protocol session.
|
||||
///
|
||||
/// Working events, committed session entries, alert snapshots, and the
|
||||
/// initial history snapshot are merged behind the client boundary.
|
||||
pub fn connect(&self) -> Client<InProcessSocket> {
|
||||
let streams = subscribe_worker_protocol_session(&self.handle);
|
||||
let (socket, peer) = InProcessSocket::pair();
|
||||
tokio::spawn(run_protocol_session(self.handle.clone(), streams, peer));
|
||||
Client::new(socket)
|
||||
}
|
||||
|
||||
pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self {
|
||||
self.shutdown_timeout = shutdown_timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
|
||||
let _ = self.handle.send(Method::Shutdown).await;
|
||||
let Some(shutdown) = self.shutdown.take() else {
|
||||
self.retain_lease();
|
||||
return Err(StandaloneShutdownError::ConfirmationLost);
|
||||
};
|
||||
match tokio::time::timeout(self.shutdown_timeout, shutdown).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(_)) => {
|
||||
self.retain_lease();
|
||||
return Err(StandaloneShutdownError::ConfirmationLost);
|
||||
}
|
||||
Err(_) => {
|
||||
self.retain_lease();
|
||||
return Err(StandaloneShutdownError::DeadlineExceeded);
|
||||
}
|
||||
}
|
||||
let active = match active_pointer(&self.worker_store, &self.record.storage_key) {
|
||||
Ok(active) => active,
|
||||
Err(_) => {
|
||||
self.retain_lease();
|
||||
return Err(StandaloneShutdownError::StateStore);
|
||||
}
|
||||
};
|
||||
if self
|
||||
.store
|
||||
.mark_stopped(
|
||||
&self.record,
|
||||
active.session_id,
|
||||
active.segment_id,
|
||||
StandaloneShutdownReason::UserExit,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
self.retain_lease();
|
||||
return Err(StandaloneShutdownError::StateStore);
|
||||
}
|
||||
if let Some(lease) = self.lease.take() {
|
||||
lease
|
||||
.release()
|
||||
.map_err(|_| StandaloneShutdownError::StateStore)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retain_lease(&mut self) {
|
||||
if let Some(lease) = self.lease.take() {
|
||||
lease.retain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_protocol_session(
|
||||
handle: worker::WorkerHandle,
|
||||
streams: WorkerProtocolSessionStreams,
|
||||
mut peer: InProcessPeer,
|
||||
) {
|
||||
let WorkerProtocolSessionStreams {
|
||||
snapshot_event,
|
||||
mut log_entries,
|
||||
alert_snapshot,
|
||||
mut events,
|
||||
} = streams;
|
||||
|
||||
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = peer.next() => {
|
||||
let Some(message) = message else {
|
||||
return;
|
||||
};
|
||||
let Ok(method) = decode_method(&message) else {
|
||||
return;
|
||||
};
|
||||
if let Some(event) = dispatch_worker_protocol_method(&handle, method).await
|
||||
&& !send_protocol_event(&peer, event).await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
event = events.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if !send_protocol_event(&peer, event).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
let replacement = subscribe_worker_protocol_session(&handle);
|
||||
let WorkerProtocolSessionStreams {
|
||||
snapshot_event,
|
||||
log_entries: replacement_log_entries,
|
||||
alert_snapshot,
|
||||
events: replacement_events,
|
||||
} = replacement;
|
||||
log_entries = replacement_log_entries;
|
||||
events = replacement_events;
|
||||
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
entry = log_entries.recv() => {
|
||||
match entry {
|
||||
Ok(entry) => {
|
||||
if let Some(event) = live_log_entry_event(entry)
|
||||
&& !send_protocol_event(&peer, event).await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
let replacement = subscribe_worker_protocol_session(&handle);
|
||||
let WorkerProtocolSessionStreams {
|
||||
snapshot_event,
|
||||
log_entries: replacement_log_entries,
|
||||
alert_snapshot,
|
||||
events: replacement_events,
|
||||
} = replacement;
|
||||
log_entries = replacement_log_entries;
|
||||
events = replacement_events;
|
||||
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_protocol_snapshot(
|
||||
peer: &InProcessPeer,
|
||||
alert_snapshot: Vec<protocol::Alert>,
|
||||
snapshot_event: Event,
|
||||
) -> bool {
|
||||
for alert in alert_snapshot {
|
||||
if !send_protocol_event(peer, Event::Alert(alert)).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
send_protocol_event(peer, snapshot_event).await
|
||||
}
|
||||
|
||||
async fn send_protocol_event(peer: &InProcessPeer, event: Event) -> bool {
|
||||
let Ok(message) = encode_event(&event) else {
|
||||
return false;
|
||||
};
|
||||
peer.send(message).await.is_ok()
|
||||
}
|
||||
|
||||
fn backing_store(
|
||||
store: &StandaloneWorkerStore,
|
||||
worker_id: WorkerId,
|
||||
) -> Result<(StandaloneBackingStore, FsWorkerStore), StandaloneStartupError> {
|
||||
let session_store = FsStore::new(store.sessions_dir(worker_id))
|
||||
.map_err(|_| StandaloneStartupError::StateStore)?;
|
||||
let worker_store = FsWorkerStore::new(store.worker_metadata_dir(worker_id))
|
||||
.map_err(|_| StandaloneStartupError::StateStore)?;
|
||||
Ok((
|
||||
CombinedStore::new(session_store, worker_store.clone()),
|
||||
worker_store,
|
||||
))
|
||||
}
|
||||
|
||||
fn active_pointer(
|
||||
worker_store: &FsWorkerStore,
|
||||
storage_key: &str,
|
||||
) -> Result<WorkerActiveSegmentRef, StandaloneStartupError> {
|
||||
worker_store
|
||||
.read_by_name(storage_key)
|
||||
.map_err(|_| StandaloneStartupError::StateStore)?
|
||||
.and_then(|metadata| metadata.active)
|
||||
.ok_or(StandaloneStartupError::StateStore)
|
||||
}
|
||||
|
||||
async fn stop_started_worker(started: BootstrappedWorker) {
|
||||
let _ = started.handle.send(Method::Shutdown).await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
|
||||
}
|
||||
|
||||
fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartupError {
|
||||
match error {
|
||||
StandaloneStoreError::WorkerLeased(_) => StandaloneStartupError::WorkerActive,
|
||||
StandaloneStoreError::LeaseLivenessUnknown(_) => {
|
||||
StandaloneStartupError::LeaseLivenessUnknown
|
||||
}
|
||||
StandaloneStoreError::CwdUnavailable(_)
|
||||
| StandaloneStoreError::CwdNotDirectory
|
||||
| StandaloneStoreError::CwdIdentityMismatch => {
|
||||
StandaloneStartupError::WorkingDirectoryUnavailable
|
||||
}
|
||||
_ => StandaloneStartupError::StateStore,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError {
|
||||
match error {
|
||||
WorkerBootstrapError::Worker(WorkerError::ScopeLock(ScopeLockError::WriteConflict {
|
||||
competitor,
|
||||
rule,
|
||||
competitor_rule,
|
||||
})) => StandaloneStartupError::ScopeConflict {
|
||||
competitor,
|
||||
requested_rule: rule,
|
||||
competitor_rule,
|
||||
},
|
||||
WorkerBootstrapError::Worker(WorkerError::Provider(_)) => {
|
||||
StandaloneStartupError::ModelProvider
|
||||
}
|
||||
WorkerBootstrapError::Worker(_) => StandaloneStartupError::WorkerConfiguration,
|
||||
WorkerBootstrapError::Controller { source, .. }
|
||||
if source.kind() == std::io::ErrorKind::Other =>
|
||||
{
|
||||
StandaloneStartupError::FeatureComposition
|
||||
}
|
||||
WorkerBootstrapError::Controller { .. } => StandaloneStartupError::Controller,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use manifest::{
|
||||
ProfileExecutionTarget, ProfileResolveOptions, ProfileResolver, ProfileSelector,
|
||||
ResolvedProfile,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use worker::PromptCatalogSource;
|
||||
|
||||
/// Process launch input resolved before any Worker/session side effect occurs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StandaloneLaunchConfig {
|
||||
pub cwd: PathBuf,
|
||||
pub state_dir: PathBuf,
|
||||
pub profile: ProfileSelector,
|
||||
pub worker_name: String,
|
||||
}
|
||||
|
||||
pub struct ResolvedStandaloneLaunch {
|
||||
pub cwd: PathBuf,
|
||||
pub state_dir: PathBuf,
|
||||
pub profile: ResolvedProfile,
|
||||
pub prompt_catalog: PromptCatalogSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum StandaloneLaunchError {
|
||||
#[error("the standalone working directory is unavailable")]
|
||||
WorkingDirectoryUnavailable,
|
||||
#[error("path-based profiles are not standalone launch authority")]
|
||||
PathProfileUnsupported,
|
||||
#[error("the standalone profile could not be resolved")]
|
||||
ProfileResolutionFailed,
|
||||
}
|
||||
|
||||
impl StandaloneLaunchConfig {
|
||||
pub fn new(
|
||||
cwd: impl Into<PathBuf>,
|
||||
state_dir: impl Into<PathBuf>,
|
||||
profile: ProfileSelector,
|
||||
worker_name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cwd: cwd.into(),
|
||||
state_dir: state_dir.into(),
|
||||
profile,
|
||||
worker_name: worker_name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve only built-in/XDG profile authority and bind standalone scope
|
||||
/// to the canonical process cwd. Repository-local profile discovery is
|
||||
/// deliberately not part of this path.
|
||||
pub fn resolve(self) -> Result<ResolvedStandaloneLaunch, StandaloneLaunchError> {
|
||||
if matches!(self.profile, ProfileSelector::Path { .. }) {
|
||||
return Err(StandaloneLaunchError::PathProfileUnsupported);
|
||||
}
|
||||
let cwd = canonical_directory(&self.cwd)?;
|
||||
let profile = ProfileResolver::new()
|
||||
.with_workspace_base(&cwd)
|
||||
.resolve_for_target(
|
||||
&self.profile,
|
||||
ProfileResolveOptions {
|
||||
worker_name: Some(self.worker_name),
|
||||
},
|
||||
ProfileExecutionTarget::Standalone,
|
||||
)
|
||||
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
|
||||
|
||||
Ok(ResolvedStandaloneLaunch {
|
||||
cwd,
|
||||
state_dir: self.state_dir,
|
||||
profile,
|
||||
prompt_catalog: PromptCatalogSource::builtins_only(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_directory(path: &Path) -> Result<PathBuf, StandaloneLaunchError> {
|
||||
let path = std::fs::canonicalize(path)
|
||||
.map_err(|_| StandaloneLaunchError::WorkingDirectoryUnavailable)?;
|
||||
if !path.is_dir() {
|
||||
return Err(StandaloneLaunchError::WorkingDirectoryUnavailable);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! In-process standalone host for one top-level Yoi Worker.
|
||||
//!
|
||||
//! The crate composes existing `worker`, `manifest`, `session-store`, and
|
||||
//! `workdir` contracts. It intentionally owns no TUI, Runtime, Workspace
|
||||
//! Server, HTTP, WebSocket, subprocess Worker, or alternative execution path.
|
||||
|
||||
pub mod host;
|
||||
pub mod launch;
|
||||
pub mod store;
|
||||
|
||||
pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError};
|
||||
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
|
||||
pub use protocol::WorkerId;
|
||||
pub use store::{
|
||||
StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneShutdownReason,
|
||||
StandaloneStoreError, StandaloneWorkerRecord, StandaloneWorkerStatus, StandaloneWorkerStore,
|
||||
};
|
||||
@@ -0,0 +1,741 @@
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use fs4::fs_std::FileExt;
|
||||
use manifest::WorkerManifest;
|
||||
use protocol::WorkerId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::{SegmentId, SessionId};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
const RECORD_FILE: &str = "record.json";
|
||||
const COMMIT_MARKER: &str = "commit.pending";
|
||||
const LEASE_FILE: &str = "lease.json";
|
||||
const LEASE_LOCK_FILE: &str = "lease.lock";
|
||||
const SESSIONS_DIR: &str = "sessions";
|
||||
const WORKER_DIR: &str = "worker";
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StandaloneCwdIdentity {
|
||||
pub canonical_path: PathBuf,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub device: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub inode: Option<u64>,
|
||||
}
|
||||
|
||||
impl StandaloneCwdIdentity {
|
||||
pub fn capture(path: impl AsRef<Path>) -> Result<Self, StandaloneStoreError> {
|
||||
let canonical_path =
|
||||
fs::canonicalize(path).map_err(StandaloneStoreError::CwdUnavailable)?;
|
||||
let metadata =
|
||||
fs::metadata(&canonical_path).map_err(StandaloneStoreError::CwdUnavailable)?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(StandaloneStoreError::CwdNotDirectory);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
let (device, inode) = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
(Some(metadata.dev()), Some(metadata.ino()))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let (device, inode) = (None, None);
|
||||
Ok(Self {
|
||||
canonical_path,
|
||||
device,
|
||||
inode,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify(&self) -> Result<PathBuf, StandaloneStoreError> {
|
||||
let current = Self::capture(&self.canonical_path)?;
|
||||
if current != *self {
|
||||
return Err(StandaloneStoreError::CwdIdentityMismatch);
|
||||
}
|
||||
Ok(current.canonical_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StandaloneWorkerStatus {
|
||||
Active,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StandaloneShutdownReason {
|
||||
UserExit,
|
||||
StartupFailed,
|
||||
ControllerError,
|
||||
ProcessInterrupted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StandaloneWorkerRecord {
|
||||
pub schema_version: u32,
|
||||
pub revision: u64,
|
||||
pub worker_id: WorkerId,
|
||||
/// User-facing Worker name resolved from the profile.
|
||||
pub worker_name: String,
|
||||
/// Internal key used by the current name-keyed Worker store.
|
||||
pub storage_key: String,
|
||||
pub cwd: StandaloneCwdIdentity,
|
||||
pub manifest: WorkerManifest,
|
||||
pub active_session_id: SessionId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_segment_id: Option<SegmentId>,
|
||||
pub status: StandaloneWorkerStatus,
|
||||
pub created_at_unix_ms: u64,
|
||||
pub updated_at_unix_ms: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shutdown_reason: Option<StandaloneShutdownReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StandaloneListScope {
|
||||
CurrentCwd,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StaleLeasePolicy {
|
||||
Reject,
|
||||
Recover,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StandaloneWorkerStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl StandaloneWorkerStore {
|
||||
pub fn open(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> {
|
||||
let root = root.into();
|
||||
fs::create_dir_all(&root).map_err(StandaloneStoreError::Io)?;
|
||||
if !fs::metadata(&root)
|
||||
.map_err(StandaloneStoreError::Io)?
|
||||
.is_dir()
|
||||
{
|
||||
return Err(StandaloneStoreError::NotDirectory);
|
||||
}
|
||||
Ok(Self { root })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn allocate(
|
||||
&self,
|
||||
cwd: impl AsRef<Path>,
|
||||
policy: StaleLeasePolicy,
|
||||
) -> Result<StandaloneWorkerAllocation, StandaloneStoreError> {
|
||||
let worker_id = WorkerId::now_v7();
|
||||
let cwd = StandaloneCwdIdentity::capture(cwd)?;
|
||||
let dir = self.worker_dir(worker_id);
|
||||
fs::create_dir(&dir).map_err(StandaloneStoreError::Io)?;
|
||||
fs::create_dir(dir.join(SESSIONS_DIR)).map_err(StandaloneStoreError::Io)?;
|
||||
fs::create_dir(dir.join(WORKER_DIR)).map_err(StandaloneStoreError::Io)?;
|
||||
let lease = self.acquire_lease(worker_id, policy)?;
|
||||
Ok(StandaloneWorkerAllocation {
|
||||
worker_id,
|
||||
cwd,
|
||||
lease,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn commit_created(
|
||||
&self,
|
||||
allocation: &StandaloneWorkerAllocation,
|
||||
manifest: WorkerManifest,
|
||||
storage_key: String,
|
||||
active_session_id: SessionId,
|
||||
active_segment_id: Option<SegmentId>,
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let now = now_unix_ms()?;
|
||||
let record = StandaloneWorkerRecord {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
revision: 1,
|
||||
worker_id: allocation.worker_id,
|
||||
worker_name: manifest.worker.name.clone(),
|
||||
storage_key,
|
||||
cwd: allocation.cwd.clone(),
|
||||
manifest,
|
||||
active_session_id,
|
||||
active_segment_id,
|
||||
status: StandaloneWorkerStatus::Active,
|
||||
created_at_unix_ms: now,
|
||||
updated_at_unix_ms: now,
|
||||
shutdown_reason: None,
|
||||
};
|
||||
self.commit_record(None, &record)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn load(&self, id: WorkerId) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let dir = self.worker_dir(id);
|
||||
if dir.join(COMMIT_MARKER).exists() {
|
||||
return Err(StandaloneStoreError::IncompleteCommit(id));
|
||||
}
|
||||
let bytes = fs::read(dir.join(RECORD_FILE)).map_err(|error| {
|
||||
if error.kind() == io::ErrorKind::NotFound {
|
||||
StandaloneStoreError::WorkerNotFound(id)
|
||||
} else {
|
||||
StandaloneStoreError::Io(error)
|
||||
}
|
||||
})?;
|
||||
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes)
|
||||
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
|
||||
if record.schema_version > SCHEMA_VERSION {
|
||||
return Err(StandaloneStoreError::NewerSchema {
|
||||
id,
|
||||
found: record.schema_version,
|
||||
supported: SCHEMA_VERSION,
|
||||
});
|
||||
}
|
||||
if record.schema_version != SCHEMA_VERSION || record.worker_id != id {
|
||||
return Err(StandaloneStoreError::InvalidRecord(id));
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
&self,
|
||||
cwd: impl AsRef<Path>,
|
||||
scope: StandaloneListScope,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StandaloneWorkerRecord>, StandaloneStoreError> {
|
||||
let current_cwd = (scope == StandaloneListScope::CurrentCwd)
|
||||
.then(|| StandaloneCwdIdentity::capture(cwd))
|
||||
.transpose()?;
|
||||
let mut records = Vec::new();
|
||||
for entry in fs::read_dir(&self.root).map_err(StandaloneStoreError::Io)? {
|
||||
let entry = entry.map_err(StandaloneStoreError::Io)?;
|
||||
if !entry
|
||||
.file_type()
|
||||
.map_err(StandaloneStoreError::Io)?
|
||||
.is_dir()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(id) = entry.file_name().to_string_lossy().parse() else {
|
||||
continue;
|
||||
};
|
||||
let record = self.load(id)?;
|
||||
if current_cwd.as_ref().is_none_or(|cwd| &record.cwd == cwd) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
records.sort_by(|left, right| {
|
||||
right
|
||||
.updated_at_unix_ms
|
||||
.cmp(&left.updated_at_unix_ms)
|
||||
.then_with(|| right.worker_id.to_string().cmp(&left.worker_id.to_string()))
|
||||
});
|
||||
records.truncate(limit);
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn acquire_lease(
|
||||
&self,
|
||||
id: WorkerId,
|
||||
policy: StaleLeasePolicy,
|
||||
) -> Result<StandaloneWorkerLease, StandaloneStoreError> {
|
||||
let dir = self.worker_dir(id);
|
||||
let path = dir.join(LEASE_FILE);
|
||||
let _guard = LeaseMutationGuard::acquire(&dir)?;
|
||||
let lease = LeaseRecord::current()?;
|
||||
loop {
|
||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(mut file) => {
|
||||
serde_json::to_writer(&mut file, &lease).map_err(StandaloneStoreError::Json)?;
|
||||
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
|
||||
file.sync_all().map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&dir)?;
|
||||
return Ok(StandaloneWorkerLease {
|
||||
path,
|
||||
lease_id: lease.lease_id,
|
||||
released: false,
|
||||
});
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
let existing = read_lease(&path, id)?;
|
||||
match existing.liveness() {
|
||||
LeaseLiveness::Live => {
|
||||
return Err(StandaloneStoreError::WorkerLeased(id));
|
||||
}
|
||||
LeaseLiveness::Unknown => {
|
||||
return Err(StandaloneStoreError::LeaseLivenessUnknown(id));
|
||||
}
|
||||
LeaseLiveness::Stale => {}
|
||||
}
|
||||
if policy == StaleLeasePolicy::Reject {
|
||||
return Err(StandaloneStoreError::StaleLease(id));
|
||||
}
|
||||
fs::remove_file(&path).map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&dir)?;
|
||||
}
|
||||
Err(error) => return Err(StandaloneStoreError::Io(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_active_pointer(
|
||||
&self,
|
||||
record: &StandaloneWorkerRecord,
|
||||
active_session_id: SessionId,
|
||||
active_segment_id: Option<SegmentId>,
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let mut next = record.clone();
|
||||
next.revision = next.revision.saturating_add(1);
|
||||
next.updated_at_unix_ms = now_unix_ms()?;
|
||||
next.active_session_id = active_session_id;
|
||||
next.active_segment_id = active_segment_id;
|
||||
next.status = StandaloneWorkerStatus::Active;
|
||||
next.shutdown_reason = None;
|
||||
self.commit_record(Some(record.revision), &next)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn mark_stopped(
|
||||
&self,
|
||||
record: &StandaloneWorkerRecord,
|
||||
active_session_id: SessionId,
|
||||
active_segment_id: Option<SegmentId>,
|
||||
reason: StandaloneShutdownReason,
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let mut next = record.clone();
|
||||
next.revision = next.revision.saturating_add(1);
|
||||
next.updated_at_unix_ms = now_unix_ms()?;
|
||||
next.active_session_id = active_session_id;
|
||||
next.active_segment_id = active_segment_id;
|
||||
next.status = StandaloneWorkerStatus::Stopped;
|
||||
next.shutdown_reason = Some(reason);
|
||||
self.commit_record(Some(record.revision), &next)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn delete(&self, id: WorkerId) -> Result<(), StandaloneStoreError> {
|
||||
let record = self.load(id)?;
|
||||
if record.status != StandaloneWorkerStatus::Stopped {
|
||||
return Err(StandaloneStoreError::DeleteActive(id));
|
||||
}
|
||||
let worker_dir = self.worker_dir(id);
|
||||
let _guard = LeaseMutationGuard::acquire(&worker_dir)?;
|
||||
let lease_path = worker_dir.join(LEASE_FILE);
|
||||
if lease_path.exists() {
|
||||
let lease = read_lease(&lease_path, id)?;
|
||||
return Err(match lease.liveness() {
|
||||
LeaseLiveness::Live => StandaloneStoreError::WorkerLeased(id),
|
||||
LeaseLiveness::Stale => StandaloneStoreError::StaleLease(id),
|
||||
LeaseLiveness::Unknown => StandaloneStoreError::LeaseLivenessUnknown(id),
|
||||
});
|
||||
}
|
||||
fs::remove_dir_all(self.worker_dir(id)).map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&self.root)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn sessions_dir(&self, id: WorkerId) -> PathBuf {
|
||||
self.worker_dir(id).join(SESSIONS_DIR)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn worker_metadata_dir(&self, id: WorkerId) -> PathBuf {
|
||||
self.worker_dir(id).join(WORKER_DIR)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn runtime_dir(&self, id: WorkerId) -> PathBuf {
|
||||
self.worker_dir(id).join("runtime")
|
||||
}
|
||||
|
||||
pub(crate) fn abandon_allocation(
|
||||
&self,
|
||||
allocation: StandaloneWorkerAllocation,
|
||||
) -> Result<(), StandaloneStoreError> {
|
||||
let worker_id = allocation.worker_id;
|
||||
allocation.lease.release()?;
|
||||
fs::remove_dir_all(self.worker_dir(worker_id)).map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&self.root)
|
||||
}
|
||||
|
||||
fn commit_record(
|
||||
&self,
|
||||
expected_revision: Option<u64>,
|
||||
next: &StandaloneWorkerRecord,
|
||||
) -> Result<(), StandaloneStoreError> {
|
||||
let dir = self.worker_dir(next.worker_id);
|
||||
let marker = dir.join(COMMIT_MARKER);
|
||||
let mut marker_file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&marker)
|
||||
.map_err(|error| {
|
||||
if error.kind() == io::ErrorKind::AlreadyExists {
|
||||
StandaloneStoreError::IncompleteCommit(next.worker_id)
|
||||
} else {
|
||||
StandaloneStoreError::Io(error)
|
||||
}
|
||||
})?;
|
||||
writeln!(marker_file, "{}", next.revision).map_err(StandaloneStoreError::Io)?;
|
||||
marker_file.sync_all().map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&dir)?;
|
||||
|
||||
if let Some(expected) = expected_revision {
|
||||
let current = self.load_record_while_committing(next.worker_id)?;
|
||||
if current.revision != expected {
|
||||
let _ = fs::remove_file(&marker);
|
||||
return Err(StandaloneStoreError::RevisionConflict {
|
||||
id: next.worker_id,
|
||||
expected,
|
||||
found: current.revision,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let temporary = dir.join(format!("record.{}.tmp", Uuid::now_v7()));
|
||||
let result = (|| {
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)
|
||||
.map_err(StandaloneStoreError::Io)?;
|
||||
serde_json::to_writer_pretty(&mut file, next).map_err(StandaloneStoreError::Json)?;
|
||||
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
|
||||
file.sync_all().map_err(StandaloneStoreError::Io)?;
|
||||
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&dir)?;
|
||||
fs::remove_file(&marker).map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&dir)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn load_record_while_committing(
|
||||
&self,
|
||||
id: WorkerId,
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let bytes =
|
||||
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
|
||||
}
|
||||
|
||||
fn worker_dir(&self, id: WorkerId) -> PathBuf {
|
||||
self.root.join(id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StandaloneWorkerAllocation {
|
||||
worker_id: WorkerId,
|
||||
cwd: StandaloneCwdIdentity,
|
||||
lease: StandaloneWorkerLease,
|
||||
}
|
||||
|
||||
impl StandaloneWorkerAllocation {
|
||||
#[must_use]
|
||||
pub fn worker_id(&self) -> WorkerId {
|
||||
self.worker_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn cwd(&self) -> &StandaloneCwdIdentity {
|
||||
&self.cwd
|
||||
}
|
||||
|
||||
pub fn into_lease(self) -> StandaloneWorkerLease {
|
||||
self.lease
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StandaloneWorkerLease {
|
||||
path: PathBuf,
|
||||
lease_id: Uuid,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
impl StandaloneWorkerLease {
|
||||
pub fn release(mut self) -> Result<(), StandaloneStoreError> {
|
||||
self.release_inner()
|
||||
}
|
||||
|
||||
pub(crate) fn retain(mut self) {
|
||||
self.released = true;
|
||||
}
|
||||
|
||||
fn release_inner(&mut self) -> Result<(), StandaloneStoreError> {
|
||||
if self.released {
|
||||
return Ok(());
|
||||
}
|
||||
if self.path.exists() {
|
||||
let parent = self.path.parent().expect("lease parent");
|
||||
let _guard = LeaseMutationGuard::acquire(parent)?;
|
||||
let bytes = fs::read(&self.path).map_err(StandaloneStoreError::Io)?;
|
||||
let current: LeaseRecord =
|
||||
serde_json::from_slice(&bytes).map_err(StandaloneStoreError::Json)?;
|
||||
if current.lease_id != self.lease_id {
|
||||
return Err(StandaloneStoreError::LeaseOwnershipLost);
|
||||
}
|
||||
fs::remove_file(&self.path).map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(self.path.parent().expect("lease parent"))?;
|
||||
}
|
||||
self.released = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StandaloneWorkerLease {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.release_inner();
|
||||
}
|
||||
}
|
||||
|
||||
struct LeaseMutationGuard {
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl LeaseMutationGuard {
|
||||
fn acquire(dir: &Path) -> Result<Self, StandaloneStoreError> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(dir.join(LEASE_LOCK_FILE))
|
||||
.map_err(StandaloneStoreError::Io)?;
|
||||
file.lock_exclusive().map_err(StandaloneStoreError::Io)?;
|
||||
Ok(Self { file })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LeaseMutationGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = FileExt::unlock(&self.file);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LeaseRecord {
|
||||
lease_id: Uuid,
|
||||
pid: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
process_start_marker: Option<u64>,
|
||||
acquired_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
impl LeaseRecord {
|
||||
fn current() -> Result<Self, StandaloneStoreError> {
|
||||
Ok(Self {
|
||||
lease_id: Uuid::now_v7(),
|
||||
pid: std::process::id(),
|
||||
process_start_marker: match observe_process(std::process::id()) {
|
||||
ProcessObservation::Running { start_marker } => Some(start_marker),
|
||||
ProcessObservation::Missing | ProcessObservation::Unobservable => None,
|
||||
},
|
||||
acquired_at_unix_ms: now_unix_ms()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn liveness(&self) -> LeaseLiveness {
|
||||
classify_lease_liveness(self.process_start_marker, observe_process(self.pid))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LeaseLiveness {
|
||||
Live,
|
||||
Stale,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProcessObservation {
|
||||
Running { start_marker: u64 },
|
||||
Missing,
|
||||
Unobservable,
|
||||
}
|
||||
|
||||
fn classify_lease_liveness(
|
||||
recorded_start_marker: Option<u64>,
|
||||
observation: ProcessObservation,
|
||||
) -> LeaseLiveness {
|
||||
match (recorded_start_marker, observation) {
|
||||
(Some(recorded), ProcessObservation::Running { start_marker })
|
||||
if recorded == start_marker =>
|
||||
{
|
||||
LeaseLiveness::Live
|
||||
}
|
||||
(Some(_), ProcessObservation::Running { .. }) | (_, ProcessObservation::Missing) => {
|
||||
LeaseLiveness::Stale
|
||||
}
|
||||
(None, ProcessObservation::Running { .. }) | (_, ProcessObservation::Unobservable) => {
|
||||
LeaseLiveness::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_lease(path: &Path, id: WorkerId) -> Result<LeaseRecord, StandaloneStoreError> {
|
||||
let bytes = fs::read(path).map_err(StandaloneStoreError::Io)?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|source| StandaloneStoreError::CorruptLease { id, source })
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn observe_process(pid: u32) -> ProcessObservation {
|
||||
let stat = match fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
Ok(stat) => stat,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
return if pid != std::process::id() && linux_proc_is_observable() {
|
||||
ProcessObservation::Missing
|
||||
} else {
|
||||
ProcessObservation::Unobservable
|
||||
};
|
||||
}
|
||||
Err(_) => return ProcessObservation::Unobservable,
|
||||
};
|
||||
parse_linux_process_start_marker(&stat)
|
||||
.map(|start_marker| ProcessObservation::Running { start_marker })
|
||||
.unwrap_or(ProcessObservation::Unobservable)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_proc_is_observable() -> bool {
|
||||
fs::read_to_string("/proc/self/stat")
|
||||
.ok()
|
||||
.and_then(|stat| parse_linux_process_start_marker(&stat))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_linux_process_start_marker(stat: &str) -> Option<u64> {
|
||||
let (_, tail) = stat.rsplit_once(") ")?;
|
||||
tail.split_whitespace().nth(19)?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn observe_process(pid: u32) -> ProcessObservation {
|
||||
if pid == std::process::id() {
|
||||
ProcessObservation::Running { start_marker: 0 }
|
||||
} else {
|
||||
ProcessObservation::Unobservable
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| StandaloneStoreError::Clock)?;
|
||||
u64::try_from(duration.as_millis()).map_err(|_| StandaloneStoreError::Clock)
|
||||
}
|
||||
|
||||
fn sync_directory(path: &Path) -> Result<(), StandaloneStoreError> {
|
||||
File::open(path)
|
||||
.and_then(|file| file.sync_all())
|
||||
.map_err(StandaloneStoreError::Io)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StandaloneStoreError {
|
||||
#[error("standalone state path is not a directory")]
|
||||
NotDirectory,
|
||||
#[error("standalone cwd is unavailable")]
|
||||
CwdUnavailable(#[source] io::Error),
|
||||
#[error("standalone cwd is not a directory")]
|
||||
CwdNotDirectory,
|
||||
#[error("standalone cwd identity no longer matches the persisted Worker")]
|
||||
CwdIdentityMismatch,
|
||||
#[error("standalone Worker {0} was not found")]
|
||||
WorkerNotFound(WorkerId),
|
||||
#[error("standalone Worker {0} has an incomplete metadata commit")]
|
||||
IncompleteCommit(WorkerId),
|
||||
#[error("standalone Worker {0} has invalid metadata")]
|
||||
InvalidRecord(WorkerId),
|
||||
#[error("standalone Worker {id} metadata is corrupt")]
|
||||
CorruptRecord {
|
||||
id: WorkerId,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("standalone Worker {id} lease is corrupt")]
|
||||
CorruptLease {
|
||||
id: WorkerId,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("standalone Worker {id} uses schema {found}, newer than supported schema {supported}")]
|
||||
NewerSchema {
|
||||
id: WorkerId,
|
||||
found: u32,
|
||||
supported: u32,
|
||||
},
|
||||
#[error("standalone Worker {0} is already active")]
|
||||
WorkerLeased(WorkerId),
|
||||
#[error("standalone Worker {0} lease liveness cannot be proven; recovery is rejected")]
|
||||
LeaseLivenessUnknown(WorkerId),
|
||||
#[error("standalone Worker {0} has a stale lease; explicit recovery is required")]
|
||||
StaleLease(WorkerId),
|
||||
#[error("standalone Worker lease ownership changed")]
|
||||
LeaseOwnershipLost,
|
||||
#[error("standalone Worker {0} must be stopped before deletion")]
|
||||
DeleteActive(WorkerId),
|
||||
#[error(
|
||||
"standalone Worker {id} metadata revision changed (expected {expected}, found {found})"
|
||||
)]
|
||||
RevisionConflict {
|
||||
id: WorkerId,
|
||||
expected: u64,
|
||||
found: u64,
|
||||
},
|
||||
#[error("system clock is before the Unix epoch or out of range")]
|
||||
Clock,
|
||||
#[error("standalone metadata serialization failed")]
|
||||
Json(#[source] serde_json::Error),
|
||||
#[error("standalone state I/O failed")]
|
||||
Io(#[source] io::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LeaseLiveness, ProcessObservation, classify_lease_liveness};
|
||||
|
||||
#[test]
|
||||
fn lease_liveness_requires_positive_live_or_stale_evidence() {
|
||||
assert_eq!(
|
||||
classify_lease_liveness(Some(41), ProcessObservation::Running { start_marker: 41 }),
|
||||
LeaseLiveness::Live
|
||||
);
|
||||
assert_eq!(
|
||||
classify_lease_liveness(Some(41), ProcessObservation::Running { start_marker: 42 }),
|
||||
LeaseLiveness::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
classify_lease_liveness(Some(41), ProcessObservation::Missing),
|
||||
LeaseLiveness::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
classify_lease_liveness(None, ProcessObservation::Running { start_marker: 41 }),
|
||||
LeaseLiveness::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
classify_lease_liveness(Some(41), ProcessObservation::Unobservable),
|
||||
LeaseLiveness::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
classify_lease_liveness(None, ProcessObservation::Unobservable),
|
||||
LeaseLiveness::Unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::llm_client::client::LlmClient;
|
||||
use agen::llm_client::error::ClientError;
|
||||
use agen::llm_client::event::{Event as LlmEvent, StopReason};
|
||||
use agen::llm_client::types::Request;
|
||||
use async_trait::async_trait;
|
||||
use client::Client;
|
||||
use client::transport::in_process::Socket as InProcessSocket;
|
||||
use futures::{Stream, stream};
|
||||
use protocol::{Event, Method};
|
||||
use standalone::{
|
||||
StaleLeasePolicy, StandaloneHost, StandaloneLaunchConfig, StandaloneListScope,
|
||||
StandaloneStartupError, StandaloneStoreError, StandaloneWorkerStatus, StandaloneWorkerStore,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ScriptedClient {
|
||||
responses: Arc<Mutex<VecDeque<Vec<LlmEvent>>>>,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
}
|
||||
|
||||
impl ScriptedClient {
|
||||
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
|
||||
Self {
|
||||
responses: Arc::new(Mutex::new(responses.into())),
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn requests(&self) -> Vec<Request> {
|
||||
self.requests.lock().expect("requests lock").clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for ScriptedClient {
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
self.requests.lock().expect("requests lock").push(request);
|
||||
let response = self
|
||||
.responses
|
||||
.lock()
|
||||
.expect("responses lock")
|
||||
.pop_front()
|
||||
.expect("scripted response");
|
||||
Ok(Box::pin(stream::iter(response.into_iter().map(Ok))))
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(temp.path().join("probe.txt"), "standalone tool evidence\n")
|
||||
.expect("write probe");
|
||||
let worker_name = format!("standalone-{}", Uuid::now_v7());
|
||||
let launch = StandaloneLaunchConfig::new(
|
||||
temp.path(),
|
||||
temp.path().join("state"),
|
||||
manifest::ProfileSelector::Default,
|
||||
&worker_name,
|
||||
)
|
||||
.resolve()
|
||||
.expect("resolve standalone profile");
|
||||
|
||||
let client = ScriptedClient::new(vec![
|
||||
vec![
|
||||
LlmEvent::tool_use_start(0, "read-1", "Read"),
|
||||
LlmEvent::tool_input_delta(0, r#"{"file_path":"probe.txt"}"#),
|
||||
LlmEvent::tool_use_stop(0),
|
||||
],
|
||||
vec![
|
||||
LlmEvent::text_block_start(0),
|
||||
LlmEvent::text_delta(0, "standalone response"),
|
||||
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
|
||||
],
|
||||
]);
|
||||
let inspection = client.clone();
|
||||
let host = StandaloneHost::start_with_model_client(launch, client)
|
||||
.await
|
||||
.expect("start in-process host");
|
||||
assert_eq!(host.record().worker_name, worker_name);
|
||||
assert_eq!(host.record().manifest.worker.name, worker_name);
|
||||
assert_eq!(
|
||||
host.record().storage_key,
|
||||
format!("standalone-{}", host.worker_id())
|
||||
);
|
||||
let mut protocol_client = host.connect();
|
||||
|
||||
protocol_client
|
||||
.send(&Method::run_text("read the probe"))
|
||||
.await
|
||||
.expect("submit input");
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
let mut saw_user_message = false;
|
||||
let mut saw_text = false;
|
||||
let mut saw_tool_result = false;
|
||||
loop {
|
||||
match protocol_client
|
||||
.next_event()
|
||||
.await
|
||||
.expect("protocol event")
|
||||
.expect("worker event")
|
||||
{
|
||||
Event::UserMessage { segments }
|
||||
if format!("{segments:?}").contains("read the probe") =>
|
||||
{
|
||||
saw_user_message = true;
|
||||
}
|
||||
Event::TextDelta { text } if text.contains("standalone response") => {
|
||||
saw_text = true;
|
||||
}
|
||||
Event::ToolResult { .. } => {
|
||||
saw_tool_result = true;
|
||||
}
|
||||
Event::RunEnd { .. } => {
|
||||
assert!(
|
||||
saw_user_message,
|
||||
"stream must expose the committed user message"
|
||||
);
|
||||
assert!(saw_text, "stream must expose the model text delta");
|
||||
assert!(saw_tool_result, "stream must expose the tool result");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("run completed");
|
||||
|
||||
let requests = inspection.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let tool_names = requests[0]
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(tool_names.contains(&"Read"));
|
||||
assert!(tool_names.contains(&"TaskCreate"));
|
||||
assert!(tool_names.contains(&"SubWorkerSpawn"));
|
||||
assert!(format!("{:?}", requests[1].items).contains("standalone tool evidence"));
|
||||
assert!(
|
||||
!temp
|
||||
.path()
|
||||
.join("state/runtime")
|
||||
.join(&worker_name)
|
||||
.join("worker.sock")
|
||||
.exists()
|
||||
);
|
||||
|
||||
host.shutdown().await.expect("graceful shutdown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_preserves_occupied_scope_conflict_details() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = temp.path().join("project");
|
||||
std::fs::create_dir(&cwd).expect("create project");
|
||||
|
||||
let first_launch = StandaloneLaunchConfig::new(
|
||||
&cwd,
|
||||
temp.path().join("first-state"),
|
||||
manifest::ProfileSelector::Default,
|
||||
"first-worker",
|
||||
)
|
||||
.resolve()
|
||||
.expect("resolve first launch");
|
||||
let first_host =
|
||||
StandaloneHost::start_with_model_client(first_launch, ScriptedClient::new(Vec::new()))
|
||||
.await
|
||||
.expect("start first host");
|
||||
let competitor = first_host.record().storage_key.clone();
|
||||
|
||||
let second_launch = StandaloneLaunchConfig::new(
|
||||
&cwd,
|
||||
temp.path().join("second-state"),
|
||||
manifest::ProfileSelector::Default,
|
||||
"second-worker",
|
||||
)
|
||||
.resolve()
|
||||
.expect("resolve second launch");
|
||||
let error =
|
||||
StandaloneHost::start_with_model_client(second_launch, ScriptedClient::new(Vec::new()))
|
||||
.await
|
||||
.err()
|
||||
.expect("occupied scope rejected");
|
||||
|
||||
first_host.shutdown().await.expect("shutdown first host");
|
||||
|
||||
let canonical_cwd = cwd.canonicalize().expect("canonical cwd");
|
||||
match &error {
|
||||
StandaloneStartupError::ScopeConflict {
|
||||
competitor: actual_competitor,
|
||||
requested_rule,
|
||||
competitor_rule,
|
||||
} => {
|
||||
assert_eq!(actual_competitor, &competitor);
|
||||
assert_eq!(requested_rule.target, canonical_cwd);
|
||||
assert_eq!(competitor_rule.target, canonical_cwd);
|
||||
}
|
||||
other => panic!("expected scope conflict, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`",
|
||||
canonical_cwd.display(),
|
||||
canonical_cwd.display()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_store_failure_is_redacted_and_starts_no_controller() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let state_path = temp.path().join("state-file-with-secret-name");
|
||||
std::fs::write(&state_path, "not a directory").expect("write blocking file");
|
||||
let launch = StandaloneLaunchConfig::new(
|
||||
temp.path(),
|
||||
&state_path,
|
||||
manifest::ProfileSelector::Default,
|
||||
format!("standalone-failure-{}", Uuid::now_v7()),
|
||||
)
|
||||
.resolve()
|
||||
.expect("resolve launch");
|
||||
let client = ScriptedClient::new(Vec::new());
|
||||
|
||||
let error = StandaloneHost::start_with_model_client(launch, client)
|
||||
.await
|
||||
.err()
|
||||
.expect("state store startup rejected");
|
||||
assert_eq!(error, standalone::StandaloneStartupError::StateStore);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the standalone state store could not be opened or validated"
|
||||
);
|
||||
assert!(!error.to_string().contains("secret-name"));
|
||||
assert!(
|
||||
!temp
|
||||
.path()
|
||||
.join("state-file-with-secret-name/runtime")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_crate_has_no_tui_runtime_or_workspace_server_dependency() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let dependencies = manifest
|
||||
.split("[dependencies]")
|
||||
.nth(1)
|
||||
.expect("dependencies section")
|
||||
.split("[dev-dependencies]")
|
||||
.next()
|
||||
.expect("dependency body");
|
||||
for forbidden in ["tui", "worker-runtime", "yoi-workspace-server"] {
|
||||
assert!(
|
||||
!dependencies.lines().any(|line| {
|
||||
line.split_once('=')
|
||||
.is_some_and(|(name, _)| name.trim() == forbidden)
|
||||
}),
|
||||
"standalone must not depend on {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_rejects_path_profile_before_worker_startup() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let error = StandaloneLaunchConfig::new(
|
||||
temp.path(),
|
||||
temp.path().join("state"),
|
||||
manifest::ProfileSelector::Path {
|
||||
path: temp.path().join("profile.dcdl"),
|
||||
},
|
||||
"standalone-path-profile",
|
||||
)
|
||||
.resolve()
|
||||
.err()
|
||||
.expect("path profile rejected");
|
||||
assert_eq!(
|
||||
error,
|
||||
standalone::StandaloneLaunchError::PathProfileUnsupported
|
||||
);
|
||||
}
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let cwd = temp.path().join("project");
|
||||
let state_dir = temp.path().join("client").join("standalone-workers");
|
||||
std::fs::create_dir_all(&cwd)?;
|
||||
let launch = StandaloneLaunchConfig::new(
|
||||
&cwd,
|
||||
&state_dir,
|
||||
manifest::ProfileSelector::Default,
|
||||
"display-name-is-not-session-identity",
|
||||
)
|
||||
.resolve()?;
|
||||
let first_client = ScriptedClient::new(vec![
|
||||
vec![
|
||||
LlmEvent::tool_use_start(0, "task-1", "TaskCreate"),
|
||||
LlmEvent::tool_input_delta(
|
||||
0,
|
||||
r#"{"subject":"persisted task","description":"survives restore"}"#,
|
||||
),
|
||||
LlmEvent::tool_use_stop(0),
|
||||
],
|
||||
vec![
|
||||
LlmEvent::text_block_start(0),
|
||||
LlmEvent::text_delta(0, "first answer"),
|
||||
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
|
||||
],
|
||||
vec![
|
||||
LlmEvent::text_block_start(0),
|
||||
LlmEvent::text_delta(0, "notification acknowledged"),
|
||||
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
|
||||
],
|
||||
]);
|
||||
let host = StandaloneHost::start_with_model_client(launch, first_client).await?;
|
||||
let worker_id = host.worker_id();
|
||||
let mut protocol_client = host.connect();
|
||||
protocol_client
|
||||
.send(&Method::run_text("first request"))
|
||||
.await?;
|
||||
wait_for_run_end(&mut protocol_client).await?;
|
||||
protocol_client
|
||||
.send(&Method::Notify {
|
||||
message: "persisted notification".to_string(),
|
||||
auto_run: true,
|
||||
})
|
||||
.await?;
|
||||
wait_for_run_end(&mut protocol_client).await?;
|
||||
host.shutdown().await?;
|
||||
|
||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||
let current = store.list(&cwd, StandaloneListScope::CurrentCwd, 100)?;
|
||||
assert_eq!(current.len(), 1);
|
||||
assert_eq!(current[0].worker_id, worker_id);
|
||||
assert_eq!(current[0].status, StandaloneWorkerStatus::Stopped);
|
||||
let other_cwd = temp.path().join("other");
|
||||
std::fs::create_dir(&other_cwd)?;
|
||||
assert!(
|
||||
store
|
||||
.list(&other_cwd, StandaloneListScope::CurrentCwd, 100)?
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
store.list(&other_cwd, StandaloneListScope::All, 100)?.len(),
|
||||
1
|
||||
);
|
||||
|
||||
let second_client = ScriptedClient::new(vec![vec![
|
||||
LlmEvent::text_block_start(0),
|
||||
LlmEvent::text_delta(0, "second answer"),
|
||||
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
|
||||
]]);
|
||||
let second_inspection = second_client.clone();
|
||||
let host =
|
||||
StandaloneHost::restore_with_model_client(state_dir.clone(), worker_id, second_client)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
host.record().worker_name,
|
||||
"display-name-is-not-session-identity"
|
||||
);
|
||||
assert_eq!(host.record().storage_key, format!("standalone-{worker_id}"));
|
||||
let mut protocol_client = host.connect();
|
||||
let snapshot = format!(
|
||||
"{:?}",
|
||||
protocol_client
|
||||
.next_event()
|
||||
.await
|
||||
.expect("restored protocol stream")
|
||||
.expect("restored snapshot")
|
||||
);
|
||||
assert!(snapshot.contains("first request"), "{snapshot}");
|
||||
assert!(snapshot.contains("first answer"), "{snapshot}");
|
||||
assert!(snapshot.contains("persisted task"), "{snapshot}");
|
||||
assert!(snapshot.contains("persisted notification"), "{snapshot}");
|
||||
|
||||
protocol_client
|
||||
.send(&Method::run_text("continue after restore"))
|
||||
.await?;
|
||||
wait_for_run_end(&mut protocol_client).await?;
|
||||
let request = second_inspection
|
||||
.requests()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("restored run request");
|
||||
let projected = format!("{:?}", request.items);
|
||||
assert!(projected.contains("first answer"), "{projected}");
|
||||
assert!(projected.contains("persisted notification"), "{projected}");
|
||||
assert!(projected.contains("persisted task"), "{projected}");
|
||||
host.shutdown().await?;
|
||||
|
||||
store.delete(worker_id)?;
|
||||
assert!(cwd.exists(), "deleting session state must not mutate cwd");
|
||||
assert!(matches!(
|
||||
store.load(worker_id),
|
||||
Err(StandaloneStoreError::WorkerNotFound(_))
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_restore_rejects_concurrent_lease_and_missing_cwd() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let cwd = temp.path().join("project");
|
||||
let moved = temp.path().join("moved-project");
|
||||
let state_dir = temp.path().join("state");
|
||||
std::fs::create_dir(&cwd)?;
|
||||
let launch = StandaloneLaunchConfig::new(
|
||||
&cwd,
|
||||
&state_dir,
|
||||
manifest::ProfileSelector::Default,
|
||||
"standalone-lease-test",
|
||||
)
|
||||
.resolve()?;
|
||||
let host =
|
||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
||||
let worker_id = host.worker_id();
|
||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||
assert!(matches!(
|
||||
store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
|
||||
Err(StandaloneStoreError::WorkerLeased(id)) if id == worker_id
|
||||
));
|
||||
let restore = StandaloneHost::restore_with_model_client(
|
||||
state_dir.clone(),
|
||||
worker_id,
|
||||
ScriptedClient::new(Vec::new()),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(restore, Err(StandaloneStartupError::WorkerActive)));
|
||||
host.shutdown().await?;
|
||||
|
||||
std::fs::rename(&cwd, &moved)?;
|
||||
let restore = StandaloneHost::restore_with_model_client(
|
||||
state_dir,
|
||||
worker_id,
|
||||
ScriptedClient::new(Vec::new()),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
restore,
|
||||
Err(StandaloneStartupError::WorkingDirectoryUnavailable)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let state_dir = temp.path().join("state");
|
||||
let mut launch = StandaloneLaunchConfig::new(
|
||||
temp.path(),
|
||||
&state_dir,
|
||||
manifest::ProfileSelector::Default,
|
||||
"standalone-stale-lease-test",
|
||||
)
|
||||
.resolve()?;
|
||||
launch.profile.manifest.profile = Some(manifest::ProfileManifestSnapshot {
|
||||
source: manifest::ProfileSource::Registry {
|
||||
source: manifest::ProfileRegistrySource::User,
|
||||
name: "user-standalone".to_string(),
|
||||
path: None,
|
||||
provenance: Some("user-config-revision-7".to_string()),
|
||||
},
|
||||
profile: Some(manifest::ProfileMetadata {
|
||||
name: Some("User standalone".to_string()),
|
||||
description: None,
|
||||
format: None,
|
||||
}),
|
||||
});
|
||||
let host =
|
||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
||||
let worker_id = host.worker_id();
|
||||
host.shutdown().await?;
|
||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||
assert!(matches!(
|
||||
store.load(worker_id)?.manifest.profile,
|
||||
Some(manifest::ProfileManifestSnapshot {
|
||||
source: manifest::ProfileSource::Registry {
|
||||
source: manifest::ProfileRegistrySource::User,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
let worker_dir = state_dir.join(worker_id.to_string());
|
||||
std::fs::write(
|
||||
worker_dir.join("lease.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"lease_id": uuid::Uuid::now_v7(),
|
||||
"pid": u32::MAX,
|
||||
"process_start_marker": 1,
|
||||
"acquired_at_unix_ms": 1
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
let host = StandaloneHost::restore_with_model_client(
|
||||
state_dir,
|
||||
worker_id,
|
||||
ScriptedClient::new(Vec::new()),
|
||||
)
|
||||
.await?;
|
||||
host.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_restore_rejects_lease_with_missing_start_marker() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let state_dir = temp.path().join("state");
|
||||
let launch = StandaloneLaunchConfig::new(
|
||||
temp.path(),
|
||||
&state_dir,
|
||||
manifest::ProfileSelector::Default,
|
||||
"standalone-unknown-lease-test",
|
||||
)
|
||||
.resolve()?;
|
||||
let host =
|
||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
||||
let worker_id = host.worker_id();
|
||||
host.shutdown().await?;
|
||||
let worker_dir = state_dir.join(worker_id.to_string());
|
||||
std::fs::write(
|
||||
worker_dir.join("lease.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"lease_id": uuid::Uuid::now_v7(),
|
||||
"pid": std::process::id(),
|
||||
"acquired_at_unix_ms": 1
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||
assert!(matches!(
|
||||
store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
|
||||
Err(StandaloneStoreError::LeaseLivenessUnknown(id)) if id == worker_id
|
||||
));
|
||||
let restore = StandaloneHost::restore_with_model_client(
|
||||
state_dir,
|
||||
worker_id,
|
||||
ScriptedClient::new(Vec::new()),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
restore,
|
||||
Err(StandaloneStartupError::LeaseLivenessUnknown)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let state_dir = temp.path().join("state");
|
||||
let launch = StandaloneLaunchConfig::new(
|
||||
temp.path(),
|
||||
&state_dir,
|
||||
manifest::ProfileSelector::Default,
|
||||
"standalone-schema-test",
|
||||
)
|
||||
.resolve()?;
|
||||
let host =
|
||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
||||
let worker_id = host.worker_id();
|
||||
host.shutdown().await?;
|
||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||
let worker_dir = state_dir.join(worker_id.to_string());
|
||||
std::fs::write(worker_dir.join("commit.pending"), b"interrupted\n")?;
|
||||
assert!(matches!(
|
||||
store.load(worker_id),
|
||||
Err(StandaloneStoreError::IncompleteCommit(id)) if id == worker_id
|
||||
));
|
||||
std::fs::remove_file(worker_dir.join("commit.pending"))?;
|
||||
let record_path = worker_dir.join("record.json");
|
||||
let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&record_path)?)?;
|
||||
record["schema_version"] = serde_json::json!(u32::MAX);
|
||||
std::fs::write(&record_path, serde_json::to_vec_pretty(&record)?)?;
|
||||
assert!(matches!(
|
||||
store.load(worker_id),
|
||||
Err(StandaloneStoreError::NewerSchema { id, .. }) if id == worker_id
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_run_end(client: &mut Client<InProcessSocket>) -> TestResult {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if matches!(client.next_event().await, Ok(Some(Event::RunEnd { .. }))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+1103
-198
File diff suppressed because it is too large
Load Diff
+169
-21
@@ -142,8 +142,8 @@ const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark
|
||||
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
|
||||
state_changed event, effective target, and planning -> ready transition atomically.";
|
||||
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
||||
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
|
||||
and rejects unresolved blocking relations.";
|
||||
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
|
||||
requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
|
||||
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
||||
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
||||
@@ -316,7 +316,11 @@ impl TicketBackend for TicketToolBackend {
|
||||
self.backend.mark_ready(id, request)
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
||||
fn queue_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
queued_by: &str,
|
||||
) -> TicketResult<crate::TicketQueueOutcome> {
|
||||
self.backend.queue_ready(id, queued_by)
|
||||
}
|
||||
|
||||
@@ -406,7 +410,7 @@ struct TicketCreateParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketEditItemParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Optional replacement title.
|
||||
#[serde(default)]
|
||||
@@ -535,7 +539,7 @@ impl QueryTicketParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct ShowTicketParams {
|
||||
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
/// Exact ticket id query. Exactly one of `id` or `query` must be provided.
|
||||
@@ -554,7 +558,7 @@ struct ShowTicketParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketThreadEventParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Markdown event body.
|
||||
body: String,
|
||||
@@ -562,7 +566,7 @@ struct TicketThreadEventParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketMarkReadyParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Optional reason attached to the state_changed event.
|
||||
#[serde(default)]
|
||||
@@ -571,7 +575,7 @@ struct TicketMarkReadyParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketIntakeReadyParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Concise bounded intake summary appended before the ready transition.
|
||||
intake_summary: String,
|
||||
@@ -582,13 +586,13 @@ struct TicketIntakeReadyParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketQueueParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketWorkflowStateParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Expected current state. The backend rejects stale transitions.
|
||||
from: TicketWorkflowStateParam,
|
||||
@@ -602,7 +606,7 @@ struct TicketWorkflowStateParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketCloseParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Markdown resolution written to resolution.md and thread.md.
|
||||
resolution: String,
|
||||
@@ -610,7 +614,7 @@ struct TicketCloseParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketDependencyCheckParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
@@ -642,7 +646,7 @@ struct TicketRelationRecordParams {
|
||||
ticket: String,
|
||||
/// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of.
|
||||
kind: TicketRelationKindParam,
|
||||
/// Target canonical Ticket id. Title/slug words are not accepted as relation authority.
|
||||
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
target: String,
|
||||
/// Optional bounded rationale/note.
|
||||
#[serde(default)]
|
||||
@@ -655,7 +659,7 @@ struct TicketRelationRemoveParams {
|
||||
ticket: String,
|
||||
/// Forward relation kind to remove.
|
||||
kind: TicketRelationKindParam,
|
||||
/// Target canonical Ticket id.
|
||||
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
target: String,
|
||||
}
|
||||
|
||||
@@ -1219,12 +1223,29 @@ impl Tool for TicketQueueTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||
let queued_by = default_author();
|
||||
self.backend
|
||||
let mut outcome = self
|
||||
.backend
|
||||
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||
outcome.requested_ticket =
|
||||
model_ticket_reference(&self.backend, &outcome.requested_ticket, "TicketQueue")?;
|
||||
outcome.queued_tickets = outcome
|
||||
.queued_tickets
|
||||
.into_iter()
|
||||
.map(|ticket| model_ticket_reference(&self.backend, &ticket, "TicketQueue"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(json_output(
|
||||
format!("Queued ticket {} for Orchestrator", params.ticket),
|
||||
json!({ "ticket": params.ticket, "state": "queued", "queued_by": queued_by, "ok": true }),
|
||||
format!(
|
||||
"Queued {} ticket(s) for Orchestrator",
|
||||
outcome.queued_tickets.len()
|
||||
),
|
||||
json!({
|
||||
"ticket": outcome.requested_ticket,
|
||||
"queued_tickets": outcome.queued_tickets,
|
||||
"state": "queued",
|
||||
"queued_by": queued_by,
|
||||
"ok": true
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1250,15 +1271,17 @@ impl Tool for TicketWorkflowStateTool {
|
||||
self.backend
|
||||
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
|
||||
.map_err(|error| backend_error("TicketWorkflowState", error))?;
|
||||
let ticket_ref =
|
||||
model_ticket_reference(&self.backend, ¶ms.ticket, "TicketWorkflowState")?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Transitioned ticket {} state {} -> {}",
|
||||
params.ticket,
|
||||
ticket_ref,
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
),
|
||||
json!({
|
||||
"ticket": params.ticket,
|
||||
"ticket": ticket_ref,
|
||||
"from": from.as_str(),
|
||||
"to": to.as_str(),
|
||||
"state": to.as_str(),
|
||||
@@ -1282,9 +1305,10 @@ impl Tool for TicketCloseTool {
|
||||
MarkdownText::new(params.resolution),
|
||||
)
|
||||
.map_err(|error| backend_error("TicketClose", error))?;
|
||||
let ticket_ref = model_ticket_reference(&self.backend, ¶ms.ticket, "TicketClose")?;
|
||||
Ok(json_output(
|
||||
format!("Closed ticket {}", params.ticket),
|
||||
json!({ "ticket": params.ticket, "state": "closed", "ok": true }),
|
||||
format!("Closed ticket {ticket_ref}"),
|
||||
json!({ "ticket": ticket_ref, "state": "closed", "ok": true }),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1511,6 +1535,29 @@ impl Tool for TicketDependencyCheckTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn model_ticket_reference(
|
||||
backend: &TicketToolBackend,
|
||||
reference: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<String, ToolError> {
|
||||
let ticket = backend
|
||||
.show(TicketIdOrSlug::Id(reference.to_string()))
|
||||
.map_err(|error| backend_error(tool_name, error))?;
|
||||
match ticket.meta.resource_key {
|
||||
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
||||
Some(_) => Err(ToolError::ExecutionFailed(format!(
|
||||
"{tool_name} failed: required Ticket key is unavailable"
|
||||
))),
|
||||
None => Ok(ticket.meta.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
|
||||
resource_key.strip_prefix("T-").is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||
@@ -1908,6 +1955,12 @@ mod tests {
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
|
||||
crate::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace")
|
||||
.unwrap()
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
|
||||
let (_, tool) = definition();
|
||||
tool
|
||||
@@ -2535,6 +2588,101 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_workflow_and_close_project_internal_inputs_to_ticket_keys() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let inner = sqlite_backend(&temp);
|
||||
let mut dependency_input = NewTicket::new("Dependency");
|
||||
dependency_input.repository_id = Some("main".to_string());
|
||||
let dependency = inner.create(dependency_input).unwrap();
|
||||
let mut target_input = NewTicket::new("Target");
|
||||
target_input.repository_id = Some("main".to_string());
|
||||
let target = inner.create(target_input).unwrap();
|
||||
inner
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id(target.id.clone()),
|
||||
NewTicketRelation {
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: dependency.id.clone(),
|
||||
note: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for id in [&dependency.id, &target.id] {
|
||||
inner
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(id.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ready-{id}"),
|
||||
reason: None,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let target_key = target.resource_key.clone().unwrap();
|
||||
let dependency_key = dependency.resource_key.clone().unwrap();
|
||||
let backend = inner;
|
||||
let queue = tool_by_name(TicketToolBackend::new(backend.clone()), "TicketQueue");
|
||||
let workflow = tool_by_name(
|
||||
TicketToolBackend::new(backend.clone()),
|
||||
"TicketWorkflowState",
|
||||
);
|
||||
let close = tool_by_name(TicketToolBackend::new(backend), "TicketClose");
|
||||
|
||||
let queued = queue
|
||||
.execute(
|
||||
&json!({"ticket": target.id.clone()}).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(queued.summary.contains("2 ticket(s)"));
|
||||
let queued_content = queued.content.unwrap();
|
||||
assert!(queued_content.contains(&target_key));
|
||||
assert!(queued_content.contains(&dependency_key));
|
||||
assert!(!queued_content.contains(&target.id));
|
||||
assert!(!queued_content.contains(&dependency.id));
|
||||
|
||||
for (from, to) in [("queued", "inprogress"), ("inprogress", "done")] {
|
||||
let transitioned = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": target.id.clone(),
|
||||
"from": from,
|
||||
"to": to,
|
||||
"reason": "test_transition",
|
||||
"body": "transitioned",
|
||||
"author": "tester"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(transitioned.summary.contains(&target_key));
|
||||
assert!(!transitioned.summary.contains(&target.id));
|
||||
let content = transitioned.content.unwrap();
|
||||
assert!(content.contains(&target_key));
|
||||
assert!(!content.contains(&target.id));
|
||||
}
|
||||
|
||||
let closed = close
|
||||
.execute(
|
||||
&json!({"ticket": target.id.clone(), "resolution": "Done"}).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(closed.summary.contains(&target_key));
|
||||
assert!(!closed.summary.contains(&target.id));
|
||||
let content = closed.content.unwrap();
|
||||
assert!(content.contains(&target_key));
|
||||
assert!(!content.contains(&target.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
+290
-17
@@ -1,5 +1,6 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
@@ -20,21 +21,66 @@ struct BashParams {
|
||||
|
||||
pub(crate) struct BashTool {
|
||||
session: WorkdirSessionHandle,
|
||||
output_dir: PathBuf,
|
||||
state: Arc<Mutex<BashExecutionState>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ActiveCommand {
|
||||
call_id: String,
|
||||
execution_nonce: u64,
|
||||
handle: CommandHandle,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BashExecutionState {
|
||||
active: HashMap<String, ActiveCommand>,
|
||||
cancellation_requested: HashSet<String>,
|
||||
legacy_cancellation_requested: HashSet<String>,
|
||||
next_execution_nonce: u64,
|
||||
}
|
||||
|
||||
struct CommandGuard {
|
||||
session: WorkdirSessionHandle,
|
||||
state: Arc<Mutex<BashExecutionState>>,
|
||||
execution_id: String,
|
||||
execution_nonce: u64,
|
||||
handle: Option<CommandHandle>,
|
||||
}
|
||||
|
||||
impl Drop for CommandGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let Some(handle) = self.handle.take() else {
|
||||
return;
|
||||
};
|
||||
let workdir = self.session.clone();
|
||||
let state = Arc::clone(&self.state);
|
||||
let execution_id = self.execution_id.clone();
|
||||
let execution_nonce = self.execution_nonce;
|
||||
// A dropped provider future is not terminal confirmation. Keep the live
|
||||
// execution registered until cleanup has both requested cancellation and
|
||||
// observed terminal command output, so cancellation/session teardown
|
||||
// cannot race with an apparently empty registry.
|
||||
tokio::spawn(async move {
|
||||
let _ = workdir.cancel_command(handle).await;
|
||||
});
|
||||
let _ = workdir.cancel_command(handle.clone()).await;
|
||||
let _ = workdir
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: INLINE_BYTE_BUDGET,
|
||||
wait: true,
|
||||
})
|
||||
.await;
|
||||
let mut state = state.lock().unwrap();
|
||||
if state
|
||||
.active
|
||||
.get(&execution_id)
|
||||
.is_some_and(|active| active.execution_nonce == execution_nonce)
|
||||
{
|
||||
state.active.remove(&execution_id);
|
||||
state.cancellation_requested.remove(&execution_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,20 +98,51 @@ impl Tool for BashTool {
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||
.clamp(1, MAX_TIMEOUT_SECS);
|
||||
let cmd_summary = truncate_for_summary(¶ms.command);
|
||||
let execution_id = ctx.execution_id();
|
||||
let call_id = ctx.call_id;
|
||||
let execution_nonce = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.next_execution_nonce = state.next_execution_nonce.wrapping_add(1);
|
||||
state.next_execution_nonce
|
||||
};
|
||||
let mut guard = CommandGuard {
|
||||
session: self.session.clone(),
|
||||
state: self.state.clone(),
|
||||
execution_id: execution_id.clone(),
|
||||
execution_nonce,
|
||||
handle: None,
|
||||
};
|
||||
let handle = self
|
||||
.session
|
||||
.start_command(CommandRequest {
|
||||
command: params.command,
|
||||
timeout_secs,
|
||||
output_limit: INLINE_BYTE_BUDGET,
|
||||
tool_call_id: Some(ctx.call_id),
|
||||
spill_dir: Some(self.output_dir.clone()),
|
||||
tool_call_id: Some(call_id.clone()),
|
||||
})
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
let mut guard = CommandGuard {
|
||||
session: self.session.clone(),
|
||||
handle: Some(handle.clone()),
|
||||
let cancel_after_start = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.active.insert(
|
||||
execution_id.clone(),
|
||||
ActiveCommand {
|
||||
call_id: call_id.clone(),
|
||||
execution_nonce,
|
||||
handle: handle.clone(),
|
||||
},
|
||||
);
|
||||
state.cancellation_requested.contains(&execution_id)
|
||||
|| state.legacy_cancellation_requested.contains(&call_id)
|
||||
};
|
||||
guard.handle = Some(handle.clone());
|
||||
if cancel_after_start {
|
||||
self.session
|
||||
.cancel_command(handle.clone())
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
}
|
||||
let output = self
|
||||
.session
|
||||
.command_output(CommandOutputRequest {
|
||||
@@ -76,9 +153,27 @@ impl Tool for BashTool {
|
||||
})
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
let cancellation_requested = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let owns_registration = state
|
||||
.active
|
||||
.get(&execution_id)
|
||||
.is_some_and(|active| active.execution_nonce == execution_nonce);
|
||||
let exact = if owns_registration {
|
||||
state.active.remove(&execution_id);
|
||||
state.cancellation_requested.remove(&execution_id)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let legacy = state.legacy_cancellation_requested.remove(&call_id);
|
||||
exact || legacy
|
||||
};
|
||||
guard.handle = None;
|
||||
|
||||
let summary = if output.timed_out {
|
||||
let timed_out = output.timed_out;
|
||||
let summary = if cancellation_requested {
|
||||
format!("$ {cmd_summary} (cancelled)")
|
||||
} else if output.timed_out {
|
||||
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
|
||||
} else {
|
||||
match output.exit_code {
|
||||
@@ -90,18 +185,74 @@ impl Tool for BashTool {
|
||||
let content = if output.content.is_empty() {
|
||||
None
|
||||
} else if output.truncated {
|
||||
Some(format!(
|
||||
"[showing bounded WorkdirSession command output; additional output was truncated]\n{}",
|
||||
output.content
|
||||
))
|
||||
let notice = match output.output_path {
|
||||
Some(path) => format!(
|
||||
"[showing bounded WorkdirSession command output; full output saved to {}]",
|
||||
path.display()
|
||||
),
|
||||
None => "[showing bounded WorkdirSession command output; additional output was truncated]"
|
||||
.to_owned(),
|
||||
};
|
||||
Some(format!("{notice}\n{}", output.content))
|
||||
} else {
|
||||
Some(output.content)
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
let output = ToolOutput {
|
||||
summary,
|
||||
content,
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
};
|
||||
if cancellation_requested {
|
||||
Err(ToolError::Cancelled(output))
|
||||
} else if timed_out {
|
||||
Err(ToolError::Interrupted(output))
|
||||
} else {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel(&self, call_id: &str) -> Result<(), ToolError> {
|
||||
let handles = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state
|
||||
.legacy_cancellation_requested
|
||||
.insert(call_id.to_string());
|
||||
state
|
||||
.active
|
||||
.values()
|
||||
.filter(|active| active.call_id == call_id)
|
||||
.map(|active| active.handle.clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for handle in handles {
|
||||
self.session
|
||||
.cancel_command(handle)
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel_execution(
|
||||
&self,
|
||||
ctx: &agen::tool::ToolExecutionContext,
|
||||
) -> Result<(), ToolError> {
|
||||
let execution_id = ctx.execution_id();
|
||||
let handle = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.cancellation_requested.insert(execution_id.clone());
|
||||
state
|
||||
.active
|
||||
.get(&execution_id)
|
||||
.map(|active| active.handle.clone())
|
||||
};
|
||||
if let Some(handle) = handle {
|
||||
self.session
|
||||
.cancel_command(handle)
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,15 +266,137 @@ fn truncate_for_summary(command: &str) -> String {
|
||||
summary
|
||||
}
|
||||
|
||||
pub fn bash_tool(session: WorkdirSessionHandle, _output_dir: PathBuf) -> ToolDefinition {
|
||||
pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(BashParams);
|
||||
let meta = ToolMeta::new("Bash")
|
||||
.description("Execute a shell command in the bound Workdir. Process start, bounded output, timeout and cancellation are owned by the WorkdirSession provider. This is not a sandbox.")
|
||||
.description("Execute a shell command in the bound Workdir. Process start, bounded inline output, full-output spill, timeout and cancellation are owned by the WorkdirSession provider. This is not a sandbox.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
||||
session: session.clone(),
|
||||
output_dir: output_dir.clone(),
|
||||
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use tempfile::TempDir;
|
||||
use workdir::{LocalWorkdirSession, WorkdirSessionHandle};
|
||||
|
||||
use super::bash_tool;
|
||||
use crate::{grep::grep_tool, read::read_tool, tracker::Tracker};
|
||||
|
||||
fn session_with_output_scope(root: &TempDir, output: &TempDir) -> WorkdirSessionHandle {
|
||||
let scope = Scope::from_config(&ScopeConfig {
|
||||
allow: vec![
|
||||
ScopeRule {
|
||||
target: root.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
},
|
||||
ScopeRule {
|
||||
target: output.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
},
|
||||
],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
Arc::new(LocalWorkdirSession::new(scope, root.path().to_path_buf()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_output_is_spilled_and_available_to_read_and_grep() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let output = TempDir::new().unwrap();
|
||||
let session = session_with_output_scope(&root, &output);
|
||||
let (_, bash) = bash_tool(session.clone(), output.path().to_path_buf())();
|
||||
let command = "i=0; while [ $i -lt 2000 ]; do printf 'line-%04d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'";
|
||||
let result = bash
|
||||
.execute(
|
||||
&serde_json::json!({ "command": command }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let rendered = result.content.expect("bounded Bash output");
|
||||
let artifact = std::fs::read_dir(output.path())
|
||||
.unwrap()
|
||||
.next()
|
||||
.expect("artifact entry")
|
||||
.unwrap()
|
||||
.path();
|
||||
|
||||
assert!(rendered.contains("full output saved to"));
|
||||
assert!(rendered.contains(&artifact.display().to_string()));
|
||||
let retained = std::fs::read_to_string(&artifact).unwrap();
|
||||
assert!(retained.starts_with("line-0000\n"));
|
||||
assert!(retained.ends_with("FINAL-NEEDLE\n"));
|
||||
assert_eq!(retained.lines().count(), 2001);
|
||||
|
||||
let (_, read) = read_tool(session.clone(), Tracker::new())();
|
||||
let read_result = read
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"file_path": artifact,
|
||||
"offset": 2000,
|
||||
"limit": 1,
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
read_result
|
||||
.content
|
||||
.expect("Read content")
|
||||
.contains("FINAL-NEEDLE")
|
||||
);
|
||||
|
||||
let (_, grep) = grep_tool(session)();
|
||||
let grep_result = grep
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"pattern": "FINAL-NEEDLE",
|
||||
"path": artifact,
|
||||
"output_mode": "content",
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let grep_content = grep_result.content.expect("Grep content");
|
||||
assert!(
|
||||
grep_content.contains("FINAL-NEEDLE"),
|
||||
"unexpected Grep content: {grep_content:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn short_output_does_not_leave_a_spill_artifact() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let output = TempDir::new().unwrap();
|
||||
let session = session_with_output_scope(&root, &output);
|
||||
let (_, bash) = bash_tool(session, output.path().to_path_buf())();
|
||||
|
||||
let result = bash
|
||||
.execute(
|
||||
&serde_json::json!({ "command": "printf short" }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.content.as_deref(), Some("short"));
|
||||
assert_eq!(std::fs::read_dir(output.path()).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ impl From<ToolsError> for ToolError {
|
||||
workdir::WorkdirError::NotFound(_)
|
||||
| workdir::WorkdirError::Io { .. }
|
||||
| workdir::WorkdirError::Unavailable(_)
|
||||
| workdir::WorkdirError::OperationFailed
|
||||
| workdir::WorkdirError::Transport(_),
|
||||
) => ToolError::ExecutionFailed(err.to_string()),
|
||||
ToolsError::FileSystem(_)
|
||||
|
||||
@@ -22,7 +22,7 @@ enum OutputMode {
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GrepParams {
|
||||
pattern: String,
|
||||
/// Logical Workdir-relative path to search. Defaults to the Workdir root.
|
||||
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -61,7 +61,7 @@ impl Tool for GrepTool {
|
||||
let params: GrepParams = serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?;
|
||||
let path = match params.path {
|
||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
||||
Some(path) => WorkdirPath::new_scoped(&path).map_err(ToolsError::from)?,
|
||||
None => WorkdirPath::root(),
|
||||
};
|
||||
let mode = match params.output_mode.unwrap_or_default() {
|
||||
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(GrepParams);
|
||||
let meta = ToolMeta::new("Grep")
|
||||
.description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
||||
.description("Search a Workdir file or directory with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Directory traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||
session: session.clone(),
|
||||
|
||||
@@ -13,14 +13,14 @@ use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
||||
const DESCRIPTION: &str = "Read a text file from the local filesystem. \
|
||||
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
||||
Directories cannot be read. The file must be read before Write or Edit can \
|
||||
modify it. Paths are relative to the bound Workdir.";
|
||||
modify it. Paths are Workdir-relative unless an absolute path is explicitly readable.";
|
||||
|
||||
const DEFAULT_LIMIT: usize = 2000;
|
||||
const PROVIDER_BYTE_LIMIT: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct ReadParams {
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
/// Workdir-relative path, or an absolute path covered by readable scope.
|
||||
pub file_path: String,
|
||||
/// 0-based line offset from the start. Defaults to 0.
|
||||
#[serde(default)]
|
||||
@@ -47,7 +47,7 @@ impl Tool for ReadTool {
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
let path = WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, offset, limit, "Read");
|
||||
|
||||
let result = self
|
||||
|
||||
@@ -131,10 +131,11 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
assert!(
|
||||
msg.contains("outside allowed read scope")
|
||||
|| msg.contains("outside allowed write scope")
|
||||
|| msg.contains("outside allowed scope")
|
||||
|| msg.contains("has not been read"),
|
||||
"symlink escape not rejected: {msg}"
|
||||
);
|
||||
if !msg.contains("has not been read") {
|
||||
if msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope") {
|
||||
assert!(
|
||||
msg.contains("add the symlink target"),
|
||||
"symlink escape diagnostic should include remediation: {msg}"
|
||||
@@ -223,22 +224,29 @@ async fn very_long_single_line() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absolute_path_is_rejected() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
async fn absolute_path_requires_matching_read_scope() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let outside_file = outside.path().join("outside.txt");
|
||||
std::fs::write(&outside_file, "secret").unwrap();
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(
|
||||
&json!({ "file_path": dir.path().join("outside.txt") }).to_string(),
|
||||
&json!({ "file_path": outside_file }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("invalid Workdir path"));
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("outside allowed scope"),
|
||||
"absolute path escaped readable scope: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_target_is_rejected_for_read() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolMeta};
|
||||
use agen::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle,
|
||||
ToolExecutionTerminal, ToolMeta,
|
||||
};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
@@ -191,7 +194,7 @@ async fn write_then_grep_finds_content() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_finds_written_files() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let write = reg.get("Write");
|
||||
let glob = reg.get("Glob");
|
||||
|
||||
@@ -229,7 +232,10 @@ async fn absolute_path_is_rejected() {
|
||||
.await;
|
||||
// Absolute paths are rejected at the logical WorkdirSession boundary.
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}");
|
||||
assert!(
|
||||
msg.contains("invalid logical filesystem path"),
|
||||
"unexpected: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -340,7 +346,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
||||
));
|
||||
|
||||
let a = dir.path().join("a.txt");
|
||||
let b = dir.path().join("b.txt");
|
||||
let _b = dir.path().join("b.txt");
|
||||
std::fs::write(&a, "one\n").unwrap();
|
||||
|
||||
// Read `a` — should appear in recency.
|
||||
@@ -388,14 +394,100 @@ async fn bash_inherits_workdir_cwd() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_provider_output_does_not_expose_internal_paths() {
|
||||
async fn bash_provider_output_exposes_readable_retained_path() {
|
||||
let (_dir, spill, reg) = setup();
|
||||
let bash = reg.get("Bash");
|
||||
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("bounded WorkdirSession command output"));
|
||||
assert!(!body.contains(spill.path().to_str().unwrap()));
|
||||
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
|
||||
assert!(body.contains("full output saved to"));
|
||||
assert!(body.contains(spill.path().to_str().unwrap()));
|
||||
let artifact = std::fs::read_dir(spill.path())
|
||||
.unwrap()
|
||||
.next()
|
||||
.expect("retained output")
|
||||
.unwrap()
|
||||
.path();
|
||||
assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let marker = dir.path().join("must-not-run-after-cancel");
|
||||
let command = format!(
|
||||
"printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'",
|
||||
marker.display()
|
||||
);
|
||||
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
|
||||
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
|
||||
let bash = reg.get("Bash");
|
||||
let executing = bash.clone();
|
||||
let execution_context = context.clone();
|
||||
let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await });
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
bash.cancel_execution(&context)
|
||||
.await
|
||||
.expect("signal exact execution cancellation");
|
||||
let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution)
|
||||
.await
|
||||
.expect("cancelled Bash should terminate inside the Engine grace budget")
|
||||
.expect("Bash task join");
|
||||
|
||||
let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else {
|
||||
panic!("expected typed cancellation result");
|
||||
};
|
||||
let content = output.content.expect("bounded progress output");
|
||||
assert!(
|
||||
content.contains("before"),
|
||||
"missing pre-cancel stdout: {content}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("err-before"),
|
||||
"missing pre-cancel stderr: {content}"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("after"),
|
||||
"post-cancel output leaked: {content}"
|
||||
);
|
||||
assert!(content.len() <= 16 * 1024, "output must remain bounded");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"the cancelled command continued executing after terminal confirmation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let marker = dir.path().join("must-not-survive-force-close");
|
||||
let command = format!("sleep 1; touch {}", marker.display());
|
||||
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
|
||||
let bash = reg.get("Bash");
|
||||
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
|
||||
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
handle.force_close();
|
||||
assert!(matches!(
|
||||
terminal.await,
|
||||
ToolExecutionTerminal::OutcomeUnknown
|
||||
));
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"CommandGuard cleanup allowed a force-closed command to continue"
|
||||
);
|
||||
|
||||
let output = bash
|
||||
.execute(r#"{"command":"printf 'reused'"}"#, Default::default())
|
||||
.await
|
||||
.expect("workdir session remains reusable after cleanup");
|
||||
assert_eq!(output.content.as_deref(), Some("reused"));
|
||||
}
|
||||
|
||||
// Sanity: unused Path import guard
|
||||
|
||||
@@ -10,11 +10,13 @@ e2e-test = []
|
||||
|
||||
[dependencies]
|
||||
client = { workspace = true }
|
||||
standalone = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
protocol = { workspace = true }
|
||||
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
|
||||
base64 = "0.22.1"
|
||||
crossterm = "0.28"
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
serde_json = { workspace = true }
|
||||
unicode-width = "0.2.2"
|
||||
uuid = { workspace = true }
|
||||
@@ -22,10 +24,8 @@ toml = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
secrets = { workspace = true }
|
||||
session-store = { workspace = true }
|
||||
fs4 = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
worker = { path = "../worker" }
|
||||
pulldown-cmark = { version = "0.13.3", default-features = false }
|
||||
agen.workspace = true
|
||||
|
||||
|
||||
+239
-194
@@ -249,6 +249,9 @@ pub struct App {
|
||||
pub running: bool,
|
||||
/// True while the Worker is in `WorkerStatus::Paused`.
|
||||
pub paused: bool,
|
||||
/// Local observation time for the current run. Used only for live UI
|
||||
/// elapsed time and spinner animation; it is not persisted in history.
|
||||
pub run_started_at: Option<Instant>,
|
||||
pub run_requests: usize,
|
||||
/// Sum of `input_tokens - cache_read_input_tokens` across the
|
||||
/// current turn's LLM requests — i.e. the net tokens this turn
|
||||
@@ -281,6 +284,9 @@ pub struct App {
|
||||
/// records the instant; a second press within the timeout exits the
|
||||
/// TUI (the Worker itself stays alive).
|
||||
pub quit_confirm: Option<std::time::Instant>,
|
||||
/// Independent 2-tap guard for `Ctrl-X` when the Worker is idle or
|
||||
/// stopped. A second press within the timeout shuts down the Worker.
|
||||
pub shutdown_confirm: Option<std::time::Instant>,
|
||||
/// Full display history in render order.
|
||||
pub blocks: Vec<Block>,
|
||||
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
|
||||
@@ -352,6 +358,7 @@ impl App {
|
||||
worker_status: WorkerStatus::Idle,
|
||||
running: false,
|
||||
paused: false,
|
||||
run_started_at: None,
|
||||
run_requests: 0,
|
||||
run_upload_tokens: 0,
|
||||
run_output_tokens: 0,
|
||||
@@ -369,6 +376,7 @@ impl App {
|
||||
command_completion_selected: None,
|
||||
quit: false,
|
||||
quit_confirm: None,
|
||||
shutdown_confirm: None,
|
||||
blocks: Vec::new(),
|
||||
run_error_messages: Vec::new(),
|
||||
internal_workers: Vec::new(),
|
||||
@@ -553,11 +561,18 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn set_worker_status(&mut self, status: WorkerStatus) {
|
||||
let was_running = self.running;
|
||||
self.worker_status = status;
|
||||
self.running = status == WorkerStatus::Running;
|
||||
self.paused = status == WorkerStatus::Paused;
|
||||
if self.running {
|
||||
if !was_running {
|
||||
self.run_started_at = Some(Instant::now());
|
||||
}
|
||||
self.quit_confirm = None;
|
||||
self.shutdown_confirm = None;
|
||||
} else {
|
||||
self.run_started_at = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,7 +780,7 @@ impl App {
|
||||
|
||||
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
|
||||
// TurnHeader / UserMessage blocks are pushed only after the Worker
|
||||
// emits `Event::UserMessage` from a committed `LogEntry::UserInput`.
|
||||
// emits `Event::UserMessage` from a committed `LogEntry::AnnotatedUserInput`.
|
||||
// Locally we only clear the input buffer and forward the method,
|
||||
// while remembering enough local state to undo the visible submit if
|
||||
// the accepted run produced no assistant output and was rolled back.
|
||||
@@ -1098,10 +1113,9 @@ impl App {
|
||||
self.blocks.push(Block::UserMessage { segments });
|
||||
self.assistant_streaming = false;
|
||||
}
|
||||
Event::SegmentRotated { entry } => {
|
||||
Event::SegmentRotated { session } => {
|
||||
let retained_run_errors = self.run_error_messages.clone();
|
||||
self.reset_for_rotation();
|
||||
self.apply_log_entry_raw(&entry);
|
||||
self.restore_session(&session, self.greeting.clone());
|
||||
for message in retained_run_errors {
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Error,
|
||||
@@ -1122,11 +1136,13 @@ impl App {
|
||||
self.latest_llm_wait_event = None;
|
||||
self.assistant_streaming = false;
|
||||
}
|
||||
// UI consumers of Invoke / LlmCall semantics are out of scope
|
||||
// for `tickets/invoke-turn-llmcall-semantics.md`; events flow
|
||||
// through to subscribers but the TUI currently derives its
|
||||
// turn header from `UserMessage` / `SystemItem` arrivals.
|
||||
Event::InvokeStart { .. } | Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
|
||||
Event::InvokeStart { .. } => {
|
||||
self.set_worker_status(WorkerStatus::Running);
|
||||
}
|
||||
// UI consumers of per-attempt LlmCall semantics remain out of scope;
|
||||
// the run-level status starts at InvokeStart and TurnStart counts each
|
||||
// LLM request within that run.
|
||||
Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
|
||||
self.latest_llm_wait_event = None;
|
||||
}
|
||||
Event::LlmRetry {
|
||||
@@ -1244,6 +1260,7 @@ impl App {
|
||||
id,
|
||||
summary,
|
||||
output,
|
||||
disposition: _,
|
||||
is_error,
|
||||
} => {
|
||||
self.latest_llm_wait_event = None;
|
||||
@@ -1342,13 +1359,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::CompactStart => {
|
||||
Event::CompactStart { .. } => {
|
||||
if self.last_streaming_compact_mut().is_none() {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
}));
|
||||
}
|
||||
Event::CompactDone { new_segment_id } => {
|
||||
}
|
||||
Event::CompactDone { lifecycle } => {
|
||||
self.session_context_tokens = 0;
|
||||
let new_segment_id = lifecycle
|
||||
.new_segment_id
|
||||
.as_deref()
|
||||
.and_then(|value| uuid::Uuid::parse_str(value).ok())
|
||||
.unwrap_or_default();
|
||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||
let elapsed_secs = match evt {
|
||||
CompactEvent::Streaming { started_at } => {
|
||||
@@ -1367,7 +1391,10 @@ impl App {
|
||||
}));
|
||||
}
|
||||
}
|
||||
Event::CompactFailed { error } => {
|
||||
Event::CompactFailed { lifecycle } => {
|
||||
let error = lifecycle
|
||||
.error
|
||||
.unwrap_or_else(|| "compaction failed".to_string());
|
||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||
let elapsed_secs = match evt {
|
||||
CompactEvent::Streaming { started_at } => {
|
||||
@@ -1397,14 +1424,14 @@ impl App {
|
||||
self.latest_memory_worker_event = Some(event.message);
|
||||
}
|
||||
Event::Snapshot {
|
||||
entries,
|
||||
session,
|
||||
greeting,
|
||||
status,
|
||||
in_flight,
|
||||
internal_workers,
|
||||
} => {
|
||||
self.rewind_refresh_fence = false;
|
||||
self.restore_snapshot(&entries, greeting, in_flight);
|
||||
self.restore_snapshot(&session, greeting, in_flight);
|
||||
self.replace_internal_worker_snapshots(internal_workers);
|
||||
self.set_worker_status(status);
|
||||
}
|
||||
@@ -1444,11 +1471,11 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::RewindApplied {
|
||||
entries,
|
||||
session,
|
||||
input,
|
||||
summary,
|
||||
} => {
|
||||
self.restore_rewind_snapshot(&entries);
|
||||
self.restore_rewind_snapshot(&session);
|
||||
self.rewind_refresh_fence = true;
|
||||
let restored_composer = if self.input.is_empty() {
|
||||
self.input.replace_with_segments(&input);
|
||||
@@ -2162,7 +2189,7 @@ impl App {
|
||||
) -> InternalWorkerView {
|
||||
let mut app = App::new(snapshot.worker.name.clone());
|
||||
app.mode = mode;
|
||||
app.restore_entries(&snapshot.entries, None);
|
||||
app.restore_session(&snapshot.session, None);
|
||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||
app.set_worker_status(snapshot.status);
|
||||
if let Some(error) = snapshot.error {
|
||||
@@ -2243,14 +2270,14 @@ impl App {
|
||||
|
||||
fn restore_snapshot(
|
||||
&mut self,
|
||||
entries: &[serde_json::Value],
|
||||
session: &protocol::SessionSnapshot,
|
||||
greeting: protocol::Greeting,
|
||||
in_flight: InFlightSnapshot,
|
||||
) {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
self.restore_entries(entries, Some(greeting));
|
||||
self.restore_session(session, Some(greeting));
|
||||
self.apply_in_flight_snapshot(in_flight);
|
||||
}
|
||||
|
||||
@@ -2259,7 +2286,7 @@ impl App {
|
||||
/// session tail; always clear/replay from it even if this TUI instance has
|
||||
/// somehow lost connect-time greeting metadata. Skipping the restore in
|
||||
/// that case would leave old post-target output visible after success.
|
||||
fn restore_rewind_snapshot(&mut self, entries: &[serde_json::Value]) {
|
||||
fn restore_rewind_snapshot(&mut self, session: &protocol::SessionSnapshot) {
|
||||
let greeting = self.greeting.clone().or_else(|| {
|
||||
self.blocks.iter().find_map(|b| match b {
|
||||
Block::Greeting(g) => Some(g.clone()),
|
||||
@@ -2272,7 +2299,7 @@ impl App {
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
}
|
||||
let missing_greeting = greeting.is_none();
|
||||
self.restore_entries(entries, greeting);
|
||||
self.restore_session(session, greeting);
|
||||
if missing_greeting {
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Warn,
|
||||
@@ -2282,9 +2309,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_entries(
|
||||
fn restore_session(
|
||||
&mut self,
|
||||
entries: &[serde_json::Value],
|
||||
session: &protocol::SessionSnapshot,
|
||||
greeting: Option<protocol::Greeting>,
|
||||
) {
|
||||
self.run_error_messages.clear();
|
||||
@@ -2298,78 +2325,83 @@ impl App {
|
||||
}
|
||||
self.assistant_streaming = false;
|
||||
|
||||
for entry in entries {
|
||||
self.apply_log_entry_raw(entry);
|
||||
}
|
||||
|
||||
self.mark_orphan_tool_calls_incomplete_pass();
|
||||
}
|
||||
|
||||
/// Drop the derived view in preparation for replaying a new
|
||||
/// `SegmentStart` (compaction / fork). Greeting is preserved
|
||||
/// because the Worker identity hasn't changed.
|
||||
fn reset_for_rotation(&mut self) {
|
||||
let greeting = self.blocks.iter().find_map(|b| match b {
|
||||
Block::Greeting(g) => Some(g.clone()),
|
||||
_ => None,
|
||||
});
|
||||
self.turn_index = 0;
|
||||
self.blocks.clear();
|
||||
self.cache = FileCache::new();
|
||||
self.task_store = TaskStore::new();
|
||||
self.task_pane_scroll = 0;
|
||||
if let Some(g) = greeting {
|
||||
self.greeting = Some(g.clone());
|
||||
self.blocks.push(Block::Greeting(g));
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a single `LogEntry` JSON value and translate it into blocks
|
||||
/// the live event path would have produced. Shared between
|
||||
/// `restore_snapshot` (replay path) and `apply_log_entry` (live
|
||||
/// path).
|
||||
fn apply_log_entry_raw(&mut self, value: &serde_json::Value) {
|
||||
let Ok(entry) = serde_json::from_value::<session_store::LogEntry>(value.clone()) else {
|
||||
return;
|
||||
};
|
||||
match entry {
|
||||
session_store::LogEntry::SegmentStart { history, .. } => {
|
||||
for logged in history {
|
||||
let item: agen::Item = logged.into();
|
||||
let item_value = serde_json::to_value(&item).expect("Item is Serialize");
|
||||
self.push_history_item(&item_value);
|
||||
}
|
||||
}
|
||||
session_store::LogEntry::UserInput { segments, .. } => {
|
||||
for entry in &session.entries {
|
||||
use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
|
||||
match &entry.data {
|
||||
SessionSnapshotEntryData::UserInput { segments } => {
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
});
|
||||
if !segments.is_empty() {
|
||||
self.blocks.push(Block::UserMessage { segments });
|
||||
self.blocks.push(Block::UserMessage {
|
||||
segments: segments.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
session_store::LogEntry::AssistantItem { item, .. }
|
||||
| session_store::LogEntry::ToolResult { item, .. } => {
|
||||
let it: agen::Item = item.into();
|
||||
let item_value = serde_json::to_value(&it).expect("Item is Serialize");
|
||||
self.push_history_item(&item_value);
|
||||
SessionSnapshotEntryData::Message { role, content } => {
|
||||
let role = match role {
|
||||
SessionMessageRole::User => agen::Role::User,
|
||||
SessionMessageRole::Assistant => agen::Role::Assistant,
|
||||
};
|
||||
let item = agen::Item::Message {
|
||||
id: None,
|
||||
role,
|
||||
content: content
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
SessionContentPart::Text { text } => {
|
||||
agen::ContentPart::Text { text: text.clone() }
|
||||
}
|
||||
session_store::LogEntry::SystemItem { item, .. } => {
|
||||
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
|
||||
self.apply_system_item(&value);
|
||||
SessionContentPart::Refusal { refusal } => {
|
||||
agen::ContentPart::Refusal {
|
||||
refusal: refusal.clone(),
|
||||
}
|
||||
session_store::LogEntry::Extension {
|
||||
domain, payload, ..
|
||||
} if domain == "yoi.compaction" => {
|
||||
self.apply_compaction_extension(&payload);
|
||||
}
|
||||
session_store::LogEntry::RunErrored { message, .. } => {
|
||||
self.push_run_error(message);
|
||||
})
|
||||
.collect(),
|
||||
status: None,
|
||||
};
|
||||
let value = serde_json::to_value(item).expect("Item is Serialize");
|
||||
self.push_history_item(&value);
|
||||
}
|
||||
// Non-history-bearing variants don't affect the block view.
|
||||
_ => {}
|
||||
SessionSnapshotEntryData::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
let item =
|
||||
agen::Item::tool_call(call_id.clone(), name.clone(), arguments.clone());
|
||||
let value = serde_json::to_value(item).expect("Item is Serialize");
|
||||
self.push_history_item(&value);
|
||||
}
|
||||
SessionSnapshotEntryData::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
let item = agen::Item::tool_result_item(
|
||||
call_id.clone(),
|
||||
summary.clone(),
|
||||
content.clone(),
|
||||
*is_error,
|
||||
);
|
||||
let value = serde_json::to_value(item).expect("Item is Serialize");
|
||||
self.push_history_item(&value);
|
||||
}
|
||||
SessionSnapshotEntryData::SystemItem { data, .. } => {
|
||||
if let Some(data) = data {
|
||||
self.apply_system_item(data);
|
||||
}
|
||||
}
|
||||
SessionSnapshotEntryData::RunError { message } => {
|
||||
self.push_run_error(message.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mark_orphan_tool_calls_incomplete_pass();
|
||||
}
|
||||
|
||||
/// Dispatch one `SystemItem` JSON value into the appropriate block.
|
||||
@@ -2377,58 +2409,6 @@ impl App {
|
||||
/// Kind-based routing replaces the old free-text `[Notification]` /
|
||||
/// `[File: …]` parsing path: each kind maps directly to a typed
|
||||
/// block (`Block::Notify`, `Block::WorkerEvent`, …).
|
||||
fn apply_compaction_extension(&mut self, payload: &serde_json::Value) {
|
||||
if payload.get("kind").and_then(|value| value.as_str()) != Some("compaction_block") {
|
||||
return;
|
||||
}
|
||||
match payload.get("state").and_then(|value| value.as_str()) {
|
||||
Some("running") => {
|
||||
if self.last_streaming_compact_mut().is_none() {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Some("done") => {
|
||||
let new_segment_id = payload
|
||||
.get("new_segment_id")
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(|value| value.parse::<uuid::Uuid>().ok())
|
||||
.unwrap_or_else(uuid::Uuid::nil);
|
||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||
*evt = CompactEvent::Done {
|
||||
new_segment_id,
|
||||
elapsed_secs: None,
|
||||
};
|
||||
} else {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Done {
|
||||
new_segment_id,
|
||||
elapsed_secs: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Some("failed") => {
|
||||
let error = payload
|
||||
.get("error")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("compact failed")
|
||||
.to_string();
|
||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||
*evt = CompactEvent::Failed {
|
||||
error,
|
||||
elapsed_secs: None,
|
||||
};
|
||||
} else {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Failed {
|
||||
error,
|
||||
elapsed_secs: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_system_item(&mut self, value: &serde_json::Value) {
|
||||
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
|
||||
// Unknown / forward-compat shape: fall back to rendering the
|
||||
@@ -2486,7 +2466,7 @@ fn event_is_stale_after_rewind(event: &Event) -> bool {
|
||||
event,
|
||||
Event::Alert(_)
|
||||
| Event::MemoryWorker(_)
|
||||
| Event::CompactStart
|
||||
| Event::CompactStart { .. }
|
||||
| Event::CompactDone { .. }
|
||||
| Event::CompactFailed { .. }
|
||||
| Event::SegmentRotated { .. }
|
||||
@@ -2531,6 +2511,15 @@ fn fmt_millis(ms: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn public_session(values: Vec<serde_json::Value>) -> protocol::SessionSnapshot {
|
||||
let entries = values
|
||||
.into_iter()
|
||||
.map(|value| serde_json::from_value(value).expect("LogEntry deserializes"))
|
||||
.collect::<Vec<session_store::LogEntry>>();
|
||||
session_store::public_snapshot::project_current_session_snapshot(&entries)
|
||||
}
|
||||
|
||||
fn message_text(item: &serde_json::Value) -> String {
|
||||
item["content"]
|
||||
.as_array()
|
||||
@@ -2674,7 +2663,7 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::text("selected rewind input")],
|
||||
summary: summary(3),
|
||||
});
|
||||
@@ -2693,7 +2682,7 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::text("rewound input")],
|
||||
summary: summary(1),
|
||||
});
|
||||
@@ -2736,7 +2725,7 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::text("rewound input")],
|
||||
summary: summary(2),
|
||||
});
|
||||
@@ -2965,6 +2954,17 @@ mod composer_history_persistence_tests {
|
||||
mod completion_flow_tests {
|
||||
use super::*;
|
||||
|
||||
fn annotated(item: agen::Item) -> session_store::LoggedHistoryEntry {
|
||||
session_store::LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::from(item),
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_at_creates_completion_state_and_emits_query() {
|
||||
let mut app = App::new("test".into());
|
||||
@@ -3267,7 +3267,7 @@ mod completion_flow_tests {
|
||||
#[test]
|
||||
fn committed_user_message_survives_fresh_segment_rotation() {
|
||||
let mut app = App::new("test".into());
|
||||
let start = session_store::LogEntry::SegmentStart {
|
||||
let start = session_store::LogEntry::AnnotatedSegmentStart {
|
||||
ts: session_store::segment_log::now_millis(),
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -3278,7 +3278,9 @@ mod completion_flow_tests {
|
||||
};
|
||||
|
||||
app.handle_worker_event(Event::SegmentRotated {
|
||||
entry: serde_json::to_value(start).expect("LogEntry is Serialize"),
|
||||
session: public_session(vec![
|
||||
serde_json::to_value(start).expect("LogEntry is Serialize"),
|
||||
]),
|
||||
});
|
||||
app.handle_worker_event(Event::UserMessage {
|
||||
segments: vec![Segment::text("first persisted message")],
|
||||
@@ -3392,6 +3394,17 @@ mod completion_flow_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_status_starts_and_stops_live_run_clock() {
|
||||
let mut app = App::new("test".into());
|
||||
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
assert!(app.run_started_at.is_some());
|
||||
|
||||
app.set_worker_status(WorkerStatus::Idle);
|
||||
assert!(app.run_started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_submit_is_queued_locally_and_clears_composer() {
|
||||
let mut app = App::new("test".into());
|
||||
@@ -3522,23 +3535,23 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_renders_system_message_block_from_session_start() {
|
||||
fn snapshot_excludes_system_prompt_history_from_public_blocks() {
|
||||
let mut app = App::new("test".into());
|
||||
let session_start = session_store::LogEntry::SegmentStart {
|
||||
let session_start = session_store::LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
config: Default::default(),
|
||||
history: vec![session_store::LoggedItem::from(
|
||||
&agen::Item::system_message("[File: src/main.rs]\nfn main() {}"),
|
||||
)],
|
||||
history: vec![annotated(agen::Item::system_message(
|
||||
"[File: src/main.rs]\nfn main() {}",
|
||||
))],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let session_start_value = serde_json::to_value(&session_start).unwrap();
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![session_start_value],
|
||||
session: public_session(vec![session_start_value]),
|
||||
status: WorkerStatus::Running,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
@@ -3546,10 +3559,8 @@ mod completion_flow_tests {
|
||||
|
||||
assert!(matches!(app.worker_status, WorkerStatus::Running));
|
||||
assert!(app.running);
|
||||
assert!(matches!(
|
||||
app.blocks.get(1),
|
||||
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
|
||||
));
|
||||
assert_eq!(app.blocks.len(), 1);
|
||||
assert!(matches!(app.blocks.first(), Some(Block::Greeting(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3584,7 +3595,7 @@ mod completion_flow_tests {
|
||||
};
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![serde_json::to_value(run_errored).unwrap()],
|
||||
session: public_session(vec![serde_json::to_value(run_errored).unwrap()]),
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
@@ -3612,7 +3623,7 @@ mod completion_flow_tests {
|
||||
code: ErrorCode::ProviderError,
|
||||
message: "provider unavailable".into(),
|
||||
});
|
||||
let segment_start = session_store::LogEntry::SegmentStart {
|
||||
let segment_start = session_store::LogEntry::AnnotatedSegmentStart {
|
||||
ts: 5,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -3622,7 +3633,7 @@ mod completion_flow_tests {
|
||||
compacted_from: None,
|
||||
};
|
||||
app.handle_worker_event(Event::SegmentRotated {
|
||||
entry: serde_json::to_value(segment_start).unwrap(),
|
||||
session: public_session(vec![serde_json::to_value(segment_start).unwrap()]),
|
||||
});
|
||||
|
||||
let errors = app
|
||||
@@ -3645,7 +3656,9 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Running,
|
||||
in_flight: InFlightSnapshot {
|
||||
blocks: vec![
|
||||
@@ -3751,7 +3764,9 @@ mod completion_flow_tests {
|
||||
},
|
||||
revision,
|
||||
status: WorkerStatus::Idle,
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
in_flight: protocol::InFlightSnapshot::default(),
|
||||
error: None,
|
||||
internal_workers: Vec::new(),
|
||||
@@ -3966,7 +3981,9 @@ mod completion_flow_tests {
|
||||
assert_eq!(app.selected_worker_view().worker_name, "parent");
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
@@ -4015,7 +4032,9 @@ mod completion_flow_tests {
|
||||
});
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: vec![InternalWorkerSnapshot {
|
||||
@@ -4026,7 +4045,9 @@ mod completion_flow_tests {
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 4,
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Running,
|
||||
error: None,
|
||||
in_flight: Default::default(),
|
||||
@@ -4076,13 +4097,34 @@ mod completion_flow_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_compaction_lifecycle(
|
||||
state: protocol::CompactionLifecycleState,
|
||||
) -> protocol::CompactionLifecycle {
|
||||
protocol::CompactionLifecycle {
|
||||
schema_version: 2,
|
||||
compaction_id: "compaction-test".into(),
|
||||
revision: 1,
|
||||
internal_worker: None,
|
||||
state,
|
||||
started_at_ms: 1,
|
||||
ended_at_ms: None,
|
||||
summary: None,
|
||||
error: None,
|
||||
new_segment_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_done_replaces_live_block() {
|
||||
let mut app = App::new("test".into());
|
||||
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
||||
|
||||
app.handle_worker_event(Event::CompactStart);
|
||||
app.handle_worker_event(Event::CompactDone { new_segment_id: id });
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||
lifecycle.new_segment_id = Some(id.to_string());
|
||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||
|
||||
assert_eq!(compact_block_count(&app), 1);
|
||||
assert!(matches!(
|
||||
@@ -4098,10 +4140,12 @@ mod completion_flow_tests {
|
||||
fn compact_failed_replaces_live_block() {
|
||||
let mut app = App::new("test".into());
|
||||
|
||||
app.handle_worker_event(Event::CompactStart);
|
||||
app.handle_worker_event(Event::CompactFailed {
|
||||
error: "provider 429".into(),
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
|
||||
lifecycle.error = Some("provider 429".into());
|
||||
app.handle_worker_event(Event::CompactFailed { lifecycle });
|
||||
|
||||
assert_eq!(compact_block_count(&app), 1);
|
||||
assert!(matches!(
|
||||
@@ -4117,7 +4161,9 @@ mod completion_flow_tests {
|
||||
fn shutdown_marks_live_compact_incomplete() {
|
||||
let mut app = App::new("test".into());
|
||||
|
||||
app.handle_worker_event(Event::CompactStart);
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
app.handle_worker_event(Event::Shutdown);
|
||||
|
||||
assert!(app.quit);
|
||||
@@ -4157,7 +4203,9 @@ mod completion_flow_tests {
|
||||
greeting.context_tokens = 45_000;
|
||||
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting,
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
@@ -4208,9 +4256,9 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.session_context_tokens = 42_000;
|
||||
|
||||
app.handle_worker_event(Event::CompactDone {
|
||||
new_segment_id: uuid::Uuid::nil(),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
|
||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||
|
||||
assert_eq!(app.session_context_tokens, 0);
|
||||
}
|
||||
@@ -4327,40 +4375,37 @@ mod completion_flow_tests {
|
||||
});
|
||||
|
||||
let assistant_item_entries = vec![
|
||||
serde_json::json!({
|
||||
"kind": "assistant_item",
|
||||
"ts": 1,
|
||||
"item": {
|
||||
"kind": "tool_call",
|
||||
"call_id": "c1",
|
||||
"name": "TaskCreate",
|
||||
"arguments": r#"{"subject":"a","description":"A"}"#,
|
||||
},
|
||||
}),
|
||||
serde_json::json!({
|
||||
"kind": "assistant_item",
|
||||
"ts": 2,
|
||||
"item": {
|
||||
"kind": "tool_call",
|
||||
"call_id": "c2",
|
||||
"name": "TaskCreate",
|
||||
"arguments": r#"{"subject":"b","description":"B"}"#,
|
||||
},
|
||||
}),
|
||||
serde_json::json!({
|
||||
"kind": "assistant_item",
|
||||
"ts": 3,
|
||||
"item": {
|
||||
"kind": "tool_call",
|
||||
"call_id": "u1",
|
||||
"name": "TaskUpdate",
|
||||
"arguments": r#"{"taskid":2,"status":"inprogress"}"#,
|
||||
},
|
||||
}),
|
||||
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
|
||||
ts: 1,
|
||||
entry: annotated(agen::Item::tool_call(
|
||||
"c1",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"a","description":"A"}"#,
|
||||
)),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
|
||||
ts: 2,
|
||||
entry: annotated(agen::Item::tool_call(
|
||||
"c2",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"b","description":"B"}"#,
|
||||
)),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
|
||||
ts: 3,
|
||||
entry: annotated(agen::Item::tool_call(
|
||||
"u1",
|
||||
"TaskUpdate",
|
||||
r#"{"taskid":2,"status":"inprogress"}"#,
|
||||
)),
|
||||
})
|
||||
.unwrap(),
|
||||
];
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: assistant_item_entries,
|
||||
session: public_session(assistant_item_entries),
|
||||
status: WorkerStatus::Running,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
|
||||
+289
-365
@@ -1,8 +1,7 @@
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -21,26 +20,19 @@ use protocol::{Event, Method, WorkerStatus};
|
||||
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use session_store::SegmentId;
|
||||
use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand};
|
||||
use client::transport::Socket;
|
||||
use client::{BackendRuntimeTarget, Client, StandaloneWorkerResumeIntent, connect_backend_runtime};
|
||||
|
||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
||||
use crate::picker::PickerOutcome;
|
||||
use crate::spawn::{SpawnOutcome, SpawnReady};
|
||||
use crate::{picker, spawn, ui};
|
||||
use crate::ui;
|
||||
|
||||
pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||
|
||||
/// Narrow request bridge used when the workspace Dashboard opens a Worker Console.
|
||||
pub(crate) struct DashboardConsoleOpenRequest {
|
||||
pub(crate) worker_name: String,
|
||||
pub(crate) socket_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Enable SGR coordinates plus normal mouse tracking. This captures clicks,
|
||||
/// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`)
|
||||
/// so terminal-native drag selection remains available during startup.
|
||||
@@ -128,281 +120,145 @@ fn copy_selection_to_terminal(app: &mut App) -> bool {
|
||||
copy_selection_to_writer(app, &mut stdout)
|
||||
}
|
||||
|
||||
fn resolve_socket(worker_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||
if let Some(p) = override_path {
|
||||
return p;
|
||||
}
|
||||
manifest::paths::worker_socket_path(worker_name).unwrap_or_else(|| {
|
||||
PathBuf::from("/tmp")
|
||||
.join("yoi")
|
||||
.join(worker_name)
|
||||
.join("sock")
|
||||
})
|
||||
struct ConsoleConnection<T> {
|
||||
client: Client<T>,
|
||||
standalone_host: Option<StandaloneHost>,
|
||||
}
|
||||
|
||||
pub(crate) async fn run_worker_name(
|
||||
worker_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(feature = "e2e-test")]
|
||||
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
terminal.clear()?;
|
||||
let result = run_e2e_rewind_fixture(&mut terminal, worker_name).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
return result;
|
||||
}
|
||||
|
||||
if let Some(client) = try_connect_live_pod(&worker_name, socket_override.clone()).await {
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
run_connected_pod(&mut terminal, worker_name, client, runtime_command.clone()).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ready = match spawn::run_worker_name(worker_name, runtime_command.clone()).await? {
|
||||
SpawnOutcome::Ready(r) => r,
|
||||
SpawnOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
terminal.clear()?;
|
||||
let result = run_ready_pod(&mut terminal, ready, runtime_command).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
result
|
||||
}
|
||||
|
||||
enum ConsoleConnection {
|
||||
LegacySocket(WorkerClient),
|
||||
BackendRuntime(BackendRuntimeClient),
|
||||
}
|
||||
|
||||
impl ConsoleConnection {
|
||||
fn try_next_event(&mut self) -> Option<Event> {
|
||||
match self {
|
||||
Self::LegacySocket(client) => client.try_next_event(),
|
||||
Self::BackendRuntime(client) => client.try_next_event(),
|
||||
impl<T: Socket> ConsoleConnection<T> {
|
||||
fn new(client: Client<T>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
standalone_host: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_event(&mut self) -> Option<Event> {
|
||||
match self {
|
||||
Self::LegacySocket(client) => client.next_event().await,
|
||||
Self::BackendRuntime(client) => client.next_event().await,
|
||||
fn with_standalone_host(client: Client<T>, host: StandaloneHost) -> Self {
|
||||
Self {
|
||||
client,
|
||||
standalone_host: Some(host),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_next_event(&mut self) -> Result<Option<Event>, Box<dyn std::error::Error>> {
|
||||
Ok(self.client.try_next_event()?)
|
||||
}
|
||||
|
||||
async fn next_event(&mut self) -> Result<Option<Event>, Box<dyn std::error::Error>> {
|
||||
Ok(self.client.next_event().await?)
|
||||
}
|
||||
|
||||
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match self {
|
||||
Self::LegacySocket(client) => Ok(client.send(method).await?),
|
||||
Self::BackendRuntime(client) => Ok(client.send(method).await?),
|
||||
Ok(self.client.send(method).await?)
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(host) = self.standalone_host.take() {
|
||||
host.shutdown().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_standalone(
|
||||
workspace_root: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
worker_name: Option<String>,
|
||||
profile: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let worker_name = worker_name.unwrap_or_else(|| "local".to_string());
|
||||
let profile = profile.map_or(manifest::ProfileSelector::Default, |profile| {
|
||||
manifest::ProfileSelector::parse_cli(&profile)
|
||||
});
|
||||
let history_root = workspace_root.clone();
|
||||
let launch = StandaloneLaunchConfig {
|
||||
state_dir,
|
||||
cwd: workspace_root,
|
||||
profile,
|
||||
worker_name: worker_name.clone(),
|
||||
}
|
||||
.resolve()
|
||||
.map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("Standalone launch configuration failed: {error}"),
|
||||
)
|
||||
})?;
|
||||
let host = StandaloneHost::start(launch)
|
||||
.await
|
||||
.map_err(|error| io::Error::other(format!("Standalone Worker startup failed: {error}")))?;
|
||||
run_standalone_host(host, worker_name, history_root).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_standalone_restore(
|
||||
intent: StandaloneWorkerResumeIntent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let worker_id = intent.worker_id.parse().map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("Invalid standalone Worker ID: {error}"),
|
||||
)
|
||||
})?;
|
||||
let host = StandaloneHost::restore(intent.state_dir, worker_id)
|
||||
.await
|
||||
.map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?;
|
||||
let worker_label = host.record().worker_name.clone();
|
||||
let history_root = host.record().cwd.canonical_path.clone();
|
||||
run_standalone_host(host, worker_label, history_root).await
|
||||
}
|
||||
|
||||
fn standalone_console_app(worker_label: String, history_root: &Path) -> App {
|
||||
let mut app = App::new_with_persistent_input_history(worker_label, history_root);
|
||||
app.connected = true;
|
||||
app
|
||||
}
|
||||
|
||||
async fn run_standalone_host(
|
||||
host: StandaloneHost,
|
||||
worker_label: String,
|
||||
history_root: PathBuf,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = host.connect();
|
||||
let mut connection = ConsoleConnection::with_standalone_host(client, host);
|
||||
|
||||
let mut terminal = match enter_fullscreen() {
|
||||
Ok(terminal) => terminal,
|
||||
Err(error) => {
|
||||
let _ = connection.shutdown().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let mut app = standalone_console_app(worker_label, &history_root);
|
||||
let run_result = run_loop(&mut terminal, &mut app, &mut connection).await;
|
||||
let shutdown_result = connection
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|error| io::Error::other(format!("Standalone Worker shutdown failed: {error}")));
|
||||
let leave_result = leave_fullscreen(&mut terminal);
|
||||
|
||||
if let Err(error) = run_result {
|
||||
return Err(error);
|
||||
}
|
||||
shutdown_result?;
|
||||
leave_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn run_backend_runtime(
|
||||
target: BackendRuntimeTarget,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let worker_label = target.display_label();
|
||||
let client = BackendRuntimeClient::connect(target).await?;
|
||||
let client = connect_backend_runtime(target).await?;
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
|
||||
app.connected = true;
|
||||
let result = run_loop(
|
||||
&mut terminal,
|
||||
&mut app,
|
||||
ConsoleConnection::BackendRuntime(client),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let mut connection = ConsoleConnection::new(client);
|
||||
let result = run_loop(&mut terminal, &mut app, &mut connection).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_connected_pod(
|
||||
terminal: &mut ConsoleTerminal,
|
||||
worker_name: String,
|
||||
client: WorkerClient,
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
|
||||
app.connected = true;
|
||||
run_loop(
|
||||
terminal,
|
||||
&mut app,
|
||||
ConsoleConnection::LegacySocket(client),
|
||||
Some(runtime_command),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn open_from_dashboard(
|
||||
terminal: &mut ConsoleTerminal,
|
||||
request: DashboardConsoleOpenRequest,
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let DashboardConsoleOpenRequest {
|
||||
worker_name,
|
||||
socket_override,
|
||||
} = request;
|
||||
|
||||
if let Some(client) = try_connect_live_pod(&worker_name, socket_override).await {
|
||||
return run_connected_pod(terminal, worker_name, client, runtime_command.clone()).await;
|
||||
}
|
||||
|
||||
let ready =
|
||||
spawn_worker_name_from_fullscreen(terminal, &worker_name, runtime_command.clone()).await?;
|
||||
run_ready_pod(terminal, ready, runtime_command).await
|
||||
}
|
||||
|
||||
async fn spawn_worker_name_from_fullscreen(
|
||||
terminal: &mut ConsoleTerminal,
|
||||
worker_name: &str,
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
|
||||
leave_fullscreen(terminal)?;
|
||||
let outcome = spawn::run_worker_name(worker_name.to_string(), runtime_command).await;
|
||||
enter_fullscreen_existing(terminal)?;
|
||||
terminal.clear()?;
|
||||
|
||||
match outcome? {
|
||||
SpawnOutcome::Ready(ready) => Ok(ready),
|
||||
SpawnOutcome::Cancelled => Err(Box::new(NestedOpenCancelled)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_connect_live_pod(
|
||||
worker_name: &str,
|
||||
socket_override: Option<PathBuf>,
|
||||
) -> Option<WorkerClient> {
|
||||
let preferred_socket = resolve_socket(worker_name, socket_override.clone());
|
||||
connect_live_pod(worker_name, preferred_socket, socket_override.is_none())
|
||||
.await
|
||||
.map(|(_, client)| client)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NestedOpenCancelled;
|
||||
|
||||
impl std::fmt::Display for NestedOpenCancelled {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Worker open was cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NestedOpenCancelled {}
|
||||
|
||||
async fn run_ready_pod(
|
||||
terminal: &mut ConsoleTerminal,
|
||||
ready: SpawnReady,
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let SpawnReady {
|
||||
worker_name,
|
||||
socket_path,
|
||||
} = ready;
|
||||
run(terminal, worker_name, &socket_path, runtime_command).await
|
||||
}
|
||||
|
||||
async fn connect_live_pod(
|
||||
worker_name: &str,
|
||||
preferred_socket: PathBuf,
|
||||
allow_registry_fallback: bool,
|
||||
) -> Option<(PathBuf, WorkerClient)> {
|
||||
if let Ok(client) = WorkerClient::connect(&preferred_socket).await {
|
||||
return Some((preferred_socket, client));
|
||||
}
|
||||
|
||||
if !allow_registry_fallback {
|
||||
return None;
|
||||
}
|
||||
let registry_socket = picker::live_socket_for_worker(worker_name)?;
|
||||
if registry_socket == preferred_socket {
|
||||
return None;
|
||||
}
|
||||
WorkerClient::connect(®istry_socket)
|
||||
.await
|
||||
.ok()
|
||||
.map(|client| (registry_socket, client))
|
||||
}
|
||||
|
||||
pub(crate) async fn run_resume(
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
workspace_root: PathBuf,
|
||||
all: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
run_worker_picker(runtime_command, workspace_root, all, true).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_worker_picker(
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
workspace_root: PathBuf,
|
||||
all: bool,
|
||||
include_stopped: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Pick a Worker in its own inline viewport, dropping the viewport before
|
||||
// attaching/restoring so each phase gets fresh vertical room.
|
||||
let picker_options = if all {
|
||||
picker::PickerOptions::all()
|
||||
} else {
|
||||
picker::PickerOptions::workspace(workspace_root)
|
||||
}
|
||||
.with_stopped(include_stopped);
|
||||
let (worker_name, socket_override) = match picker::run(picker_options).await? {
|
||||
PickerOutcome::Picked {
|
||||
worker_name,
|
||||
socket_override,
|
||||
} => (worker_name, socket_override),
|
||||
PickerOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
run_worker_name(worker_name, socket_override, runtime_command).await
|
||||
}
|
||||
|
||||
pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static)) -> bool {
|
||||
error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
|
||||
}
|
||||
|
||||
pub(crate) async fn run_spawn(
|
||||
resume_from: Option<SegmentId>,
|
||||
worker_name: Option<String>,
|
||||
profile: Option<String>,
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(feature = "e2e-test")]
|
||||
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
terminal.clear()?;
|
||||
let fixture_worker_name = worker_name.unwrap_or_else(|| "e2e-rewind".to_string());
|
||||
let result = run_e2e_rewind_fixture(&mut terminal, fixture_worker_name).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
return result;
|
||||
}
|
||||
|
||||
let ready = match spawn::run(resume_from, worker_name, profile, runtime_command.clone()).await?
|
||||
{
|
||||
SpawnOutcome::Ready(r) => r,
|
||||
SpawnOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
|
||||
let SpawnReady {
|
||||
worker_name,
|
||||
socket_path,
|
||||
} = ready;
|
||||
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
let result = run(&mut terminal, worker_name, &socket_path, runtime_command).await;
|
||||
|
||||
// Leave alt-screen explicitly before `main`'s terminal restore path.
|
||||
let _ = execute!(
|
||||
terminal.backend_mut(),
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn enter_fullscreen() -> Result<ConsoleTerminal, Box<dyn std::error::Error>> {
|
||||
let mut stdout = io::stdout();
|
||||
// Enable button-event tracking so the transcript can own drag selection;
|
||||
@@ -421,19 +277,6 @@ pub(crate) fn enter_dashboard_fullscreen() -> Result<ConsoleTerminal, Box<dyn st
|
||||
Ok(Terminal::new(backend)?)
|
||||
}
|
||||
|
||||
fn enter_fullscreen_existing(
|
||||
terminal: &mut ConsoleTerminal,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Re-enable the same least-intrusive wheel mouse mode after returning from
|
||||
// nested inline screens.
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
EnterAlternateScreen,
|
||||
EnableSinglePodMouseCapture
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leave_fullscreen(terminal: &mut ConsoleTerminal) -> io::Result<()> {
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
@@ -446,40 +289,6 @@ pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io::
|
||||
leave_fullscreen(terminal)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
terminal: &mut ConsoleTerminal,
|
||||
worker_name: String,
|
||||
socket_path: &std::path::Path,
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
|
||||
|
||||
match WorkerClient::connect(socket_path).await {
|
||||
Ok(client) => {
|
||||
app.connected = true;
|
||||
// The Worker sends `Event::Snapshot` automatically on connect;
|
||||
// no explicit method call is required to fetch history.
|
||||
run_loop(
|
||||
terminal,
|
||||
&mut app,
|
||||
ConsoleConnection::LegacySocket(client),
|
||||
Some(runtime_command),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(e) => {
|
||||
app.push_error(format!(
|
||||
"Failed to connect to {}: {e}",
|
||||
socket_path.display()
|
||||
));
|
||||
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||
run_disconnected(&mut app)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type TerminalEventResult = io::Result<TermEvent>;
|
||||
|
||||
const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
@@ -547,7 +356,9 @@ async fn run_e2e_rewind_fixture(
|
||||
let mut app = App::new_with_persistent_input_history(worker_name.clone(), &workspace_root);
|
||||
app.connected = true;
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
greeting: Greeting {
|
||||
worker_name: worker_name.clone(),
|
||||
@@ -673,7 +484,9 @@ async fn run_e2e_rewind_fixture(
|
||||
if let Some(submitted_at) = pending_apply {
|
||||
if submitted_at.elapsed() >= apply_delay {
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
input: vec![Segment::text("rewind-live-refresh")],
|
||||
summary: RewindSummary {
|
||||
truncated_to_entries: 1,
|
||||
@@ -715,16 +528,20 @@ enum E2eRewindInput {
|
||||
|
||||
enum LoopInput<P> {
|
||||
Terminal(TerminalEventResult),
|
||||
Worker(Option<P>),
|
||||
Worker(P),
|
||||
Tick,
|
||||
}
|
||||
|
||||
async fn next_loop_input<P, F>(
|
||||
async fn next_loop_input<P, F, T>(
|
||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||
connected: bool,
|
||||
pod_next: F,
|
||||
animate: bool,
|
||||
animation_tick: T,
|
||||
) -> LoopInput<P>
|
||||
where
|
||||
F: Future<Output = Option<P>>,
|
||||
F: Future<Output = P>,
|
||||
T: Future,
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
@@ -738,21 +555,21 @@ where
|
||||
}))
|
||||
}
|
||||
event = pod_next, if connected => LoopInput::Worker(event),
|
||||
_ = animation_tick, if animate => LoopInput::Tick,
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_terminal_events(
|
||||
async fn drain_terminal_events<T: Socket>(
|
||||
app: &mut App,
|
||||
client: &mut ConsoleConnection,
|
||||
client: &mut ConsoleConnection<T>,
|
||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||
runtime_command: Option<&WorkerRuntimeCommand>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let mut handled = false;
|
||||
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
|
||||
match term_rx.try_recv() {
|
||||
Ok(event) => {
|
||||
handled = true;
|
||||
handle_terminal_event(app, client, event?, runtime_command).await?;
|
||||
handle_terminal_event(app, client, event?).await?;
|
||||
if app.quit {
|
||||
break;
|
||||
}
|
||||
@@ -769,13 +586,13 @@ async fn drain_terminal_events(
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
async fn drain_worker_events(
|
||||
async fn drain_worker_events<T: Socket>(
|
||||
app: &mut App,
|
||||
client: &mut ConsoleConnection,
|
||||
client: &mut ConsoleConnection<T>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let mut handled = false;
|
||||
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
||||
match client.try_next_event() {
|
||||
match client.try_next_event()? {
|
||||
Some(ev) => {
|
||||
handled = true;
|
||||
if let Some(method) = app.handle_worker_event(ev) {
|
||||
@@ -788,13 +605,14 @@ async fn drain_worker_events(
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
async fn run_loop(
|
||||
async fn run_loop<T: Socket>(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
mut client: ConsoleConnection,
|
||||
runtime_command: Option<WorkerRuntimeCommand>,
|
||||
client: &mut ConsoleConnection<T>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||
let mut animation_tick = tokio::time::interval(Duration::from_millis(80));
|
||||
animation_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
|
||||
@@ -803,23 +621,29 @@ async fn run_loop(
|
||||
break;
|
||||
}
|
||||
|
||||
let handled_term_event =
|
||||
drain_terminal_events(app, &mut client, &mut term_rx, runtime_command.as_ref()).await?;
|
||||
let handled_term_event = drain_terminal_events(app, client, &mut term_rx).await?;
|
||||
if app.quit {
|
||||
break;
|
||||
}
|
||||
let handled_worker_event = drain_worker_events(app, &mut client).await?;
|
||||
let handled_worker_event = drain_worker_events(app, client).await?;
|
||||
if handled_term_event || handled_worker_event {
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
continue;
|
||||
}
|
||||
|
||||
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
|
||||
match next_loop_input(
|
||||
&mut term_rx,
|
||||
app.connected,
|
||||
client.next_event(),
|
||||
app.running,
|
||||
animation_tick.tick(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
LoopInput::Terminal(term_event) => {
|
||||
handle_terminal_event(app, &mut client, term_event?, runtime_command.as_ref())
|
||||
.await?;
|
||||
handle_terminal_event(app, client, term_event?).await?;
|
||||
}
|
||||
LoopInput::Worker(event) => match event {
|
||||
LoopInput::Worker(event) => match event? {
|
||||
Some(ev) => {
|
||||
if let Some(method) = app.handle_worker_event(ev) {
|
||||
client.send(&method).await?;
|
||||
@@ -831,6 +655,7 @@ async fn run_loop(
|
||||
app.push_error("Connection lost");
|
||||
}
|
||||
},
|
||||
LoopInput::Tick => {}
|
||||
}
|
||||
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
@@ -839,11 +664,10 @@ async fn run_loop(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_terminal_event(
|
||||
async fn handle_terminal_event<T: Socket>(
|
||||
app: &mut App,
|
||||
client: &mut ConsoleConnection,
|
||||
client: &mut ConsoleConnection<T>,
|
||||
event: TermEvent,
|
||||
_runtime_command: Option<&WorkerRuntimeCommand>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match event {
|
||||
TermEvent::Key(key) => {
|
||||
@@ -865,19 +689,6 @@ async fn handle_terminal_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
if event::poll(std::time::Duration::from_millis(100))?
|
||||
&& let TermEvent::Key(key) = event::read()?
|
||||
&& let KeyCode::Char('c') = key.code
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so
|
||||
/// hand-rolling through long histories isn't tedious, but slow enough
|
||||
/// that a single notch doesn't blow past the section the user is
|
||||
@@ -1011,13 +822,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
||||
KeyCode::Char('x') if ctrl => Some(match app.worker_status {
|
||||
WorkerStatus::Running | WorkerStatus::Paused => {
|
||||
app.clear_queued_inputs();
|
||||
Some(Method::Cancel)
|
||||
}
|
||||
WorkerStatus::Idle => Some(Method::Shutdown),
|
||||
}),
|
||||
KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(app)),
|
||||
KeyCode::Char('d') if ctrl => {
|
||||
app.quit = true;
|
||||
Some(None)
|
||||
@@ -1274,6 +1079,33 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||
|
||||
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
/// Running / Paused → send `Method::Cancel` immediately.
|
||||
/// Idle / Stopped → 2-tap to shut down the Worker.
|
||||
fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
|
||||
if matches!(
|
||||
app.worker_status,
|
||||
WorkerStatus::Running | WorkerStatus::Paused
|
||||
) {
|
||||
app.shutdown_confirm = None;
|
||||
app.clear_queued_inputs();
|
||||
return Some(Method::Cancel);
|
||||
}
|
||||
if let Some(pressed_at) = app.shutdown_confirm
|
||||
&& pressed_at.elapsed() < CONFIRM_TIMEOUT
|
||||
{
|
||||
app.shutdown_confirm = None;
|
||||
return Some(Method::Shutdown);
|
||||
}
|
||||
app.shutdown_confirm = Some(std::time::Instant::now());
|
||||
app.flash_actionbar_notice(
|
||||
"Press Ctrl-X again within 3 s to shut down the Worker.",
|
||||
ActionbarNoticeLevel::Warn,
|
||||
ActionbarNoticeSource::Tui,
|
||||
CONFIRM_TIMEOUT,
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
/// Running → send `Method::Pause`.
|
||||
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
|
||||
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||
@@ -1304,6 +1136,14 @@ mod tests {
|
||||
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
||||
|
||||
#[test]
|
||||
fn standalone_console_starts_with_in_process_connection_ready() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let app = standalone_console_app("standalone".to_string(), temp.path());
|
||||
|
||||
assert!(app.connected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() {
|
||||
let mut ansi = String::new();
|
||||
@@ -1411,6 +1251,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn animation_tick_wakes_loop_while_running() {
|
||||
let (_tx, mut rx) = mpsc::unbounded_channel::<TerminalEventResult>();
|
||||
|
||||
assert!(matches!(
|
||||
next_loop_input(
|
||||
&mut rx,
|
||||
true,
|
||||
std::future::pending::<Option<u8>>(),
|
||||
true,
|
||||
std::future::ready(()),
|
||||
)
|
||||
.await,
|
||||
LoopInput::Tick
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_event_is_selected_before_ready_worker_event() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
@@ -1420,7 +1277,15 @@ mod tests {
|
||||
))))
|
||||
.unwrap();
|
||||
|
||||
match next_loop_input(&mut rx, true, std::future::ready(Some(()))).await {
|
||||
match next_loop_input(
|
||||
&mut rx,
|
||||
true,
|
||||
std::future::ready(Some(())),
|
||||
false,
|
||||
std::future::pending::<()>(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
||||
assert_eq!(key.code, KeyCode::Char('x'));
|
||||
}
|
||||
@@ -1432,7 +1297,15 @@ mod tests {
|
||||
async fn terminal_event_is_preserved_after_worker_event_wins() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await {
|
||||
match next_loop_input(
|
||||
&mut rx,
|
||||
true,
|
||||
std::future::ready(Some(1_u8)),
|
||||
false,
|
||||
std::future::pending::<()>(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
LoopInput::Worker(Some(1)) => {}
|
||||
_ => panic!("expected the first ready Worker event to win before any terminal input"),
|
||||
}
|
||||
@@ -1443,7 +1316,15 @@ mod tests {
|
||||
))))
|
||||
.unwrap();
|
||||
|
||||
match next_loop_input(&mut rx, true, std::future::ready(Some(2_u8))).await {
|
||||
match next_loop_input(
|
||||
&mut rx,
|
||||
true,
|
||||
std::future::ready(Some(2_u8)),
|
||||
false,
|
||||
std::future::pending::<()>(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
||||
assert_eq!(key.code, KeyCode::Char('y'));
|
||||
}
|
||||
@@ -1610,15 +1491,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_x_shutdown_while_idle_is_unchanged() {
|
||||
fn ctrl_x_requires_confirmation_before_shutdown_while_idle() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.set_worker_status(WorkerStatus::Idle);
|
||||
let ctrl_x = || KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
|
||||
|
||||
assert!(handle_key(&mut app, ctrl_x()).is_none());
|
||||
assert!(app.shutdown_confirm.is_some());
|
||||
let notice = app
|
||||
.current_actionbar_notice(std::time::Instant::now())
|
||||
.expect("first Ctrl-X should arm shutdown confirmation");
|
||||
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
||||
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
||||
assert!(notice.text.contains("Ctrl-X"));
|
||||
assert!(notice.text.contains("shut down the Worker"));
|
||||
assert!(!has_alert(&app, "shut down the Worker"));
|
||||
|
||||
assert!(matches!(
|
||||
handle_key(&mut app, ctrl_x()),
|
||||
Some(Method::Shutdown)
|
||||
));
|
||||
assert!(app.shutdown_confirm.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_and_ctrl_x_confirmations_do_not_authorize_each_other() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.set_worker_status(WorkerStatus::Idle);
|
||||
|
||||
let shutdown = handle_key(
|
||||
assert!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(app.quit_confirm.is_some());
|
||||
assert!(app.shutdown_confirm.is_none());
|
||||
|
||||
assert!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(matches!(shutdown, Some(Method::Shutdown)));
|
||||
assert!(!app.quit);
|
||||
assert!(app.shutdown_confirm.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2023,13 +1942,13 @@ mod tests {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::Text {
|
||||
content: "retry this".into(),
|
||||
}],
|
||||
@@ -2050,7 +1969,7 @@ mod tests {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
@@ -2058,7 +1977,7 @@ mod tests {
|
||||
type_keys(&mut app, "draft");
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::Text {
|
||||
content: "retry this".into(),
|
||||
}],
|
||||
@@ -2334,12 +2253,17 @@ mod tests {
|
||||
handle_key(&mut app, key(KeyCode::Tab));
|
||||
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
|
||||
|
||||
let method = handle_key(
|
||||
let first = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
let second = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
|
||||
assert!(matches!(method, Some(Method::Shutdown)));
|
||||
assert!(first.is_none());
|
||||
assert!(matches!(second, Some(Method::Shutdown)));
|
||||
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,784 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn draw(frame: &mut Frame<'_>, app: &mut DashboardApp) {
|
||||
let area = frame.area();
|
||||
let input_content_width = area.width.saturating_sub(2).max(1);
|
||||
let mut input_render = app.input.render(input_content_width);
|
||||
let input_height = input_area_height(&input_render, area.height);
|
||||
app.input
|
||||
.apply_cursor_viewport(&mut input_render, input_height);
|
||||
let layout = dashboard_layout(area, input_height);
|
||||
|
||||
draw_title(frame, app, layout.title);
|
||||
draw_list(frame, app, layout.list);
|
||||
draw_separator(frame, layout.boundary);
|
||||
draw_target_status(frame, app, layout.target_status);
|
||||
draw_input(frame, &input_render, layout.input);
|
||||
draw_actionbar(frame, app, layout.actionbar);
|
||||
if app.panel_diagnostic_open {
|
||||
render_panel_diagnostic(frame, app, area);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn panel_diagnostic_area(area: Rect) -> Rect {
|
||||
let width = if area.width <= 20 {
|
||||
area.width
|
||||
} else {
|
||||
area.width.saturating_sub(4).min(100).max(20)
|
||||
};
|
||||
let height = if area.height <= 8 {
|
||||
area.height
|
||||
} else {
|
||||
area.height.saturating_sub(4).min(24).max(8)
|
||||
};
|
||||
let x = area.x + area.width.saturating_sub(width) / 2;
|
||||
let y = area.y + area.height.saturating_sub(height) / 2;
|
||||
Rect::new(x, y, width, height)
|
||||
}
|
||||
|
||||
pub(super) fn render_panel_diagnostic(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
|
||||
let Some(diagnostic) = app.panel_diagnostic.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let popup_area = panel_diagnostic_area(area);
|
||||
let title = format!(" {} ", diagnostic.title);
|
||||
let text = format!("{}\n\nF2/Esc: close", diagnostic.details);
|
||||
let paragraph = Paragraph::new(text)
|
||||
.block(Block::default().title(title).borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(Clear, popup_area);
|
||||
frame.render_widget(paragraph, popup_area);
|
||||
}
|
||||
|
||||
pub(super) fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -> u16 {
|
||||
let needed = render.lines.len().max(1) as u16;
|
||||
let cap = (terminal_height / 3).max(1).min(10);
|
||||
needed.clamp(1, cap)
|
||||
}
|
||||
|
||||
pub(super) fn draw_title(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
|
||||
frame.render_widget(Paragraph::new(title_line(app)), area);
|
||||
}
|
||||
|
||||
pub(super) fn title_line(app: &DashboardApp) -> Line<'static> {
|
||||
let mut spans = vec![Span::styled(
|
||||
"workspace dashboard",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
if let Some(companion) = &app.panel.header.companion {
|
||||
spans.push(Span::styled(
|
||||
" · companion ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
companion.status.label(),
|
||||
companion_status_style(companion.status),
|
||||
));
|
||||
if let Some(detail) = companion.detail.as_deref() {
|
||||
spans.push(Span::styled(
|
||||
format!(" ({detail})"),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(orchestrator) = &app.panel.header.orchestrator {
|
||||
spans.push(Span::styled(
|
||||
" · orchestrator ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
orchestrator.status.label(),
|
||||
orchestrator_status_style(orchestrator.status),
|
||||
));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
pub(super) fn companion_status_style(status: CompanionPanelStatus) -> Style {
|
||||
match status {
|
||||
CompanionPanelStatus::Live
|
||||
| CompanionPanelStatus::Restored
|
||||
| CompanionPanelStatus::Spawned => Style::default().fg(Color::Green),
|
||||
CompanionPanelStatus::Stopped | CompanionPanelStatus::Missing => {
|
||||
Style::default().fg(Color::Yellow)
|
||||
}
|
||||
CompanionPanelStatus::Unavailable => Style::default().fg(Color::Red),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn orchestrator_status_style(status: OrchestratorPanelStatus) -> Style {
|
||||
match status {
|
||||
OrchestratorPanelStatus::Live
|
||||
| OrchestratorPanelStatus::Restored
|
||||
| OrchestratorPanelStatus::Spawned => Style::default().fg(Color::Green),
|
||||
OrchestratorPanelStatus::Stopped | OrchestratorPanelStatus::Missing => {
|
||||
Style::default().fg(Color::Yellow)
|
||||
}
|
||||
OrchestratorPanelStatus::Unavailable => Style::default().fg(Color::Red),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_list(frame: &mut Frame<'_>, app: &mut DashboardApp, area: Rect) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
app.row_hit_boxes.clear();
|
||||
return;
|
||||
}
|
||||
let rows = list_rows(app, area.width, area.height);
|
||||
app.set_row_hit_boxes(&rows, area);
|
||||
let lines = rows.into_iter().map(|row| row.line).collect::<Vec<_>>();
|
||||
Paragraph::new(lines).render(area, frame.buffer_mut());
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct PanelListRow {
|
||||
pub(super) line: Line<'static>,
|
||||
pub(super) key: Option<PanelRowKey>,
|
||||
}
|
||||
|
||||
impl PanelListRow {
|
||||
fn inert(line: Line<'static>) -> Self {
|
||||
Self { line, key: None }
|
||||
}
|
||||
|
||||
fn selectable(line: Line<'static>, key: PanelRowKey) -> Self {
|
||||
Self {
|
||||
line,
|
||||
key: Some(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn list_lines(app: &DashboardApp, width: u16, height: u16) -> Vec<Line<'static>> {
|
||||
list_rows(app, width, height)
|
||||
.into_iter()
|
||||
.map(|row| row.line)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn list_rows(app: &DashboardApp, width: u16, height: u16) -> Vec<PanelListRow> {
|
||||
let sections = sectioned_entries(&app.list);
|
||||
let selected = app.selected_row.as_ref();
|
||||
let diagnostic_rows = panel_diagnostic_lines(&app.panel, width)
|
||||
.into_iter()
|
||||
.map(PanelListRow::inert)
|
||||
.collect::<Vec<_>>();
|
||||
let action_rows = panel_action_rows(&app.panel, selected, width);
|
||||
let live_rows = sections
|
||||
.iter()
|
||||
.filter(|section| section.kind != DashboardSectionKind::Closed)
|
||||
.flat_map(|section| section_rows(&app.list, section, selected, width))
|
||||
.collect::<Vec<_>>();
|
||||
let closed_rows = sections
|
||||
.iter()
|
||||
.find(|section| section.kind == DashboardSectionKind::Closed)
|
||||
.map(|section| section_rows(&app.list, section, selected, width))
|
||||
.unwrap_or_default();
|
||||
|
||||
let available = height as usize;
|
||||
let diagnostic_len = diagnostic_rows.len().min(available);
|
||||
let remaining_after_diagnostics = available.saturating_sub(diagnostic_len);
|
||||
let action_len = action_rows.len().min(remaining_after_diagnostics);
|
||||
let remaining_after_actions = remaining_after_diagnostics.saturating_sub(action_len);
|
||||
let closed_len = closed_rows.len().min(remaining_after_actions);
|
||||
let live_len = live_rows
|
||||
.len()
|
||||
.min(remaining_after_actions.saturating_sub(closed_len));
|
||||
let spacer_len = available.saturating_sub(diagnostic_len + action_len + live_len + closed_len);
|
||||
|
||||
let mut rows = Vec::with_capacity(available);
|
||||
rows.extend(diagnostic_rows.into_iter().take(diagnostic_len));
|
||||
rows.extend(action_rows.into_iter().take(action_len));
|
||||
rows.extend(live_rows.into_iter().take(live_len));
|
||||
rows.extend(
|
||||
std::iter::repeat_with(|| PanelListRow::inert(Line::from(Span::raw("")))).take(spacer_len),
|
||||
);
|
||||
rows.extend(closed_rows.into_iter().take(closed_len));
|
||||
rows
|
||||
}
|
||||
|
||||
pub(super) fn row_hit_boxes(rows: &[PanelListRow], area: Rect) -> Vec<PanelRowHitBox> {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut hit_boxes: Vec<PanelRowHitBox> = Vec::new();
|
||||
for (offset, row) in rows.iter().enumerate() {
|
||||
let Some(key) = row.key.clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(y) = area.y.checked_add(offset as u16) else {
|
||||
continue;
|
||||
};
|
||||
if y >= area.y.saturating_add(area.height) {
|
||||
continue;
|
||||
}
|
||||
if let Some(last) = hit_boxes.last_mut() {
|
||||
if last.key == key
|
||||
&& last.rect.x == area.x
|
||||
&& last.rect.width == area.width
|
||||
&& last.rect.y.saturating_add(last.rect.height) == y
|
||||
{
|
||||
last.rect.height = last.rect.height.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
hit_boxes.push(PanelRowHitBox {
|
||||
rect: Rect::new(area.x, y, area.width, 1),
|
||||
key,
|
||||
});
|
||||
}
|
||||
hit_boxes
|
||||
}
|
||||
|
||||
pub(super) fn panel_diagnostic_lines(
|
||||
panel: &WorkspacePanelViewModel,
|
||||
width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
panel
|
||||
.header
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| {
|
||||
Line::from(vec![
|
||||
Span::styled("⚠ ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(
|
||||
truncate_with_ellipsis(diagnostic, width.saturating_sub(2) as usize),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn panel_action_rows(
|
||||
panel: &WorkspacePanelViewModel,
|
||||
selected: Option<&PanelRowKey>,
|
||||
width: u16,
|
||||
) -> Vec<PanelListRow> {
|
||||
let rows = panel
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| row.is_ticket_section_row())
|
||||
.collect::<Vec<_>>();
|
||||
if rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut lines = Vec::with_capacity((rows.len() * 2) + 1);
|
||||
lines.push(PanelListRow::inert(panel_action_header_line(
|
||||
rows.len(),
|
||||
width,
|
||||
)));
|
||||
for row in rows {
|
||||
for line in panel_row_lines(row, selected == Some(&row.key), width) {
|
||||
lines.push(PanelListRow::selectable(line, row.key.clone()));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
pub(super) fn panel_action_header_line(total: usize, width: u16) -> Line<'static> {
|
||||
let detail = if total == 1 {
|
||||
" 1 row".to_string()
|
||||
} else {
|
||||
format!(" {total} rows")
|
||||
};
|
||||
let text = truncate_with_ellipsis(&format!("--tickets{detail}---"), width as usize);
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) const TICKET_STATE_COLUMN_WIDTH: usize = 10;
|
||||
pub(super) const POD_STATUS_COLUMN_WIDTH: usize = 18;
|
||||
|
||||
pub(super) fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec<Line<'static>> {
|
||||
if row.kind == PanelRowKind::TicketIntakeWorker {
|
||||
vec![panel_intake_child_line(row, selected, width)]
|
||||
} else {
|
||||
vec![
|
||||
panel_row_title_line(row, selected, width),
|
||||
panel_row_detail_line(row, selected, width),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||
let title_style = if selected {
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Magenta)
|
||||
};
|
||||
let mut spans = Vec::new();
|
||||
let mut remaining = width as usize;
|
||||
|
||||
push_ticket_primary_marker_span(&mut spans, selected, &mut remaining);
|
||||
push_column_span(
|
||||
&mut spans,
|
||||
&row.status,
|
||||
TICKET_STATE_COLUMN_WIDTH,
|
||||
panel_priority_style(row.priority),
|
||||
&mut remaining,
|
||||
);
|
||||
push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining);
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
pub(super) fn panel_intake_child_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||
let title_style = if selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
let mut spans = Vec::new();
|
||||
let mut remaining = width as usize;
|
||||
|
||||
push_intake_child_marker_span(&mut spans, selected, &mut remaining);
|
||||
push_column_span(
|
||||
&mut spans,
|
||||
&row.status,
|
||||
TICKET_STATE_COLUMN_WIDTH,
|
||||
intake_status_style(&row.status),
|
||||
&mut remaining,
|
||||
);
|
||||
push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining);
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
pub(super) fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||
let mut spans = Vec::new();
|
||||
let mut remaining = width as usize;
|
||||
|
||||
push_ticket_detail_marker_span(&mut spans, selected, &mut remaining);
|
||||
push_bounded_span(
|
||||
&mut spans,
|
||||
"meta ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
&mut remaining,
|
||||
);
|
||||
push_bounded_span(
|
||||
&mut spans,
|
||||
&panel_ticket_detail(row),
|
||||
ticket_detail_style(row),
|
||||
&mut remaining,
|
||||
);
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
pub(super) fn push_ticket_primary_marker_span(
|
||||
spans: &mut Vec<Span<'static>>,
|
||||
selected: bool,
|
||||
remaining: &mut usize,
|
||||
) {
|
||||
let (marker, style) = if selected {
|
||||
(
|
||||
"▶ ",
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
(" ", Style::default().fg(Color::DarkGray))
|
||||
};
|
||||
push_bounded_span(spans, marker, style, remaining);
|
||||
}
|
||||
|
||||
pub(super) fn push_ticket_detail_marker_span(
|
||||
spans: &mut Vec<Span<'static>>,
|
||||
selected: bool,
|
||||
remaining: &mut usize,
|
||||
) {
|
||||
let (marker, style) = if selected {
|
||||
(
|
||||
"│ ",
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
(" ", Style::default().fg(Color::DarkGray))
|
||||
};
|
||||
push_bounded_span(spans, marker, style, remaining);
|
||||
}
|
||||
|
||||
pub(super) fn push_intake_child_marker_span(
|
||||
spans: &mut Vec<Span<'static>>,
|
||||
selected: bool,
|
||||
remaining: &mut usize,
|
||||
) {
|
||||
let (marker, style) = if selected {
|
||||
(
|
||||
" ▶ ",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
(" └ ", Style::default().fg(Color::DarkGray))
|
||||
};
|
||||
push_bounded_span(spans, marker, style, remaining);
|
||||
}
|
||||
|
||||
pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
|
||||
if row.kind == PanelRowKind::InvalidTicket {
|
||||
let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()];
|
||||
if let Some(reason) = panel_ticket_reason(row) {
|
||||
parts.push(format!("Reason: {reason}"));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
if row.kind == PanelRowKind::TicketIntakeWorker {
|
||||
let mut parts = row
|
||||
.subtitle
|
||||
.as_ref()
|
||||
.map(|subtitle| vec![subtitle.clone()])
|
||||
.unwrap_or_else(|| vec![panel_ticket_reference(row)]);
|
||||
if let Some(action) = row.next_action {
|
||||
parts.push(format!("Action: {}", action.label()));
|
||||
}
|
||||
if let Some(reason) = panel_ticket_reason(row) {
|
||||
parts.push(format!("Reason: {reason}"));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
let mut parts = vec![panel_ticket_reference(row)];
|
||||
if let Some(overlay_detail) = panel_ticket_overlay_detail(row) {
|
||||
parts.push(overlay_detail);
|
||||
}
|
||||
if let Some(blocked_reason) = row
|
||||
.ticket
|
||||
.as_ref()
|
||||
.and_then(|ticket| ticket.blocked_reason.as_deref())
|
||||
{
|
||||
parts.push(format!("Gate: waiting for {blocked_reason}"));
|
||||
} else {
|
||||
parts.push("Gate: clear".to_string());
|
||||
}
|
||||
if let Some(action) = row.next_action {
|
||||
parts.push(format!(
|
||||
"Action: {}",
|
||||
panel_ticket_action_label(row, action)
|
||||
));
|
||||
}
|
||||
if let Some(reason) = panel_ticket_reason(row) {
|
||||
parts.push(format!("Reason: {reason}"));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
pub(super) fn panel_ticket_action_label(row: &PanelRow, action: NextUserAction) -> &'static str {
|
||||
if action == NextUserAction::Wait
|
||||
&& row
|
||||
.ticket
|
||||
.as_ref()
|
||||
.and_then(|ticket| ticket.blocked_reason.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
"queue disabled"
|
||||
} else {
|
||||
action.label()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn panel_ticket_overlay_detail(row: &PanelRow) -> Option<String> {
|
||||
let ticket = row.ticket.as_ref()?;
|
||||
let overlay = ticket.orchestration_overlay.as_ref()?;
|
||||
let mut detail = format!(
|
||||
"Overlay: local {} · {} {}",
|
||||
ticket.workflow_state.as_str(),
|
||||
overlay.source,
|
||||
overlay.workflow_state.as_str()
|
||||
);
|
||||
if matches!(
|
||||
overlay.workflow_state,
|
||||
TicketWorkflowState::Done | TicketWorkflowState::Closed
|
||||
) {
|
||||
detail.push_str(" · merge pending");
|
||||
}
|
||||
Some(detail)
|
||||
}
|
||||
|
||||
pub(super) fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
|
||||
row.disabled_reason
|
||||
.as_deref()
|
||||
.or_else(|| row.key_hint.as_deref())
|
||||
}
|
||||
|
||||
pub(super) fn ticket_detail_style(row: &PanelRow) -> Style {
|
||||
if row.kind == PanelRowKind::InvalidTicket {
|
||||
return Style::default().fg(Color::Yellow);
|
||||
}
|
||||
if row
|
||||
.ticket
|
||||
.as_ref()
|
||||
.and_then(|ticket| ticket.blocked_reason.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||
row.ticket
|
||||
.as_ref()
|
||||
.map(|ticket| {
|
||||
ticket
|
||||
.resource_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| "resource key unavailable".to_string())
|
||||
})
|
||||
.unwrap_or_else(|| match &row.key {
|
||||
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
|
||||
PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(),
|
||||
PanelRowKey::Worker(name) => name.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn push_column_span(
|
||||
spans: &mut Vec<Span<'static>>,
|
||||
value: &str,
|
||||
column_width: usize,
|
||||
style: Style,
|
||||
remaining: &mut usize,
|
||||
) {
|
||||
if *remaining == 0 {
|
||||
return;
|
||||
}
|
||||
let mut content = padded_cell(value, column_width);
|
||||
content.push(' ');
|
||||
push_bounded_span(spans, &content, style, remaining);
|
||||
}
|
||||
|
||||
pub(super) fn push_bounded_span(
|
||||
spans: &mut Vec<Span<'static>>,
|
||||
value: &str,
|
||||
style: Style,
|
||||
remaining: &mut usize,
|
||||
) {
|
||||
if *remaining == 0 || value.is_empty() {
|
||||
return;
|
||||
}
|
||||
let content = truncate_with_ellipsis(value, *remaining);
|
||||
*remaining = remaining.saturating_sub(content.width());
|
||||
spans.push(Span::styled(content, style));
|
||||
}
|
||||
|
||||
pub(super) fn padded_cell(value: &str, width: usize) -> String {
|
||||
let mut cell = truncate_with_ellipsis(value, width);
|
||||
let padding = width.saturating_sub(cell.width());
|
||||
cell.extend(std::iter::repeat_n(' ', padding));
|
||||
cell
|
||||
}
|
||||
|
||||
pub(super) fn panel_priority_style(priority: ActionPriority) -> Style {
|
||||
match priority {
|
||||
ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
|
||||
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
|
||||
ActionPriority::Background => Style::default().fg(Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn intake_status_style(status: &str) -> Style {
|
||||
match status {
|
||||
"live" => Style::default().fg(Color::Green),
|
||||
"restorable" => Style::default().fg(Color::Yellow),
|
||||
"stale" => Style::default().fg(Color::DarkGray),
|
||||
_ => Style::default().fg(Color::Cyan),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn section_rows(
|
||||
list: &WorkerList,
|
||||
section: &DashboardSection,
|
||||
selected: Option<&PanelRowKey>,
|
||||
width: u16,
|
||||
) -> Vec<PanelListRow> {
|
||||
let visible = visible_section_indices(section);
|
||||
if visible.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut rows = Vec::with_capacity(visible.len() + 1);
|
||||
rows.push(PanelListRow::inert(section_header_line(
|
||||
section.kind,
|
||||
section.entries.len(),
|
||||
section.hidden_count(),
|
||||
width,
|
||||
)));
|
||||
for index in visible {
|
||||
if let Some(entry) = list.entries.get(index) {
|
||||
let key = PanelRowKey::Worker(entry.name.clone());
|
||||
let selected = selected == Some(&key);
|
||||
rows.push(PanelListRow::selectable(
|
||||
row_line(entry, selected, width),
|
||||
key,
|
||||
));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub(super) fn row_line(entry: &WorkerListEntry, selected: bool, width: u16) -> Line<'static> {
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
let name_style = if selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
let (status, status_style) = row_status_label(entry);
|
||||
let mut spans = Vec::new();
|
||||
let mut remaining = width as usize;
|
||||
|
||||
push_bounded_span(
|
||||
&mut spans,
|
||||
marker,
|
||||
if selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
},
|
||||
&mut remaining,
|
||||
);
|
||||
push_column_span(
|
||||
&mut spans,
|
||||
status,
|
||||
POD_STATUS_COLUMN_WIDTH,
|
||||
status_style,
|
||||
&mut remaining,
|
||||
);
|
||||
push_bounded_span(&mut spans, entry.name.as_str(), name_style, &mut remaining);
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
pub(super) fn draw_separator(frame: &mut Frame<'_>, area: Rect) {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
"─".repeat(area.width as usize),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
))),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn draw_target_status(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
|
||||
frame.render_widget(Paragraph::new(target_status_line(app)), area);
|
||||
}
|
||||
|
||||
pub(super) fn target_status_line(_app: &DashboardApp) -> Line<'static> {
|
||||
Line::from(Span::raw(""))
|
||||
}
|
||||
|
||||
pub(super) fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) {
|
||||
let mut lines: Vec<Line<'static>> = Vec::with_capacity(render.lines.len());
|
||||
for (i, src) in render.lines.iter().enumerate() {
|
||||
let absolute_row = render.viewport_start_row as usize + i;
|
||||
let prefix = if absolute_row == 0 { "> " } else { " " };
|
||||
let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::DarkGray))];
|
||||
spans.extend(src.spans.iter().cloned());
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(lines), area);
|
||||
|
||||
let cursor_x = area.x + 2 + render.cursor_col;
|
||||
let cursor_y = area.y + render.cursor_row;
|
||||
if cursor_y < area.y + area.height {
|
||||
frame.set_cursor_position(Position::new(cursor_x, cursor_y));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn actionbar_left_text(app: &DashboardApp) -> String {
|
||||
if app.sending && app.composer_target() == ComposerTarget::TicketIntake {
|
||||
"launching Ticket Intake…".to_string()
|
||||
} else if app.sending {
|
||||
"working…".to_string()
|
||||
} else if app.refreshing {
|
||||
match app.notice.as_deref() {
|
||||
Some(notice) if notice.contains("Refreshing") || notice.contains("refreshing") => {
|
||||
notice.to_string()
|
||||
}
|
||||
Some(notice) => format!("{notice} Refreshing workspace…"),
|
||||
None => "Refreshing workspace…".to_string(),
|
||||
}
|
||||
} else if let Some(notice) = app.notice.as_deref() {
|
||||
notice.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn actionbar_right_text(app: &DashboardApp) -> &'static str {
|
||||
if app.panel_diagnostic_open {
|
||||
"F2/Esc close details"
|
||||
} else if app.panel_diagnostic.is_some() {
|
||||
"F2 details"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_actionbar(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
|
||||
let left = actionbar_left_text(app);
|
||||
let right = actionbar_right_text(app);
|
||||
let left_width = area
|
||||
.width
|
||||
.saturating_sub(right.width() as u16)
|
||||
.saturating_sub(2) as usize;
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
truncate_with_ellipsis(&left, left_width),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
))),
|
||||
area,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
right,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)))
|
||||
.alignment(ratatui::layout::Alignment::Right),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
|
||||
if max_width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if s.width() <= max_width {
|
||||
return s.to_string();
|
||||
}
|
||||
if max_width == 1 {
|
||||
return "…".to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut width = 0usize;
|
||||
for c in s.chars() {
|
||||
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
|
||||
if width + cw > max_width - 1 {
|
||||
break;
|
||||
}
|
||||
out.push(c);
|
||||
width += cw;
|
||||
}
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+381
-74
@@ -15,6 +15,64 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
pub const MAX_PLAIN_TEXT_PASTE_CHARS: usize = 50;
|
||||
pub const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PasteMeasurement {
|
||||
pub chars: usize,
|
||||
pub logical_lines: usize,
|
||||
}
|
||||
|
||||
impl PasteMeasurement {
|
||||
pub fn presentation(self) -> PastePresentation {
|
||||
if self.chars <= MAX_PLAIN_TEXT_PASTE_CHARS
|
||||
&& self.logical_lines <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||
{
|
||||
PastePresentation::Text
|
||||
} else {
|
||||
PastePresentation::Chip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PastePresentation {
|
||||
Text,
|
||||
Chip,
|
||||
}
|
||||
|
||||
pub fn measure_paste(content: &str) -> PasteMeasurement {
|
||||
PasteMeasurement {
|
||||
chars: content.chars().count(),
|
||||
logical_lines: logical_line_count(content),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty content has zero logical lines. Otherwise LF, lone CR, and CRLF each
|
||||
/// advance one line; a CRLF pair is one break rather than two.
|
||||
pub fn logical_line_count(content: &str) -> usize {
|
||||
if content.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut lines = 1;
|
||||
let mut chars = content.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\r' => {
|
||||
if chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
lines += 1;
|
||||
}
|
||||
'\n' => lines += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PasteRef {
|
||||
pub id: u32,
|
||||
@@ -61,6 +119,7 @@ impl FlowRefAtom {
|
||||
pub enum Atom {
|
||||
Char(char),
|
||||
Paste(PasteRef),
|
||||
PasteArtifact(protocol::PasteArtifactRef),
|
||||
FileRef(FileRefAtom),
|
||||
FlowRef(FlowRefAtom),
|
||||
}
|
||||
@@ -72,6 +131,18 @@ impl Atom {
|
||||
match self {
|
||||
Atom::Char(_) => None,
|
||||
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
|
||||
Atom::PasteArtifact(artifact) => Some((
|
||||
Style::default().fg(Color::Magenta),
|
||||
format!(
|
||||
"[Paste artifact {} | {} chars, {} lines, {}, {}, created {} ms]",
|
||||
artifact.artifact_id,
|
||||
artifact.char_count,
|
||||
artifact.line_count,
|
||||
artifact.media_type.as_str(),
|
||||
artifact.availability.as_str(),
|
||||
artifact.created_at_ms
|
||||
),
|
||||
)),
|
||||
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
|
||||
Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())),
|
||||
}
|
||||
@@ -102,7 +173,9 @@ enum WordKind {
|
||||
fn atom_class(atom: &Atom) -> AtomClass {
|
||||
match atom {
|
||||
Atom::Char(c) => char_class(*c),
|
||||
Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => AtomClass::Chip,
|
||||
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
|
||||
AtomClass::Chip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +263,9 @@ impl InputBuffer {
|
||||
content: content.clone(),
|
||||
}));
|
||||
}
|
||||
protocol::Segment::PasteArtifact { artifact } => {
|
||||
self.atoms.push(Atom::PasteArtifact(artifact.clone()));
|
||||
}
|
||||
protocol::Segment::FileRef { path } => {
|
||||
self.atoms
|
||||
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
|
||||
@@ -225,6 +301,13 @@ impl InputBuffer {
|
||||
match atom {
|
||||
Atom::Char(c) => text.push(*c),
|
||||
Atom::Paste(paste) => text.push_str(&paste.content),
|
||||
Atom::PasteArtifact(artifact) => {
|
||||
text.push_str(&protocol::Segment::flatten_to_text(&[
|
||||
protocol::Segment::PasteArtifact {
|
||||
artifact: artifact.clone(),
|
||||
},
|
||||
]))
|
||||
}
|
||||
Atom::FileRef(file) => text.push_str(&file.path),
|
||||
Atom::FlowRef(flow) => text.push_str(&flow.selector),
|
||||
}
|
||||
@@ -237,16 +320,20 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn insert_paste(&mut self, content: String) {
|
||||
let measurement = measure_paste(&content);
|
||||
if measurement.presentation() == PastePresentation::Text {
|
||||
self.insert_str(&content);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = self.next_paste_id;
|
||||
self.next_paste_id = self.next_paste_id.wrapping_add(1);
|
||||
let chars = content.chars().count();
|
||||
let lines = content.lines().count().max(1);
|
||||
self.atoms.insert(
|
||||
self.cursor,
|
||||
Atom::Paste(PasteRef {
|
||||
id,
|
||||
chars,
|
||||
lines,
|
||||
chars: measurement.chars,
|
||||
lines: measurement.logical_lines,
|
||||
content,
|
||||
}),
|
||||
);
|
||||
@@ -395,80 +482,78 @@ impl InputBuffer {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
fn logical_line_ranges(&self) -> Vec<(usize, usize)> {
|
||||
let mut ranges = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut index = 0;
|
||||
while index < self.atoms.len() {
|
||||
let break_len = match self.atoms[index] {
|
||||
Atom::Char('\r') => {
|
||||
if matches!(self.atoms.get(index + 1), Some(Atom::Char('\n'))) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
Atom::Char('\n') => 1,
|
||||
_ => {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
ranges.push((start, index));
|
||||
index += break_len;
|
||||
start = index;
|
||||
}
|
||||
ranges.push((start, self.atoms.len()));
|
||||
ranges
|
||||
}
|
||||
|
||||
fn logical_line_and_col(&self) -> (Vec<(usize, usize)>, usize, usize) {
|
||||
let ranges = self.logical_line_ranges();
|
||||
for (line, &(start, end)) in ranges.iter().enumerate() {
|
||||
if self.cursor <= end {
|
||||
return (ranges, line, self.cursor.saturating_sub(start));
|
||||
}
|
||||
if let Some(&(next_start, _)) = ranges.get(line + 1)
|
||||
&& self.cursor < next_start
|
||||
{
|
||||
return (ranges, line + 1, 0);
|
||||
}
|
||||
}
|
||||
let line = ranges.len().saturating_sub(1);
|
||||
let col = self.cursor.saturating_sub(ranges[line].0);
|
||||
(ranges, line, col)
|
||||
}
|
||||
|
||||
pub fn move_home(&mut self) {
|
||||
while self.cursor > 0 {
|
||||
if matches!(self.atoms[self.cursor - 1], Atom::Char('\n')) {
|
||||
break;
|
||||
}
|
||||
self.cursor -= 1;
|
||||
}
|
||||
let (ranges, line, _) = self.logical_line_and_col();
|
||||
self.cursor = ranges[line].0;
|
||||
}
|
||||
|
||||
pub fn move_end(&mut self) {
|
||||
while self.cursor < self.atoms.len() {
|
||||
if matches!(self.atoms[self.cursor], Atom::Char('\n')) {
|
||||
break;
|
||||
}
|
||||
self.cursor += 1;
|
||||
}
|
||||
let (ranges, line, _) = self.logical_line_and_col();
|
||||
self.cursor = ranges[line].1;
|
||||
}
|
||||
|
||||
/// Move one logical line up, preserving column (atom count from
|
||||
/// current line start). No-op if already on the first line.
|
||||
pub fn move_up(&mut self) {
|
||||
let (line_start, col) = self.line_start_and_col();
|
||||
if line_start == 0 {
|
||||
let (ranges, line, col) = self.logical_line_and_col();
|
||||
if line == 0 {
|
||||
return;
|
||||
}
|
||||
// `atoms[line_start - 1]` is the '\n' that opens the current
|
||||
// line; find the previous line's start.
|
||||
let prev_end = line_start - 1;
|
||||
let mut prev_start = 0;
|
||||
for i in (0..prev_end).rev() {
|
||||
if matches!(self.atoms[i], Atom::Char('\n')) {
|
||||
prev_start = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let prev_len = prev_end - prev_start;
|
||||
self.cursor = prev_start + col.min(prev_len);
|
||||
let (start, end) = ranges[line - 1];
|
||||
self.cursor = start + col.min(end - start);
|
||||
}
|
||||
|
||||
/// Move one logical line down, preserving column.
|
||||
pub fn move_down(&mut self) {
|
||||
let (line_start, col) = self.line_start_and_col();
|
||||
// End of current line.
|
||||
let mut cur_end = self.atoms.len();
|
||||
for i in line_start..self.atoms.len() {
|
||||
if matches!(self.atoms[i], Atom::Char('\n')) {
|
||||
cur_end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if cur_end == self.atoms.len() {
|
||||
return; // no next line
|
||||
}
|
||||
let next_start = cur_end + 1;
|
||||
let mut next_end = self.atoms.len();
|
||||
for i in next_start..self.atoms.len() {
|
||||
if matches!(self.atoms[i], Atom::Char('\n')) {
|
||||
next_end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let next_len = next_end - next_start;
|
||||
self.cursor = next_start + col.min(next_len);
|
||||
}
|
||||
|
||||
fn line_start_and_col(&self) -> (usize, usize) {
|
||||
let mut start = 0;
|
||||
for i in (0..self.cursor).rev() {
|
||||
if matches!(self.atoms[i], Atom::Char('\n')) {
|
||||
start = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
(start, self.cursor - start)
|
||||
let (ranges, line, col) = self.logical_line_and_col();
|
||||
let Some(&(start, end)) = ranges.get(line + 1) else {
|
||||
return;
|
||||
};
|
||||
self.cursor = start + col.min(end - start);
|
||||
}
|
||||
|
||||
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
||||
@@ -497,6 +582,12 @@ impl InputBuffer {
|
||||
content: p.content.clone(),
|
||||
});
|
||||
}
|
||||
Atom::PasteArtifact(artifact) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::PasteArtifact {
|
||||
artifact: artifact.clone(),
|
||||
});
|
||||
}
|
||||
Atom::FileRef(r) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::FileRef {
|
||||
@@ -535,6 +626,7 @@ impl InputBuffer {
|
||||
let mut cursor_row: u16 = 0;
|
||||
let mut cursor_col: u16 = 0;
|
||||
let mut cursor_set = false;
|
||||
let mut previous_was_cr = false;
|
||||
|
||||
// Record cursor once, at the point right before `atom` would be
|
||||
// placed — accounting for a wrap that the atom itself will cause.
|
||||
@@ -558,7 +650,7 @@ impl InputBuffer {
|
||||
for (i, atom) in self.atoms.iter().enumerate() {
|
||||
if !cursor_set && i == self.cursor {
|
||||
let leading = match atom {
|
||||
Atom::Char('\n') => 0,
|
||||
Atom::Char('\n' | '\r') => 0,
|
||||
Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0),
|
||||
other => other
|
||||
.chip()
|
||||
@@ -573,6 +665,21 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
match atom {
|
||||
Atom::Char('\r') => {
|
||||
flush_pending(
|
||||
&mut pending,
|
||||
&mut pending_width,
|
||||
pending_style,
|
||||
&mut rows,
|
||||
&mut row_width,
|
||||
);
|
||||
rows.push(Vec::new());
|
||||
row_width = 0;
|
||||
previous_was_cr = true;
|
||||
}
|
||||
Atom::Char('\n') if previous_was_cr => {
|
||||
previous_was_cr = false;
|
||||
}
|
||||
Atom::Char('\n') => {
|
||||
flush_pending(
|
||||
&mut pending,
|
||||
@@ -583,8 +690,10 @@ impl InputBuffer {
|
||||
);
|
||||
rows.push(Vec::new());
|
||||
row_width = 0;
|
||||
previous_was_cr = false;
|
||||
}
|
||||
Atom::Char(c) => {
|
||||
previous_was_cr = false;
|
||||
let cw = UnicodeWidthChar::width(*c).unwrap_or(0);
|
||||
if pending_style != text_style && !pending.is_empty() {
|
||||
flush_pending(
|
||||
@@ -608,6 +717,7 @@ impl InputBuffer {
|
||||
);
|
||||
}
|
||||
other => {
|
||||
previous_was_cr = false;
|
||||
let (chip_style, label) = other.chip().expect("non-char atom has a chip");
|
||||
if pending_style != chip_style && !pending.is_empty() {
|
||||
flush_pending(
|
||||
@@ -848,6 +958,161 @@ mod render_viewport_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paste_policy_tests {
|
||||
use super::*;
|
||||
use protocol::Segment;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Fixture {
|
||||
max_plain_text_chars: usize,
|
||||
max_plain_text_logical_lines: usize,
|
||||
cases: Vec<FixtureCase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FixtureCase {
|
||||
name: String,
|
||||
parts: Vec<FixturePart>,
|
||||
char_count: usize,
|
||||
logical_line_count: usize,
|
||||
presentation: FixturePresentation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FixturePart {
|
||||
value: String,
|
||||
repeat: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum FixturePresentation {
|
||||
Text,
|
||||
Chip,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
serde_json::from_str(include_str!(
|
||||
"../../../tests/fixtures/composer-paste-policy.json"
|
||||
))
|
||||
.expect("shared composer paste policy fixture must be valid")
|
||||
}
|
||||
|
||||
fn fixture_content(case: &FixtureCase) -> String {
|
||||
case.parts
|
||||
.iter()
|
||||
.map(|part| part.value.repeat(part.repeat))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_follows_shared_paste_presentation_contract() {
|
||||
let fixture = fixture();
|
||||
assert_eq!(fixture.max_plain_text_chars, MAX_PLAIN_TEXT_PASTE_CHARS);
|
||||
assert_eq!(
|
||||
fixture.max_plain_text_logical_lines,
|
||||
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||
);
|
||||
|
||||
for case in fixture.cases {
|
||||
let content = fixture_content(&case);
|
||||
let measurement = measure_paste(&content);
|
||||
let expected_presentation = match case.presentation {
|
||||
FixturePresentation::Text => PastePresentation::Text,
|
||||
FixturePresentation::Chip => PastePresentation::Chip,
|
||||
};
|
||||
assert_eq!(measurement.chars, case.char_count, "{} chars", case.name);
|
||||
assert_eq!(
|
||||
measurement.logical_lines, case.logical_line_count,
|
||||
"{} logical lines",
|
||||
case.name
|
||||
);
|
||||
assert_eq!(
|
||||
measurement.presentation(),
|
||||
expected_presentation,
|
||||
"{} presentation",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_paste_is_editable_text_at_the_cursor() {
|
||||
let mut buffer = InputBuffer::new();
|
||||
buffer.insert_str("ac");
|
||||
buffer.move_left();
|
||||
buffer.insert_paste("b".to_owned());
|
||||
|
||||
assert_eq!(buffer.plain_text(), "abc");
|
||||
assert!(
|
||||
buffer
|
||||
.atoms
|
||||
.iter()
|
||||
.all(|atom| matches!(atom, Atom::Char(_)))
|
||||
);
|
||||
assert_eq!(
|
||||
buffer.submit_segments(),
|
||||
vec![Segment::text("abc".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_multiline_paste_preserves_original_line_endings_as_text() {
|
||||
let content = "ab\r\ncd\ref";
|
||||
let mut buffer = InputBuffer::new();
|
||||
buffer.insert_paste(content.to_owned());
|
||||
|
||||
assert_eq!(buffer.plain_text(), content);
|
||||
assert!(
|
||||
buffer
|
||||
.atoms
|
||||
.iter()
|
||||
.all(|atom| matches!(atom, Atom::Char(_)))
|
||||
);
|
||||
assert_eq!(
|
||||
buffer.submit_segments(),
|
||||
vec![Segment::text(content.to_owned())]
|
||||
);
|
||||
|
||||
let rendered: Vec<String> = buffer
|
||||
.render(80)
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(rendered, vec!["ab", "cd", "ef"]);
|
||||
|
||||
buffer.move_up();
|
||||
assert_eq!(buffer.cursor, 6);
|
||||
buffer.move_up();
|
||||
assert_eq!(buffer.cursor, 2);
|
||||
buffer.move_down();
|
||||
assert_eq!(buffer.cursor, 6);
|
||||
buffer.move_home();
|
||||
assert_eq!(buffer.cursor, 4);
|
||||
buffer.move_end();
|
||||
assert_eq!(buffer.cursor, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_paste_is_a_noop() {
|
||||
let mut buffer = InputBuffer::new();
|
||||
buffer.insert_str("unchanged");
|
||||
let paste_id = buffer.next_paste_id;
|
||||
buffer.insert_paste(String::new());
|
||||
|
||||
assert_eq!(buffer.plain_text(), "unchanged");
|
||||
assert_eq!(buffer.next_paste_id, paste_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod submit_segments_tests {
|
||||
use super::*;
|
||||
@@ -873,7 +1138,8 @@ mod submit_segments_tests {
|
||||
for c in "see ".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.insert_paste("line1\nline2".into());
|
||||
let pasted = "line1\nline2\nline3\nline4";
|
||||
buf.insert_paste(pasted.into());
|
||||
for c in " end".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
@@ -890,9 +1156,9 @@ mod submit_segments_tests {
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(content, "line1\nline2");
|
||||
assert_eq!(*chars, "line1\nline2".chars().count() as u32);
|
||||
assert_eq!(*lines, 2);
|
||||
assert_eq!(content, pasted);
|
||||
assert_eq!(*chars, pasted.chars().count() as u32);
|
||||
assert_eq!(*lines, 4);
|
||||
}
|
||||
other => panic!("expected Paste, got {other:?}"),
|
||||
}
|
||||
@@ -902,6 +1168,45 @@ mod submit_segments_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_direct_paste_remains_a_typed_segment_without_reclassification() {
|
||||
let original = Segment::Paste {
|
||||
id: 7,
|
||||
chars: 1,
|
||||
lines: 1,
|
||||
content: "x".to_owned(),
|
||||
};
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.replace_with_segments(std::slice::from_ref(&original));
|
||||
|
||||
assert_eq!(buf.submit_segments(), vec![original]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_paste_artifact_remains_a_typed_segment() {
|
||||
let artifact = protocol::PasteArtifactRef {
|
||||
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b2".to_string(),
|
||||
created_at_ms: 1_700_000_000_000,
|
||||
media_type: protocol::PasteArtifactMediaType::TextPlainUtf8,
|
||||
availability: protocol::PasteArtifactAvailability::Available,
|
||||
byte_len: 65_536,
|
||||
char_count: 65_530,
|
||||
line_count: 200,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: "entry-1".to_string(),
|
||||
};
|
||||
let original = Segment::PasteArtifact {
|
||||
artifact: artifact.clone(),
|
||||
};
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.replace_with_segments(std::slice::from_ref(&original));
|
||||
|
||||
assert_eq!(
|
||||
buf.submit_segments(),
|
||||
vec![Segment::PasteArtifact { artifact }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_buffer_yields_empty_segments() {
|
||||
let buf = InputBuffer::new();
|
||||
@@ -911,7 +1216,7 @@ mod submit_segments_tests {
|
||||
#[test]
|
||||
fn leading_paste_does_not_emit_empty_text() {
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.insert_paste("X".into());
|
||||
buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||
let segs = buf.submit_segments();
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert!(matches!(segs[0], Segment::Paste { .. }));
|
||||
@@ -1011,7 +1316,7 @@ mod completion_prefix_tests {
|
||||
#[test]
|
||||
fn trigger_after_chip_atom() {
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.insert_paste("X".into());
|
||||
buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||
for c in "@sr".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
@@ -1120,7 +1425,7 @@ mod word_motion_tests {
|
||||
for c in "foo ".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.insert_paste("anything".into());
|
||||
buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||
for c in " bar".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
@@ -1219,7 +1524,9 @@ mod word_motion_tests {
|
||||
for a in &buf.atoms {
|
||||
match a {
|
||||
Atom::Char(c) => out.push(*c),
|
||||
Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => out.push_str("<P>"),
|
||||
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
|
||||
out.push_str("<P>")
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -1277,7 +1584,7 @@ mod word_motion_tests {
|
||||
for c in "foo ".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.insert_paste("anything".into());
|
||||
buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||
for c in " bar".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
|
||||
+77
-96
@@ -8,24 +8,20 @@ mod command;
|
||||
mod composer_history;
|
||||
mod composer_keys;
|
||||
mod console;
|
||||
mod dashboard;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
mod e2e_observer;
|
||||
mod input;
|
||||
pub mod keys;
|
||||
mod markdown;
|
||||
mod picker;
|
||||
mod role_session_registry;
|
||||
mod scroll;
|
||||
pub mod setup_model;
|
||||
mod spawn;
|
||||
mod standalone_picker;
|
||||
mod standalone_spawn;
|
||||
mod task;
|
||||
mod text_selection;
|
||||
mod tool;
|
||||
mod ui;
|
||||
mod view_mode;
|
||||
mod worker_list;
|
||||
mod workspace_panel;
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
@@ -34,7 +30,6 @@ use std::process::ExitCode;
|
||||
use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
||||
use session_store::SegmentId;
|
||||
|
||||
use client::{Target, WorkerConnectionSelector, WorkerListRequest};
|
||||
|
||||
@@ -47,42 +42,69 @@ pub struct LaunchOptions {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LaunchMode {
|
||||
/// Start one client-owned in-process Standalone Worker.
|
||||
Spawn {
|
||||
worker_name: Option<String>,
|
||||
profile: Option<String>,
|
||||
},
|
||||
/// `yoi --worker <name>`: attach to a live Worker by name if possible;
|
||||
/// otherwise launch the Worker runtime command with `--worker <name>` so it
|
||||
/// resumes from name-keyed state or creates a fresh same-name Worker.
|
||||
WorkerName {
|
||||
worker_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
},
|
||||
/// `yoi workers` / `yoi --backend <url>`: list workers through the selected
|
||||
/// connection target, then attach to the selected Worker.
|
||||
/// Restore one client-owned standalone Worker. The current cwd is the default scope;
|
||||
/// `include_all` opts into all standalone Workers under the same client data root.
|
||||
StandaloneResume { include_all: bool },
|
||||
/// List Backend Workers and attach to the selected Worker.
|
||||
Workers {
|
||||
runtime_id: Option<String>,
|
||||
include_stopped: bool,
|
||||
all: bool,
|
||||
},
|
||||
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: open one Worker
|
||||
/// through the selected connection target.
|
||||
/// Open one Backend Worker through the selected connection target.
|
||||
OpenWorker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
/// `yoi resume`: open the Worker picker, then attach to the selected live Worker
|
||||
/// or restore the selected stopped Worker by name. Without `--all`, the picker
|
||||
/// is scoped to the current runtime workspace.
|
||||
Resume { all: bool },
|
||||
/// `yoi --session <UUID>`: skip the picker, go straight to the
|
||||
/// resume name dialog with `id` baked in.
|
||||
ResumeWithSession {
|
||||
id: SegmentId,
|
||||
worker_name: Option<String>,
|
||||
},
|
||||
/// `yoi panel`: open the workspace Dashboard from the current workspace.
|
||||
Panel { include_stopped: bool },
|
||||
/// Open the Backend Workspace dashboard.
|
||||
Panel,
|
||||
}
|
||||
|
||||
struct TerminalModeGuard {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl TerminalModeGuard {
|
||||
fn new() -> Self {
|
||||
Self { active: true }
|
||||
}
|
||||
|
||||
fn restore(&mut self) -> io::Result<()> {
|
||||
if !self.active {
|
||||
return Ok(());
|
||||
}
|
||||
self.active = false;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(
|
||||
stdout,
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen,
|
||||
DisableBracketedPaste,
|
||||
crossterm::cursor::Show
|
||||
)?;
|
||||
disable_raw_mode()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalModeGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.active {
|
||||
let mut stdout = io::stdout();
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen,
|
||||
DisableBracketedPaste,
|
||||
crossterm::cursor::Show
|
||||
);
|
||||
let _ = disable_raw_mode();
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
@@ -109,56 +131,46 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
eprintln!("yoi: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let mut terminal_mode = TerminalModeGuard::new();
|
||||
|
||||
let result = match mode {
|
||||
LaunchMode::Spawn {
|
||||
worker_name,
|
||||
profile,
|
||||
} => match target.spawn_worker() {
|
||||
} => match standalone_spawn::select(&workspace_root, worker_name, profile) {
|
||||
Ok(Some(selection)) => match target.spawn_worker() {
|
||||
Ok(spawn) => {
|
||||
console::run_spawn(None, worker_name, profile, spawn.runtime_command).await
|
||||
}
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::WorkerName {
|
||||
worker_name,
|
||||
socket_override,
|
||||
} => match target.worker_by_name() {
|
||||
Ok(worker_by_name) => {
|
||||
console::run_worker_name(
|
||||
worker_name,
|
||||
socket_override,
|
||||
worker_by_name.runtime_command,
|
||||
console::run_standalone(
|
||||
workspace_root.clone(),
|
||||
spawn.state_dir,
|
||||
Some(selection.worker_name),
|
||||
Some(selection.profile),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
||||
},
|
||||
Ok(None) => Ok(()),
|
||||
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::StandaloneResume { include_all } => {
|
||||
match standalone_picker::pick(target.as_ref(), include_all) {
|
||||
Ok(Some(intent)) => console::run_standalone_restore(intent).await,
|
||||
Ok(None) => Ok(()),
|
||||
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
||||
}
|
||||
}
|
||||
LaunchMode::Workers {
|
||||
runtime_id,
|
||||
include_stopped,
|
||||
all,
|
||||
} => match target.list_workers(if include_stopped {
|
||||
WorkerListRequest::with_stopped(runtime_id)
|
||||
} else {
|
||||
WorkerListRequest::new(runtime_id)
|
||||
}) {
|
||||
Ok(worker_list) => {
|
||||
if let Some(target) = worker_list.backend_target {
|
||||
backend_worker_picker::run(target, worker_list.include_stopped).await
|
||||
} else if let Some(runtime_command) = worker_list.local_runtime_command {
|
||||
console::run_worker_picker(
|
||||
runtime_command,
|
||||
workspace_root.clone(),
|
||||
all,
|
||||
worker_list.include_stopped,
|
||||
)
|
||||
backend_worker_picker::run(worker_list.backend_target, worker_list.include_stopped)
|
||||
.await
|
||||
} else {
|
||||
Err(Box::new(io::Error::other(
|
||||
"worker list target did not include a local or backend source",
|
||||
)) as Box<dyn std::error::Error>)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
@@ -169,28 +181,12 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
Ok(connection) => console::run_backend_runtime(connection.target).await,
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::Resume { all } => match target.resume_worker() {
|
||||
Ok(resume) => {
|
||||
console::run_resume(resume.runtime_command, workspace_root.clone(), all).await
|
||||
LaunchMode::Panel => match target.dashboard() {
|
||||
Ok(dashboard) => {
|
||||
backend_dashboard::launch(dashboard.base_url, dashboard.workspace_id).await
|
||||
}
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::ResumeWithSession { id, worker_name } => match target.spawn_worker() {
|
||||
Ok(spawn) => {
|
||||
console::run_spawn(Some(id), worker_name, None, spawn.runtime_command).await
|
||||
}
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::Panel { include_stopped } => match target.dashboard() {
|
||||
Ok(client::Dashboard::Local { runtime_command }) => {
|
||||
dashboard::launch(runtime_command, include_stopped).await
|
||||
}
|
||||
Ok(client::Dashboard::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
}) => backend_dashboard::launch(base_url, workspace_id).await,
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
};
|
||||
|
||||
// Always restore the terminal first so any pending eprintln below
|
||||
@@ -198,15 +194,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
// alternate-screen buffer.
|
||||
#[cfg(feature = "e2e-test")]
|
||||
e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({}));
|
||||
let mut stdout = io::stdout();
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen,
|
||||
DisableBracketedPaste
|
||||
);
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(stdout, crossterm::cursor::Show);
|
||||
let _ = terminal_mode.restore();
|
||||
#[cfg(feature = "e2e-test")]
|
||||
e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({}));
|
||||
|
||||
@@ -217,14 +205,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
// SpawnError has already been painted into the inline
|
||||
// viewport's final frame, so it's already visible in the
|
||||
// user's scrollback — printing it again would be a noisy
|
||||
// duplicate. Other errors (worker-name failures, terminal setup
|
||||
// hiccups, etc.) need surfacing here.
|
||||
if e.downcast_ref::<spawn::SpawnError>().is_none() {
|
||||
eprintln!("yoi: {e}");
|
||||
}
|
||||
#[cfg(feature = "e2e-test")]
|
||||
e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" }));
|
||||
ExitCode::FAILURE
|
||||
|
||||
@@ -1,525 +0,0 @@
|
||||
//! Inline-viewport "pick a Worker to attach or restore" UX.
|
||||
//!
|
||||
//! Reads live Worker allocations from the runtime registry and stopped Worker state
|
||||
//! from the session-store worker metadata name-keyed metadata. Picking a live row attaches to
|
||||
//! its socket; picking a stopped row restores via the Worker runtime command.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{Frame, TerminalOptions, Viewport};
|
||||
use session_store::FsStore;
|
||||
use session_store::FsWorkerStore;
|
||||
|
||||
use crate::worker_list::{
|
||||
LiveWorkerInfo, StoredMetadataState, StoredWorkerInfo, WorkerList, WorkerListEntry,
|
||||
WorkerVisibilitySource, live_socket_for_worker as worker_list_live_socket_for_worker,
|
||||
read_reachable_live_worker_infos, read_stored_worker_infos,
|
||||
};
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PickerError {
|
||||
Io(io::Error),
|
||||
Store(session_store::StoreError),
|
||||
NoWorkers { all: bool },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PickerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(e) => write!(f, "io error: {e}"),
|
||||
Self::Store(e) => write!(f, "session store error: {e}"),
|
||||
Self::NoWorkers { all: true } => write!(
|
||||
f,
|
||||
"no workers found — start a fresh Worker with `yoi` and try again"
|
||||
),
|
||||
Self::NoWorkers { all: false } => write!(
|
||||
f,
|
||||
"no workers found in this workspace — use `yoi resume --all` to list all host/data-dir Workers"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PickerError {}
|
||||
|
||||
impl From<io::Error> for PickerError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<session_store::StoreError> for PickerError {
|
||||
fn from(e: session_store::StoreError) -> Self {
|
||||
Self::Store(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PickerOutcome {
|
||||
/// User picked a Worker. `socket_override` is set for live rows when the
|
||||
/// runtime registry knows the exact socket path; stopped rows leave it
|
||||
/// empty so the caller restores by spawning the Worker runtime command.
|
||||
Picked {
|
||||
worker_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
},
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PickerOptions {
|
||||
scope: PickerScope,
|
||||
include_stopped: bool,
|
||||
}
|
||||
|
||||
impl PickerOptions {
|
||||
pub(crate) fn workspace(workspace_root: PathBuf) -> Self {
|
||||
Self {
|
||||
scope: PickerScope::Workspace(workspace_root),
|
||||
include_stopped: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn all() -> Self {
|
||||
Self {
|
||||
scope: PickerScope::All,
|
||||
include_stopped: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_stopped(mut self, include_stopped: bool) -> Self {
|
||||
self.include_stopped = include_stopped;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum PickerScope {
|
||||
Workspace(PathBuf),
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WorkerRowState {
|
||||
Live,
|
||||
Stopped,
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
impl WorkerRowState {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Live => "live",
|
||||
Self::Stopped => "stopped",
|
||||
Self::Corrupt => "corrupt",
|
||||
}
|
||||
}
|
||||
|
||||
fn style(self) -> Style {
|
||||
match self {
|
||||
Self::Live => Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Self::Stopped => Style::default().fg(Color::Yellow),
|
||||
Self::Corrupt => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_for_options(
|
||||
options: &PickerOptions,
|
||||
stored_workers: Vec<StoredWorkerInfo>,
|
||||
live_workers: Vec<LiveWorkerInfo>,
|
||||
) -> WorkerList {
|
||||
let stored_workers = if options.include_stopped {
|
||||
stored_workers
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
match &options.scope {
|
||||
PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources(
|
||||
WorkerVisibilitySource::ResumePicker,
|
||||
stored_workers,
|
||||
live_workers,
|
||||
None,
|
||||
MAX_ROWS,
|
||||
workspace_root,
|
||||
),
|
||||
PickerScope::All => WorkerList::from_sources(
|
||||
WorkerVisibilitySource::ResumePicker,
|
||||
stored_workers,
|
||||
live_workers,
|
||||
None,
|
||||
MAX_ROWS,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
|
||||
let store_dir = default_store_dir()?;
|
||||
let store = FsStore::new(&store_dir)?;
|
||||
let worker_metadata_store =
|
||||
FsWorkerStore::new(default_worker_metadata_dir()?).map_err(io::Error::other)?;
|
||||
let stored_workers = read_stored_worker_infos(&store, &worker_metadata_store)?;
|
||||
let live_workers = read_reachable_live_worker_infos(&store)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut list = list_for_options(&options, stored_workers, live_workers);
|
||||
if list.entries.is_empty() {
|
||||
return Err(PickerError::NoWorkers {
|
||||
all: matches!(options.scope, PickerScope::All),
|
||||
});
|
||||
}
|
||||
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
loop {
|
||||
terminal.draw(|f| draw(f, &list))?;
|
||||
match poll_event()? {
|
||||
None => continue,
|
||||
Some(Action::Up) => {
|
||||
let selected = list.selected_index().saturating_sub(1);
|
||||
list.select_index(selected);
|
||||
}
|
||||
Some(Action::Down) => {
|
||||
let selected = list.selected_index();
|
||||
if selected + 1 < list.entries.len() {
|
||||
list.select_index(selected + 1);
|
||||
}
|
||||
}
|
||||
Some(Action::Submit) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
let entry = list.selected_entry().expect("non-empty worker list");
|
||||
return Ok(PickerOutcome::Picked {
|
||||
worker_name: entry.name.clone(),
|
||||
socket_override: entry.attach_socket_path().map(PathBuf::from),
|
||||
});
|
||||
}
|
||||
Some(Action::Cancel) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(PickerOutcome::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Park the cursor at the very bottom of the picker's inline viewport and emit
|
||||
/// one newline before dropping the terminal. This keeps any next inline viewport
|
||||
/// from drawing over the lower picker rows.
|
||||
fn close_viewport(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
||||
let area = terminal.get_frame().area();
|
||||
let last_row = area.bottom().saturating_sub(1);
|
||||
terminal.set_cursor_position((0, last_row))?;
|
||||
use std::io::Write;
|
||||
let mut out = io::stdout();
|
||||
out.write_all(b"\r\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_store_dir() -> Result<PathBuf, PickerError> {
|
||||
manifest::paths::sessions_dir().ok_or_else(|| {
|
||||
PickerError::Io(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"could not resolve sessions directory \
|
||||
(set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn default_worker_metadata_dir() -> Result<PathBuf, PickerError> {
|
||||
manifest::paths::data_dir()
|
||||
.map(|dir| dir.join("workers"))
|
||||
.ok_or_else(|| {
|
||||
PickerError::Io(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"could not resolve worker state directory \
|
||||
(set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn live_socket_for_worker(worker_name: &str) -> Option<PathBuf> {
|
||||
worker_list_live_socket_for_worker(worker_name)
|
||||
}
|
||||
|
||||
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(VIEWPORT_LINES),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
enum Action {
|
||||
Up,
|
||||
Down,
|
||||
Submit,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
fn poll_event() -> io::Result<Option<Action>> {
|
||||
if !event::poll(Duration::from_millis(100))? {
|
||||
return Ok(None);
|
||||
}
|
||||
match event::read()? {
|
||||
TermEvent::Key(k) if k.kind != KeyEventKind::Release => {
|
||||
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
|
||||
Ok(match k.code {
|
||||
KeyCode::Up => Some(Action::Up),
|
||||
KeyCode::Down => Some(Action::Down),
|
||||
KeyCode::Char('k') if !ctrl => Some(Action::Up),
|
||||
KeyCode::Char('j') if !ctrl => Some(Action::Down),
|
||||
KeyCode::Enter => Some(Action::Submit),
|
||||
KeyCode::Esc => Some(Action::Cancel),
|
||||
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(f: &mut Frame<'_>, list: &WorkerList) {
|
||||
let area = f.area();
|
||||
let mut constraints: Vec<Constraint> = Vec::with_capacity(list.entries.len() + 3);
|
||||
constraints.push(Constraint::Length(1)); // title
|
||||
for _ in &list.entries {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // hint
|
||||
constraints.push(Constraint::Length(1)); // spacer
|
||||
let layout = Layout::vertical(constraints).split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
picker_title(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
layout[0],
|
||||
);
|
||||
|
||||
let selected = list.selected_index();
|
||||
for (i, entry) in list.entries.iter().enumerate() {
|
||||
f.render_widget(
|
||||
Paragraph::new(row_line(entry, i == selected)),
|
||||
layout[i + 1],
|
||||
);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" select "),
|
||||
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
||||
Span::raw(" open/restore "),
|
||||
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" cancel"),
|
||||
])),
|
||||
layout[list.entries.len() + 1],
|
||||
);
|
||||
}
|
||||
|
||||
fn picker_title() -> &'static str {
|
||||
"resume worker pick a worker"
|
||||
}
|
||||
|
||||
fn row_line(entry: &WorkerListEntry, selected: bool) -> Line<'_> {
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
let name_style = if selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
let preview_style = if selected {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let state = row_state(entry);
|
||||
let _visibility = entry.visibility;
|
||||
let _source_kinds = &entry.source_kinds;
|
||||
|
||||
let mut spans = vec![
|
||||
Span::raw(marker),
|
||||
Span::styled(entry.name.as_str(), name_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("[{}]", state.label()), state.style()),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format_updated_at(entry.summary.updated_at),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(debug_ids(entry), Style::default().fg(Color::DarkGray)),
|
||||
];
|
||||
if let Some(preview) = entry.summary.preview.as_ref() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(preview.as_str(), preview_style));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn row_state(entry: &WorkerListEntry) -> WorkerRowState {
|
||||
if entry.live.as_ref().is_some_and(|live| live.reachable) {
|
||||
return WorkerRowState::Live;
|
||||
}
|
||||
if entry
|
||||
.stored
|
||||
.as_ref()
|
||||
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_)))
|
||||
{
|
||||
return WorkerRowState::Corrupt;
|
||||
}
|
||||
WorkerRowState::Stopped
|
||||
}
|
||||
|
||||
fn format_updated_at(updated_at: u64) -> String {
|
||||
if updated_at == 0 {
|
||||
"updated: —".to_string()
|
||||
} else {
|
||||
format!("updated: {updated_at}")
|
||||
}
|
||||
}
|
||||
|
||||
fn debug_ids(entry: &WorkerListEntry) -> String {
|
||||
let session = entry
|
||||
.summary
|
||||
.active_session_id
|
||||
.map(short_id)
|
||||
.unwrap_or_else(|| "--------".to_string());
|
||||
let segment = entry
|
||||
.summary
|
||||
.active_segment_id
|
||||
.map(short_id)
|
||||
.unwrap_or_else(|| "--------".to_string());
|
||||
format!("s:{session} g:{segment}")
|
||||
}
|
||||
|
||||
fn short_id<T: ToString>(id: T) -> String {
|
||||
id.to_string().chars().take(8).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn picker_title_names_pods_not_sessions() {
|
||||
assert_eq!(picker_title(), "resume worker pick a worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_no_pods_message_mentions_all_for_workspace_scope() {
|
||||
let message = PickerError::NoWorkers { all: false }.to_string();
|
||||
assert!(message.contains("no workers found in this workspace"));
|
||||
assert!(message.contains("yoi resume --all"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_no_pods_message_keeps_fresh_pod_hint_for_all_scope() {
|
||||
let message = PickerError::NoWorkers { all: true }.to_string();
|
||||
assert!(message.contains("start a fresh Worker with `yoi`"));
|
||||
assert!(!message.contains("yoi resume --all"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_workspace_options_filter_by_workspace_metadata() {
|
||||
let list = list_for_options(
|
||||
&PickerOptions::workspace(PathBuf::from("/workspace/current")),
|
||||
vec![
|
||||
stored_pod("current", Some("/workspace/current"), 3),
|
||||
stored_pod("other", Some("/workspace/other"), 2),
|
||||
stored_pod("legacy", None, 1),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let names: Vec<_> = list
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["current"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_all_options_include_host_wide_and_legacy_pods() {
|
||||
let list = list_for_options(
|
||||
&PickerOptions::all(),
|
||||
vec![
|
||||
stored_pod("current", Some("/workspace/current"), 3),
|
||||
stored_pod("other", Some("/workspace/other"), 2),
|
||||
stored_pod("legacy", None, 1),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let names: Vec<_> = list
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["current", "other", "legacy"]);
|
||||
}
|
||||
|
||||
fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredWorkerInfo {
|
||||
StoredWorkerInfo {
|
||||
worker_name: name.to_string(),
|
||||
metadata_state: StoredMetadataState::Present,
|
||||
active_session_id: None,
|
||||
active_segment_id: None,
|
||||
updated_at,
|
||||
workspace_root: workspace_root.map(PathBuf::from),
|
||||
preview: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_row_shows_live_pending_preview_and_runtime_segment_id() {
|
||||
let segment_id = session_store::new_segment_id();
|
||||
let entry = WorkerList::from_sources(
|
||||
WorkerVisibilitySource::ResumePicker,
|
||||
vec![],
|
||||
vec![crate::worker_list::LiveWorkerInfo {
|
||||
worker_name: "pending".to_string(),
|
||||
socket_path: PathBuf::from("/tmp/pending.sock"),
|
||||
status: Some(protocol::WorkerStatus::Idle),
|
||||
reachable: true,
|
||||
segment_id: Some(segment_id),
|
||||
summary: crate::worker_list::WorkerEntrySummary::default(),
|
||||
}],
|
||||
None,
|
||||
10,
|
||||
)
|
||||
.entries
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let text = row_line(&entry, false)
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(text.contains("[live]"));
|
||||
assert!(text.contains("[live, pending segment]"));
|
||||
assert!(text.contains(&format!("g:{}", short_id(segment_id))));
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const REGISTRY_VERSION: u32 = 1;
|
||||
const REGISTRY_FILE: &str = "role-sessions.json";
|
||||
const REGISTRY_LOCK_FILE: &str = "role-sessions.lock";
|
||||
const CLAIMS_DIR: &str = "ticket-claims";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PanelRegistryStore {
|
||||
root: PathBuf,
|
||||
workspace_root: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct RoleSessionRegistry {
|
||||
pub version: u32,
|
||||
pub workspace_root: String,
|
||||
pub sessions: BTreeMap<String, RoleSessionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct RoleSessionRecord {
|
||||
pub role: String,
|
||||
pub worker_name: String,
|
||||
pub origin: RoleSessionOrigin,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub related_tickets: Vec<RelatedTicketRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum RoleSessionOrigin {
|
||||
PreTicketIntake,
|
||||
TicketClaim,
|
||||
RoleLaunch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) struct RelatedTicketRef {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct TicketClaim {
|
||||
pub ticket_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ticket_slug: Option<String>,
|
||||
pub worker_name: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PanelRegistrySnapshot {
|
||||
pub sessions: Vec<RoleSessionRecord>,
|
||||
pub claims: Vec<TicketClaim>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum TicketClaimResult {
|
||||
Claimed,
|
||||
AlreadyOwned(TicketClaim),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum PanelRegistryError {
|
||||
Io(io::Error),
|
||||
Json(serde_json::Error),
|
||||
TicketAlreadyClaimed(TicketClaim),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PanelRegistryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(f, "local role session registry I/O error: {error}"),
|
||||
Self::Json(error) => write!(f, "local role session registry JSON error: {error}"),
|
||||
Self::TicketAlreadyClaimed(claim) => write!(
|
||||
f,
|
||||
"Ticket {} is already claimed locally by {} ({})",
|
||||
claim.ticket_id, claim.worker_name, claim.role
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PanelRegistryError {}
|
||||
|
||||
impl From<io::Error> for PanelRegistryError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for PanelRegistryError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::Json(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelRegistryStore {
|
||||
pub(crate) fn default_for_workspace(workspace_root: &Path) -> Result<Self, PanelRegistryError> {
|
||||
let data_dir = manifest::paths::data_dir().ok_or_else(|| {
|
||||
PanelRegistryError::Io(io::Error::other("failed to resolve yoi data directory"))
|
||||
})?;
|
||||
Ok(Self::for_data_dir(data_dir, workspace_root))
|
||||
}
|
||||
|
||||
pub(crate) fn for_data_dir(data_dir: impl AsRef<Path>, workspace_root: &Path) -> Self {
|
||||
let workspace_root = normalized_workspace_key(workspace_root);
|
||||
let leaf = workspace_leaf(&workspace_root);
|
||||
let digest = fnv1a64_hex(workspace_root.as_bytes());
|
||||
Self {
|
||||
root: data_dir
|
||||
.as_ref()
|
||||
.join("panel")
|
||||
.join("workspaces")
|
||||
.join(format!("{leaf}-{digest}")),
|
||||
workspace_root: Some(workspace_root),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_root(root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
root: root.into(),
|
||||
workspace_root: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> Result<PanelRegistrySnapshot, PanelRegistryError> {
|
||||
let registry = self.load_registry()?;
|
||||
let claims = self.load_claims()?;
|
||||
Ok(PanelRegistrySnapshot {
|
||||
sessions: registry.sessions.into_values().collect(),
|
||||
claims,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_registry(&self) -> Result<RoleSessionRegistry, PanelRegistryError> {
|
||||
match fs::read(self.registry_path()) {
|
||||
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(RoleSessionRegistry {
|
||||
version: REGISTRY_VERSION,
|
||||
workspace_root: self.workspace_root.clone().unwrap_or_default(),
|
||||
sessions: BTreeMap::new(),
|
||||
}),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_session(
|
||||
&self,
|
||||
worker_name: impl Into<String>,
|
||||
role: impl Into<String>,
|
||||
origin: RoleSessionOrigin,
|
||||
session_id: Option<String>,
|
||||
related_tickets: impl IntoIterator<Item = RelatedTicketRef>,
|
||||
) -> Result<(), PanelRegistryError> {
|
||||
let worker_name = worker_name.into();
|
||||
let role = role.into();
|
||||
let related_tickets: Vec<RelatedTicketRef> = related_tickets.into_iter().collect();
|
||||
self.update_registry(|registry| {
|
||||
let now = now_timestamp_string();
|
||||
let mut tickets: BTreeSet<RelatedTicketRef> = registry
|
||||
.sessions
|
||||
.get(&worker_name)
|
||||
.map(|record| record.related_tickets.iter().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
tickets.extend(related_tickets);
|
||||
let created_at = registry
|
||||
.sessions
|
||||
.get(&worker_name)
|
||||
.map(|record| record.created_at.clone())
|
||||
.unwrap_or_else(|| now.clone());
|
||||
registry.sessions.insert(
|
||||
worker_name.clone(),
|
||||
RoleSessionRecord {
|
||||
role,
|
||||
worker_name,
|
||||
origin,
|
||||
created_at,
|
||||
updated_at: now,
|
||||
session_id,
|
||||
related_tickets: tickets.into_iter().collect(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn claim_ticket(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
ticket_slug: Option<&str>,
|
||||
worker_name: &str,
|
||||
role: &str,
|
||||
) -> Result<TicketClaimResult, PanelRegistryError> {
|
||||
fs::create_dir_all(self.claims_dir())?;
|
||||
let claim_path = self.claim_path(ticket_id);
|
||||
let claim = TicketClaim {
|
||||
ticket_id: ticket_id.to_string(),
|
||||
ticket_slug: ticket_slug.map(ToOwned::to_owned),
|
||||
worker_name: worker_name.to_string(),
|
||||
role: role.to_string(),
|
||||
};
|
||||
match self.create_claim_file(&claim_path, &claim) {
|
||||
Ok(()) => {
|
||||
if let Err(error) = self.record_session(
|
||||
worker_name.to_string(),
|
||||
role.to_string(),
|
||||
RoleSessionOrigin::TicketClaim,
|
||||
None,
|
||||
[RelatedTicketRef {
|
||||
id: ticket_id.to_string(),
|
||||
slug: ticket_slug.map(ToOwned::to_owned),
|
||||
}],
|
||||
) {
|
||||
let _ = fs::remove_file(&claim_path);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(TicketClaimResult::Claimed)
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
let existing = self.load_claim(ticket_id)?;
|
||||
if existing.worker_name == worker_name && existing.role == role {
|
||||
Ok(TicketClaimResult::AlreadyOwned(existing))
|
||||
} else {
|
||||
Err(PanelRegistryError::TicketAlreadyClaimed(existing))
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_claim(&self, ticket_id: &str) -> Result<TicketClaim, PanelRegistryError> {
|
||||
let bytes = fs::read(self.claim_path(ticket_id))?;
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
pub(crate) fn claim_for_ticket(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<TicketClaim>, PanelRegistryError> {
|
||||
match self.load_claim(ticket_id) {
|
||||
Ok(claim) => Ok(Some(claim)),
|
||||
Err(PanelRegistryError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_registry(
|
||||
&self,
|
||||
update: impl FnOnce(&mut RoleSessionRegistry) -> Result<(), PanelRegistryError>,
|
||||
) -> Result<(), PanelRegistryError> {
|
||||
fs::create_dir_all(&self.root)?;
|
||||
let _lock = self.acquire_registry_lock()?;
|
||||
let mut registry = self.load_registry()?;
|
||||
registry.version = REGISTRY_VERSION;
|
||||
if let Some(workspace_root) = self.workspace_root.as_ref() {
|
||||
registry.workspace_root = workspace_root.clone();
|
||||
}
|
||||
update(&mut registry)?;
|
||||
self.save_registry(®istry)
|
||||
}
|
||||
|
||||
fn acquire_registry_lock(&self) -> Result<RegistryLockGuard, PanelRegistryError> {
|
||||
let lock_path = self.root.join(REGISTRY_LOCK_FILE);
|
||||
for _ in 0..50 {
|
||||
match OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&lock_path)
|
||||
{
|
||||
Ok(_) => return Ok(RegistryLockGuard { path: lock_path }),
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Err(PanelRegistryError::Io(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"timed out acquiring panel role session registry lock",
|
||||
)))
|
||||
}
|
||||
|
||||
fn save_registry(&self, registry: &RoleSessionRegistry) -> Result<(), PanelRegistryError> {
|
||||
let path = self.registry_path();
|
||||
let temp_path = path.with_extension(format!("json.{}.tmp", now_timestamp_string()));
|
||||
let bytes = serde_json::to_vec_pretty(registry)?;
|
||||
fs::write(&temp_path, [&bytes[..], b"\n"].concat())?;
|
||||
fs::rename(temp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_claim_file(&self, claim_path: &Path, claim: &TicketClaim) -> io::Result<()> {
|
||||
let temp_path = self
|
||||
.claims_dir()
|
||||
.join(format!(".{}.tmp", now_timestamp_string()));
|
||||
let bytes = serde_json::to_vec_pretty(claim).map_err(io::Error::other)?;
|
||||
fs::write(&temp_path, [&bytes[..], b"\n"].concat())?;
|
||||
let link_result = fs::hard_link(&temp_path, claim_path);
|
||||
let remove_result = fs::remove_file(&temp_path);
|
||||
match (link_result, remove_result) {
|
||||
(Ok(()), Ok(())) | (Ok(()), Err(_)) => Ok(()),
|
||||
(Err(error), _) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_claims(&self) -> Result<Vec<TicketClaim>, PanelRegistryError> {
|
||||
let mut claims: Vec<TicketClaim> = Vec::new();
|
||||
match fs::read_dir(self.claims_dir()) {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_file()
|
||||
&& entry
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "json")
|
||||
{
|
||||
let bytes = fs::read(entry.path())?;
|
||||
claims.push(serde_json::from_slice(&bytes)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
claims.sort_by(|left, right| left.ticket_id.cmp(&right.ticket_id));
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
fn registry_path(&self) -> PathBuf {
|
||||
self.root.join(REGISTRY_FILE)
|
||||
}
|
||||
|
||||
fn claims_dir(&self) -> PathBuf {
|
||||
self.root.join(CLAIMS_DIR)
|
||||
}
|
||||
|
||||
fn claim_path(&self, ticket_id: &str) -> PathBuf {
|
||||
self.claims_dir()
|
||||
.join(format!("{}.json", encode_path_component(ticket_id)))
|
||||
}
|
||||
}
|
||||
|
||||
struct RegistryLockGuard {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for RegistryLockGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelRegistrySnapshot {
|
||||
pub(crate) fn empty() -> Self {
|
||||
Self {
|
||||
sessions: Vec::new(),
|
||||
claims: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn claim_for_ticket(&self, ticket_id: &str) -> Option<&TicketClaim> {
|
||||
self.claims
|
||||
.iter()
|
||||
.find(|claim| claim.ticket_id == ticket_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_workspace_key(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
fn workspace_leaf(workspace_root: &str) -> String {
|
||||
let leaf = workspace_root
|
||||
.rsplit('/')
|
||||
.find(|part| !part.is_empty())
|
||||
.unwrap_or("workspace");
|
||||
let sanitized = leaf
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
if sanitized.is_empty() {
|
||||
"workspace".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn fnv1a64_hex(bytes: &[u8]) -> String {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in bytes {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{hash:016x}")
|
||||
}
|
||||
|
||||
fn encode_path_component(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' => encoded.push(byte as char),
|
||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn now_timestamp_string() -> String {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos().to_string())
|
||||
.unwrap_or_else(|_| "0".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn registry_path_is_workspace_scoped_under_data_dir() {
|
||||
let data_dir = TempDir::new().unwrap();
|
||||
let store = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi"));
|
||||
let other = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/other"));
|
||||
|
||||
assert!(store.root().starts_with(data_dir.path()));
|
||||
let root = store.root().to_string_lossy();
|
||||
assert!(root.contains("panel/workspaces/yoi-"));
|
||||
assert_ne!(store.root(), other.root());
|
||||
|
||||
store
|
||||
.record_session(
|
||||
"ticket-intake-preticket",
|
||||
"intake",
|
||||
RoleSessionOrigin::PreTicketIntake,
|
||||
None,
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.load_registry().unwrap().workspace_root, "/repo/yoi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_ticket_rejects_second_active_local_pod() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = PanelRegistryStore::from_root(temp.path().join("registry"));
|
||||
|
||||
assert!(matches!(
|
||||
store.claim_ticket("T-1", Some("ticket-one"), "ticket-one-intake", "intake"),
|
||||
Ok(TicketClaimResult::Claimed)
|
||||
));
|
||||
|
||||
let error = store
|
||||
.claim_ticket("T-1", Some("ticket-one"), "ticket-two-intake", "intake")
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, PanelRegistryError::TicketAlreadyClaimed(_)));
|
||||
let claim = store.claim_for_ticket("T-1").unwrap().unwrap();
|
||||
assert_eq!(claim.worker_name, "ticket-one-intake");
|
||||
assert_eq!(claim.ticket_slug.as_deref(), Some("ticket-one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intake_session_relation_is_not_one_to_one_with_tickets() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = PanelRegistryStore::from_root(temp.path().join("registry"));
|
||||
|
||||
store
|
||||
.record_session(
|
||||
"ticket-intake-preticket",
|
||||
"intake",
|
||||
RoleSessionOrigin::PreTicketIntake,
|
||||
None,
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.record_session(
|
||||
"ticket-intake-shared",
|
||||
"intake",
|
||||
RoleSessionOrigin::RoleLaunch,
|
||||
None,
|
||||
[
|
||||
RelatedTicketRef {
|
||||
id: "T-1".to_string(),
|
||||
slug: Some("one".to_string()),
|
||||
},
|
||||
RelatedTicketRef {
|
||||
id: "T-2".to_string(),
|
||||
slug: Some("two".to_string()),
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let snapshot = store.snapshot().unwrap();
|
||||
let preticket = snapshot
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.worker_name == "ticket-intake-preticket")
|
||||
.unwrap();
|
||||
let shared = snapshot
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.worker_name == "ticket-intake-shared")
|
||||
.unwrap();
|
||||
|
||||
assert!(preticket.related_tickets.is_empty());
|
||||
assert_eq!(shared.role, "intake");
|
||||
assert_eq!(shared.origin, RoleSessionOrigin::RoleLaunch);
|
||||
assert!(!shared.created_at.is_empty());
|
||||
assert!(!shared.updated_at.is_empty());
|
||||
assert_eq!(
|
||||
shared.related_tickets,
|
||||
vec![
|
||||
RelatedTicketRef {
|
||||
id: "T-1".to_string(),
|
||||
slug: Some("one".to_string()),
|
||||
},
|
||||
RelatedTicketRef {
|
||||
id: "T-2".to_string(),
|
||||
slug: Some("two".to_string()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user