Compare commits
81
Commits
master
..
374449e663
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
374449e663 | ||
|
|
0496cd907b | ||
|
|
060f280fdf | ||
|
|
7aaf189247 | ||
|
|
63306cf017 | ||
|
|
08be5e85e4 | ||
|
|
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 |
Generated
+523
-1
@@ -26,6 +26,16 @@ dependencies = [
|
|||||||
"pom",
|
"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]]
|
[[package]]
|
||||||
name = "aes"
|
name = "aes"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -37,6 +47,20 @@ dependencies = [
|
|||||||
"cpufeatures 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "agen"
|
name = "agen"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -326,6 +350,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -338,6 +368,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64urlsafedata"
|
name = "base64urlsafedata"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -349,6 +385,17 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -403,6 +450,16 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "bstr"
|
name = "bstr"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -435,6 +492,12 @@ version = "1.25.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -495,6 +558,17 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -654,6 +728,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -937,6 +1017,18 @@ version = "0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
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]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -966,6 +1058,41 @@ dependencies = [
|
|||||||
"phf 0.11.3",
|
"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]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@@ -1056,6 +1183,16 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
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]]
|
[[package]]
|
||||||
name = "der-parser"
|
name = "der-parser"
|
||||||
version = "9.0.0"
|
version = "9.0.0"
|
||||||
@@ -1114,7 +1251,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
|
"const-oid 0.9.6",
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.7",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1124,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.0",
|
"block-buffer 0.12.0",
|
||||||
"const-oid",
|
"const-oid 0.10.2",
|
||||||
"crypto-common 0.2.1",
|
"crypto-common 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1175,12 +1314,66 @@ dependencies = [
|
|||||||
"cipher",
|
"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]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
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]]
|
[[package]]
|
||||||
name = "embedded-io"
|
name = "embedded-io"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -1284,6 +1477,22 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
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]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -1526,6 +1735,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1568,6 +1778,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"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]]
|
[[package]]
|
||||||
name = "gimli"
|
name = "gimli"
|
||||||
version = "0.33.0"
|
version = "0.33.0"
|
||||||
@@ -1636,6 +1856,17 @@ dependencies = [
|
|||||||
"memmap2",
|
"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]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -1724,6 +1955,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
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]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.26.0"
|
version = "0.26.0"
|
||||||
@@ -2212,6 +2452,9 @@ name = "lazy_static"
|
|||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
dependencies = [
|
||||||
|
"spin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leb128fmt"
|
name = "leb128fmt"
|
||||||
@@ -2672,6 +2915,22 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2698,6 +2957,16 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2705,6 +2974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2765,6 +3035,12 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.76"
|
version = "0.10.76"
|
||||||
@@ -2818,6 +3094,44 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -2847,6 +3161,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
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]]
|
[[package]]
|
||||||
name = "pdf-extract"
|
name = "pdf-extract"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -2864,6 +3187,15 @@ dependencies = [
|
|||||||
"unicode-normalization",
|
"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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -3009,6 +3341,27 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
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]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -3021,6 +3374,29 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
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]]
|
[[package]]
|
||||||
name = "pom"
|
name = "pom"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -3101,6 +3477,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -3525,6 +3910,16 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3539,6 +3934,27 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
@@ -3745,6 +4161,20 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "secrets"
|
name = "secrets"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4060,6 +4490,16 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -4103,6 +4543,72 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -4934,6 +5440,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "unsafe-libyaml"
|
name = "unsafe-libyaml"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
@@ -6131,9 +6647,12 @@ dependencies = [
|
|||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"toml",
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"workdir",
|
"workdir",
|
||||||
"worker",
|
"worker",
|
||||||
|
"workspace-api",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6257,11 +6776,13 @@ dependencies = [
|
|||||||
"project-record",
|
"project-record",
|
||||||
"protocol",
|
"protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"ring",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
"ssh-key",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"ticket",
|
"ticket",
|
||||||
@@ -6278,6 +6799,7 @@ dependencies = [
|
|||||||
"worker",
|
"worker",
|
||||||
"worker-runtime",
|
"worker-runtime",
|
||||||
"workspace-api",
|
"workspace-api",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ tar = "0.4"
|
|||||||
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tokio = "1.52"
|
tokio = "1.52"
|
||||||
@@ -124,4 +125,5 @@ toml = "1.1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
uuid = "1.23"
|
uuid = "1.23"
|
||||||
|
zeroize = "1"
|
||||||
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
|||||||
- "8787"
|
- "8787"
|
||||||
volumes:
|
volumes:
|
||||||
- server-data:/server-data
|
- server-data:/server-data
|
||||||
- ./docker/workspace:/workspace:ro
|
- /etc/yoi/server.toml:/server-config/server.toml:ro
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: yoi-webui:latest
|
image: yoi-webui:latest
|
||||||
|
|||||||
@@ -21,20 +21,21 @@ agen = { version = "0.2.1", features = ["codex"] }
|
|||||||
|
|
||||||
## Quick start
|
## 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
|
```no_run
|
||||||
use agen::{Engine, EngineError};
|
use agen::{Engine, EngineError, History};
|
||||||
use agen::llm_client::LlmClient;
|
use agen::llm_client::LlmClient;
|
||||||
|
|
||||||
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
|
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
|
||||||
|
let mut history = History::new();
|
||||||
let output = Engine::new(client)
|
let output = Engine::new(client)
|
||||||
.system_prompt("You are a concise assistant.")
|
.system_prompt("You are a concise assistant.")
|
||||||
.run("Explain typed state in one sentence.")
|
.run(&mut history, "Explain typed state in one sentence.")
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut engine = output.engine;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||||
use agen::{Engine, EngineResult};
|
use agen::{Engine, EngineResult, History};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[tokio::main]
|
#[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 base_url = scheme.default_base_url().to_string();
|
||||||
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
|
let mut history = History::new();
|
||||||
|
|
||||||
println!("🚀 Starting Engine...");
|
println!("🚀 Starting Engine...");
|
||||||
println!("💡 Will cancel after 2 seconds\n");
|
println!("💡 Will cancel after 2 seconds\n");
|
||||||
@@ -45,7 +46,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
println!("📡 Sending request to LLM...");
|
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 {
|
match engine.run(&mut history, "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 {
|
Ok(out) => match out.result {
|
||||||
EngineResult::Finished => println!("✅ Task completed normally"),
|
EngineResult::Finished => println!("✅ Task completed normally"),
|
||||||
EngineResult::Paused => println!("⏸️ Task paused"),
|
EngineResult::Paused => println!("⏸️ Task paused"),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ use tracing::info;
|
|||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use agen::{
|
use agen::{
|
||||||
Engine,
|
Engine, History,
|
||||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||||
llm_client::{
|
llm_client::{
|
||||||
LlmClient,
|
LlmClient,
|
||||||
@@ -474,9 +474,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
engine.set_interceptor(ToolResultPrinterPolicy::new(tool_call_names));
|
engine.set_interceptor(ToolResultPrinterPolicy::new(tool_call_names));
|
||||||
|
|
||||||
|
let mut history = History::new();
|
||||||
|
|
||||||
// One-shot mode
|
// One-shot mode
|
||||||
if let Some(prompt) = args.prompt {
|
if let Some(prompt) = args.prompt {
|
||||||
match engine.run(&prompt).await {
|
match engine.run(&mut history, &prompt).await {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("\n❌ Error: {}", e);
|
eprintln!("\n❌ Error: {}", e);
|
||||||
@@ -500,7 +502,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut locked = match engine.run(first_input).await {
|
let mut locked = match engine.run(&mut history, first_input).await {
|
||||||
Ok(out) => out.engine,
|
Ok(out) => out.engine,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("\n❌ Error: {}", e);
|
eprintln!("\n❌ Error: {}", e);
|
||||||
@@ -525,7 +527,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
match locked.run(input).await {
|
match locked.run(&mut history, input).await {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("\n❌ Error: {}", e);
|
eprintln!("\n❌ Error: {}", e);
|
||||||
|
|||||||
+249
-103
@@ -7,7 +7,7 @@ use tokio::sync::mpsc;
|
|||||||
use tracing::{debug, info, trace, warn};
|
use tracing::{debug, info, trace, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Item,
|
History, HistoryEntry, Item,
|
||||||
callback::{
|
callback::{
|
||||||
ClosureMetaHandler, ClosureTextBlockHandler, ClosureThinkingBlockHandler,
|
ClosureMetaHandler, ClosureTextBlockHandler, ClosureThinkingBlockHandler,
|
||||||
ClosureToolUseBlockHandler, TextBlockScope, ThinkingBlockScope, ToolUseBlockScope,
|
ClosureToolUseBlockHandler, TextBlockScope, ThinkingBlockScope, ToolUseBlockScope,
|
||||||
@@ -91,9 +91,9 @@ pub enum EngineResult {
|
|||||||
/// Result of [`Engine::run`] or [`Engine::resume`].
|
/// Result of [`Engine::run`] or [`Engine::resume`].
|
||||||
///
|
///
|
||||||
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
|
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
|
||||||
pub struct EngineRunOutput<C: LlmClient> {
|
pub struct EngineRunOutput<C: LlmClient, A = ()> {
|
||||||
/// The Engine, now in Locked state.
|
/// The Engine, now in Locked state.
|
||||||
pub engine: Engine<C, Locked>,
|
pub engine: Engine<C, Locked, A>,
|
||||||
/// Outcome of the turn.
|
/// Outcome of the turn.
|
||||||
pub result: EngineResult,
|
pub result: EngineResult,
|
||||||
}
|
}
|
||||||
@@ -113,29 +113,31 @@ const MAX_STREAM_CONTINUATIONS: u32 = 3;
|
|||||||
///
|
///
|
||||||
/// # State Transitions (Type-state)
|
/// # State Transitions (Type-state)
|
||||||
///
|
///
|
||||||
/// - [`Mutable`]: Initial state. System prompt, history, and tools can be freely edited.
|
/// - [`Mutable`]: Initial state. System prompt and tools can be edited; history is caller-owned.
|
||||||
/// - [`Locked`]: Cache-protected state. Prefix context is immutable; only `run()` / `resume()` are available.
|
/// - [`Locked`]: Cache-protected state. Prefix context is immutable; only `run()` / `resume()` are available.
|
||||||
///
|
///
|
||||||
/// Calling `run()` on a `Mutable` Engine consumes it and returns a
|
/// Calling `run()` on a `Mutable` Engine consumes it and returns a
|
||||||
/// `Locked` Engine together with the result. This ensures the
|
/// `Locked` Engine together with the result. The engine borrows the caller's
|
||||||
/// cache prefix is fixed for optimal KV cache hit rate.
|
/// [`History`](crate::History) only while running, so host annotations stay with
|
||||||
|
/// the host-owned history and are never projected to providers.
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
|
/// let mut history = History::new();
|
||||||
/// let mut engine = Engine::new(client)
|
/// let mut engine = Engine::new(client)
|
||||||
/// .system_prompt("You are a helpful assistant.");
|
/// .system_prompt("You are a helpful assistant.");
|
||||||
/// engine.register_tool(my_tool);
|
/// engine.register_tool(my_tool);
|
||||||
///
|
///
|
||||||
/// // Mutable::run() consumes self → EngineRunOutput { engine: Locked, result }
|
/// // Mutable::run() consumes self → EngineRunOutput { engine: Locked, result }
|
||||||
/// let out = engine.run("Hello").await?;
|
/// let out = engine.run(&mut history, "Hello").await?;
|
||||||
/// let mut engine = out.engine;
|
/// let mut engine = out.engine;
|
||||||
///
|
///
|
||||||
/// // Locked::run() borrows &mut self
|
/// // Locked::run() borrows &mut self
|
||||||
/// engine.run("Follow-up").await?;
|
/// engine.run(&mut history, "Follow-up").await?;
|
||||||
///
|
///
|
||||||
/// // To edit between turns, unlock back to Mutable
|
/// // To edit between turns, unlock back to Mutable
|
||||||
/// let mut engine = engine.unlock();
|
/// let mut engine = engine.unlock();
|
||||||
/// engine.truncate_history(5);
|
/// history.truncate(5);
|
||||||
/// let out = engine.run("Continue").await?;
|
/// let out = engine.run(&mut history, "Continue").await?;
|
||||||
/// let mut engine = out.engine;
|
/// let mut engine = out.engine;
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -155,7 +157,7 @@ enum StreamCompletion {
|
|||||||
Interrupted { reason: String },
|
Interrupted { reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
|
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
||||||
/// LLM client
|
/// LLM client
|
||||||
client: C,
|
client: C,
|
||||||
/// Retry policy for opening an LLM response stream.
|
/// Retry policy for opening an LLM response stream.
|
||||||
@@ -175,18 +177,22 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
|
|||||||
interceptor: Box<dyn Interceptor>,
|
interceptor: Box<dyn Interceptor>,
|
||||||
/// System prompt
|
/// System prompt
|
||||||
system_prompt: Option<String>,
|
system_prompt: Option<String>,
|
||||||
/// Item history (owned by Engine)
|
|
||||||
history: Vec<Item>,
|
|
||||||
/// History length at lock time (only meaningful in Locked state)
|
/// History length at lock time (only meaningful in Locked state)
|
||||||
locked_prefix_len: usize,
|
locked_prefix_len: usize,
|
||||||
/// AgentTurn count.
|
/// AgentTurn count across the lifetime of this Engine.
|
||||||
///
|
///
|
||||||
/// Once retry (`agen-stream-continuation`) is implemented, an
|
/// Once retry (`agen-stream-continuation`) is implemented, an
|
||||||
/// AgentTurn collapses N retried `LlmCall`s with identical input;
|
/// AgentTurn collapses N retried `LlmCall`s with identical input;
|
||||||
/// today retry is not implemented so AgentTurn and LlmCall fire 1:1
|
/// today retry is not implemented so AgentTurn and LlmCall fire 1:1
|
||||||
/// and the increment site (the LLM-call loop) is shared.
|
/// and the increment site (the LLM-call loop) is shared.
|
||||||
/// `max_turns` is interpreted as a per-`run()` AgentTurn cap.
|
|
||||||
turn_count: usize,
|
turn_count: usize,
|
||||||
|
/// AgentTurns consumed by the currently active logical run.
|
||||||
|
///
|
||||||
|
/// A fresh [`run`](Self::run) starts at zero. Pause and Yield retain the
|
||||||
|
/// count for [`resume`](Self::resume), while terminal outcomes clear it.
|
||||||
|
/// `max_turns` is enforced against this run-scoped count rather than the
|
||||||
|
/// cumulative `turn_count` above.
|
||||||
|
active_run_turn_count: Option<usize>,
|
||||||
/// LlmCall count (per-Engine running counter, monotonic). Unlike
|
/// LlmCall count (per-Engine running counter, monotonic). Unlike
|
||||||
/// `turn_count` this never collapses retries.
|
/// `turn_count` this never collapses retries.
|
||||||
llm_call_count: usize,
|
llm_call_count: usize,
|
||||||
@@ -260,14 +266,28 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
|
|||||||
/// stable conversation identifier when the backend benefits from one.
|
/// stable conversation identifier when the backend benefits from one.
|
||||||
cache_key: Option<String>,
|
cache_key: Option<String>,
|
||||||
/// State marker
|
/// State marker
|
||||||
_state: PhantomData<S>,
|
_state: PhantomData<(S, A)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||||
fn reset_interruption_state(&mut self) {
|
fn reset_interruption_state(&mut self) {
|
||||||
self.last_run_interrupted = false;
|
self.last_run_interrupted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_logical_run(&mut self) {
|
||||||
|
self.active_run_turn_count = Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_logical_run(&mut self) {
|
||||||
|
self.active_run_turn_count.get_or_insert(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_logical_run(&mut self, result: &Result<EngineResult, EngineError>) {
|
||||||
|
if !matches!(result, Ok(EngineResult::Paused) | Ok(EngineResult::Yielded)) {
|
||||||
|
self.active_run_turn_count = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn drain_cancel_queue(&mut self) {
|
fn drain_cancel_queue(&mut self) {
|
||||||
while self.cancel_rx.try_recv().is_ok() {}
|
while self.cancel_rx.try_recv().is_ok() {}
|
||||||
}
|
}
|
||||||
@@ -519,11 +539,15 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
|
|
||||||
fn append_history_items(
|
fn append_history_items(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
history: &mut History<A>,
|
||||||
items: impl IntoIterator<Item = Item>,
|
items: impl IntoIterator<Item = Item>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
) -> Result<(), EngineError> {
|
) -> Result<(), EngineError> {
|
||||||
for item in items {
|
for item in items {
|
||||||
self.emit_history_append(&item)?;
|
self.emit_history_append(&item)?;
|
||||||
self.history.push(item);
|
history
|
||||||
|
.append_with(item, annotate)
|
||||||
|
.map_err(EngineError::HistoryAppend)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -630,9 +654,9 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
&self.client
|
&self.client
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the history
|
/// Borrow caller-owned annotated history entries.
|
||||||
pub fn history(&self) -> &[Item] {
|
pub fn history<'h>(&self, history: &'h History<A>) -> &'h [HistoryEntry<A>] {
|
||||||
&self.history
|
history.entries()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the system prompt
|
/// Get a reference to the system prompt
|
||||||
@@ -650,6 +674,23 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
self.turn_count
|
self.turn_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the AgentTurns consumed by an interrupted logical run.
|
||||||
|
///
|
||||||
|
/// `Some` is retained only while Pause or Yield permits a later
|
||||||
|
/// [`resume`](Self::resume). Terminal outcomes return this to `None`.
|
||||||
|
pub fn active_run_turn_count(&self) -> Option<usize> {
|
||||||
|
self.active_run_turn_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the persisted turn budget of an interrupted logical run.
|
||||||
|
///
|
||||||
|
/// Session owners restore this together with the cumulative turn count and
|
||||||
|
/// history. `None` means there is no resumable logical run and the next
|
||||||
|
/// [`resume`](Self::resume) starts a fresh budget.
|
||||||
|
pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) {
|
||||||
|
self.active_run_turn_count = turn_count;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the current LlmCall count (per-Engine running counter, never
|
/// Get the current LlmCall count (per-Engine running counter, never
|
||||||
/// collapsed by retry).
|
/// collapsed by retry).
|
||||||
pub fn llm_call_count(&self) -> usize {
|
pub fn llm_call_count(&self) -> usize {
|
||||||
@@ -878,20 +919,20 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check for pending tool calls (for resuming from Pause)
|
/// Check for pending tool calls (for resuming from Pause)
|
||||||
fn get_pending_tool_calls(&self) -> Option<Vec<ToolCall>> {
|
fn get_pending_tool_calls(&self, history: &History<A>) -> Option<Vec<ToolCall>> {
|
||||||
// Find the last ToolCall items that don't have corresponding ToolResult
|
// Find the last ToolCall items that don't have corresponding ToolResult
|
||||||
let mut pending_calls = Vec::new();
|
let mut pending_calls = Vec::new();
|
||||||
let mut answered_call_ids = std::collections::HashSet::new();
|
let mut answered_call_ids = std::collections::HashSet::new();
|
||||||
|
|
||||||
// First pass: collect all answered call IDs
|
// First pass: collect all answered call IDs
|
||||||
for item in &self.history {
|
for item in history.items() {
|
||||||
if let Item::ToolResult { call_id, .. } = item {
|
if let Item::ToolResult { call_id, .. } = item {
|
||||||
answered_call_ids.insert(call_id.clone());
|
answered_call_ids.insert(call_id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: find unanswered tool calls
|
// Second pass: find unanswered tool calls
|
||||||
for item in &self.history {
|
for item in history.items() {
|
||||||
if let Item::ToolCall {
|
if let Item::ToolCall {
|
||||||
call_id,
|
call_id,
|
||||||
name,
|
name,
|
||||||
@@ -1095,20 +1136,27 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Internal turn execution logic
|
/// Internal turn execution logic
|
||||||
async fn run_turn_loop(&mut self) -> Result<EngineResult, EngineError> {
|
async fn run_turn_loop(
|
||||||
|
&mut self,
|
||||||
|
history: &mut History<A>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
|
) -> Result<EngineResult, EngineError> {
|
||||||
self.reset_interruption_state();
|
self.reset_interruption_state();
|
||||||
let tool_definitions = self.build_tool_definitions();
|
let tool_definitions = self.build_tool_definitions();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
item_count = self.history.len(),
|
item_count = history.len(),
|
||||||
tool_count = tool_definitions.len(),
|
tool_count = tool_definitions.len(),
|
||||||
"Starting engine run"
|
"Starting engine run"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resume pending tool calls from a previous Pause
|
// Resume pending tool calls from a previous Pause
|
||||||
if let Some(tool_calls) = self.get_pending_tool_calls() {
|
if let Some(tool_calls) = self.get_pending_tool_calls(history) {
|
||||||
info!("Resuming pending tool calls");
|
info!("Resuming pending tool calls");
|
||||||
if let Some(result) = self.execute_and_commit_tools(tool_calls).await? {
|
if let Some(result) = self
|
||||||
|
.execute_and_commit_tools(history, annotate, tool_calls)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1123,6 +1171,19 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
return Err(EngineError::Cancelled);
|
return Err(EngineError::Cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(max) = self.max_turns
|
||||||
|
&& self.active_run_turn_count.unwrap_or(0) >= max as usize
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
active_run_turn_count = self.active_run_turn_count.unwrap_or(0),
|
||||||
|
total_turn_count = self.turn_count,
|
||||||
|
max_turns = max,
|
||||||
|
"Logical run turn limit reached"
|
||||||
|
);
|
||||||
|
self.last_run_interrupted = false;
|
||||||
|
return Ok(EngineResult::LimitReached);
|
||||||
|
}
|
||||||
|
|
||||||
let current_turn = self.turn_count;
|
let current_turn = self.turn_count;
|
||||||
if !continuing_stream {
|
if !continuing_stream {
|
||||||
debug!(turn = current_turn, "Turn start");
|
debug!(turn = current_turn, "Turn start");
|
||||||
@@ -1142,13 +1203,13 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
.await
|
.await
|
||||||
.map_err(EngineError::HistoryAppend)?;
|
.map_err(EngineError::HistoryAppend)?;
|
||||||
if !pending.is_empty() {
|
if !pending.is_empty() {
|
||||||
self.append_history_items(pending)?;
|
self.append_history_items(history, pending, annotate)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the history into a per-request context. Everything
|
// Clone the history into a per-request context. Everything
|
||||||
// below (prune projection, interceptor hooks) mutates only
|
// below (prune projection, interceptor hooks) mutates only
|
||||||
// this clone, so the persistent `self.history` stays intact.
|
// this clone, so the caller-owned `history` stays intact.
|
||||||
let mut request_context = self.history.clone();
|
let mut request_context = history.items_cloned();
|
||||||
|
|
||||||
// Prune projection: if both the config and the savings
|
// Prune projection: if both the config and the savings
|
||||||
// estimator are configured, drop ToolResult.content from
|
// estimator are configured, drop ToolResult.content from
|
||||||
@@ -1217,7 +1278,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
return Err(EngineError::Aborted(reason));
|
return Err(EngineError::Aborted(reason));
|
||||||
}
|
}
|
||||||
PreRequestAction::YieldWith(items) => {
|
PreRequestAction::YieldWith(items) => {
|
||||||
self.append_history_items(items.clone())?;
|
self.append_history_items(history, items.clone(), annotate)?;
|
||||||
request_context.extend(items);
|
request_context.extend(items);
|
||||||
info!("Yielded by interceptor after pre-request history append");
|
info!("Yielded by interceptor after pre-request history append");
|
||||||
for cb in &self.turn_end_cbs {
|
for cb in &self.turn_end_cbs {
|
||||||
@@ -1235,7 +1296,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
return Ok(EngineResult::Yielded);
|
return Ok(EngineResult::Yielded);
|
||||||
}
|
}
|
||||||
PreRequestAction::ContinueWith(items) => {
|
PreRequestAction::ContinueWith(items) => {
|
||||||
self.append_history_items(items.clone())?;
|
self.append_history_items(history, items.clone(), annotate)?;
|
||||||
request_context.extend(items);
|
request_context.extend(items);
|
||||||
}
|
}
|
||||||
PreRequestAction::Continue => {}
|
PreRequestAction::Continue => {}
|
||||||
@@ -1295,7 +1356,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
let assistant_items =
|
let assistant_items =
|
||||||
self.build_assistant_items(&reasoning_items, &text_blocks, &[]);
|
self.build_assistant_items(&reasoning_items, &text_blocks, &[]);
|
||||||
if !assistant_items.is_empty() {
|
if !assistant_items.is_empty() {
|
||||||
self.append_history_items(assistant_items)?;
|
self.append_history_items(history, assistant_items, annotate)?;
|
||||||
}
|
}
|
||||||
self.emit_llm_continuation(
|
self.emit_llm_continuation(
|
||||||
current_llm_call,
|
current_llm_call,
|
||||||
@@ -1314,6 +1375,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
cb(current_turn);
|
cb(current_turn);
|
||||||
}
|
}
|
||||||
self.turn_count += 1;
|
self.turn_count += 1;
|
||||||
|
*self.active_run_turn_count.get_or_insert(0) += 1;
|
||||||
|
|
||||||
// Collect and commit assistant items. Routed through
|
// Collect and commit assistant items. Routed through
|
||||||
// `append_history_items` so observers see each item as it lands.
|
// `append_history_items` so observers see each item as it lands.
|
||||||
@@ -1322,16 +1384,17 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
let tool_calls = self.tool_call_collector.take_collected();
|
let tool_calls = self.tool_call_collector.take_collected();
|
||||||
let assistant_items =
|
let assistant_items =
|
||||||
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
|
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
|
||||||
self.append_history_items(assistant_items)?;
|
self.append_history_items(history, assistant_items, annotate)?;
|
||||||
|
|
||||||
if tool_calls.is_empty() {
|
if tool_calls.is_empty() {
|
||||||
match self.interceptor.on_turn_end(&self.history).await {
|
let turn_end_context = history.items_cloned();
|
||||||
|
match self.interceptor.on_turn_end(&turn_end_context).await {
|
||||||
TurnEndAction::Finish => {
|
TurnEndAction::Finish => {
|
||||||
self.last_run_interrupted = false;
|
self.last_run_interrupted = false;
|
||||||
return Ok(EngineResult::Finished);
|
return Ok(EngineResult::Finished);
|
||||||
}
|
}
|
||||||
TurnEndAction::ContinueWithMessages(additional) => {
|
TurnEndAction::ContinueWithMessages(additional) => {
|
||||||
self.append_history_items(additional)?;
|
self.append_history_items(history, additional, annotate)?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
TurnEndAction::Pause => {
|
TurnEndAction::Pause => {
|
||||||
@@ -1341,21 +1404,12 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(result) = self.execute_and_commit_tools(tool_calls).await? {
|
if let Some(result) = self
|
||||||
|
.execute_and_commit_tools(history, annotate, tool_calls)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(max) = self.max_turns {
|
|
||||||
if self.turn_count >= max as usize {
|
|
||||||
info!(
|
|
||||||
turn_count = self.turn_count,
|
|
||||||
max_turns = max,
|
|
||||||
"Turn limit reached"
|
|
||||||
);
|
|
||||||
self.last_run_interrupted = false;
|
|
||||||
return Ok(EngineResult::LimitReached);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1607,6 +1661,8 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
/// `None` if the turn loop should continue.
|
/// `None` if the turn loop should continue.
|
||||||
async fn execute_and_commit_tools(
|
async fn execute_and_commit_tools(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
history: &mut History<A>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
tool_calls: Vec<ToolCall>,
|
tool_calls: Vec<ToolCall>,
|
||||||
) -> Result<Option<EngineResult>, EngineError> {
|
) -> Result<Option<EngineResult>, EngineError> {
|
||||||
match self.execute_tools(tool_calls).await {
|
match self.execute_tools(tool_calls).await {
|
||||||
@@ -1626,7 +1682,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
result.attachments,
|
result.attachments,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
self.append_history_items(items)?;
|
self.append_history_items(history, items, annotate)?;
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -1637,9 +1693,9 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: LlmClient> Engine<C, Mutable> {
|
impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
||||||
/// Create a new Engine (in Mutable state)
|
/// Create a new annotated Engine (in Mutable state).
|
||||||
pub fn new(client: C) -> Self {
|
pub fn new_annotated(client: C) -> Self {
|
||||||
let text_block_collector = TextBlockCollector::new();
|
let text_block_collector = TextBlockCollector::new();
|
||||||
let tool_call_collector = ToolCallCollector::new();
|
let tool_call_collector = ToolCallCollector::new();
|
||||||
let thinking_block_collector = ThinkingBlockCollector::new();
|
let thinking_block_collector = ThinkingBlockCollector::new();
|
||||||
@@ -1661,9 +1717,9 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
|||||||
tool_server: ToolServer::new().handle(),
|
tool_server: ToolServer::new().handle(),
|
||||||
interceptor: Box::new(DefaultInterceptor),
|
interceptor: Box::new(DefaultInterceptor),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
history: Vec::new(),
|
|
||||||
locked_prefix_len: 0,
|
locked_prefix_len: 0,
|
||||||
turn_count: 0,
|
turn_count: 0,
|
||||||
|
active_run_turn_count: None,
|
||||||
llm_call_count: 0,
|
llm_call_count: 0,
|
||||||
tool_execution_batch_count: 0,
|
tool_execution_batch_count: 0,
|
||||||
max_turns: None,
|
max_turns: None,
|
||||||
@@ -1821,36 +1877,38 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace history during restore/rebuild without emitting append callbacks.
|
/// Replace caller-owned history during restore/rebuild without emitting append callbacks.
|
||||||
///
|
///
|
||||||
/// This is not a history-growth API. Live append paths must use
|
/// This is not a history-growth API. Live append paths must use
|
||||||
/// [`append_history`](Self::append_history) so `on_history_append` observers
|
/// [`append_history_with`](Self::append_history_with) so observers and the
|
||||||
/// see every inserted item.
|
/// trusted annotation callback see every inserted item.
|
||||||
pub fn set_history(&mut self, items: Vec<Item>) {
|
pub fn replace_history_entries(
|
||||||
self.history = items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append items to history after every history-append observer accepts the
|
|
||||||
/// item. This is the only public Mutable-state API for growing engine
|
|
||||||
/// history; callers that need session-log persistence must install
|
|
||||||
/// [`on_history_append`](Self::on_history_append) before calling it.
|
|
||||||
pub fn append_history(
|
|
||||||
&mut self,
|
&mut self,
|
||||||
|
history: &mut History<A>,
|
||||||
|
entries: Vec<HistoryEntry<A>>,
|
||||||
|
) -> Vec<HistoryEntry<A>> {
|
||||||
|
history.replace_entries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append items to caller-owned history after every observer and the trusted
|
||||||
|
/// annotation callback accepts the item.
|
||||||
|
pub fn append_history_with(
|
||||||
|
&mut self,
|
||||||
|
history: &mut History<A>,
|
||||||
items: impl IntoIterator<Item = Item>,
|
items: impl IntoIterator<Item = Item>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
) -> Result<(), EngineError> {
|
) -> Result<(), EngineError> {
|
||||||
self.append_history_items(items)
|
self.append_history_items(history, items, annotate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Truncate history without emitting append callbacks.
|
/// Truncate caller-owned history without emitting append callbacks.
|
||||||
///
|
pub fn truncate_history(&mut self, history: &mut History<A>, len: usize) {
|
||||||
/// This is an edit operation, not a history-growth path.
|
history.truncate(len);
|
||||||
pub fn truncate_history(&mut self, len: usize) {
|
|
||||||
self.history.truncate(len);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear history
|
/// Clear caller-owned history.
|
||||||
pub fn clear_history(&mut self) {
|
pub fn clear_history(&mut self, history: &mut History<A>) {
|
||||||
self.history.clear();
|
history.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the turn count (for session restoration)
|
/// Set the turn count (for session restoration)
|
||||||
@@ -1866,6 +1924,9 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
|||||||
/// Set the last_run_interrupted flag (for session restoration)
|
/// Set the last_run_interrupted flag (for session restoration)
|
||||||
pub fn set_last_run_interrupted(&mut self, interrupted: bool) {
|
pub fn set_last_run_interrupted(&mut self, interrupted: bool) {
|
||||||
self.last_run_interrupted = interrupted;
|
self.last_run_interrupted = interrupted;
|
||||||
|
if !interrupted {
|
||||||
|
self.active_run_turn_count = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply configuration (reserved for future extensions)
|
/// Apply configuration (reserved for future extensions)
|
||||||
@@ -1874,19 +1935,21 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a turn, consuming self and transitioning to Locked.
|
/// Run the engine with one user input, appending to caller-owned history.
|
||||||
///
|
///
|
||||||
/// This is the primary entry point for first use. Equivalent to
|
/// The trusted `annotate` callback is invoked after append observers and before
|
||||||
/// `self.lock()` followed by `locked.run(user_input)`.
|
/// each new item becomes live in `history`. Providers, token counters, pruners,
|
||||||
///
|
/// and interceptors receive only the `Item` projection.
|
||||||
/// Subsequent runs can call [`Engine::run`] directly.
|
pub async fn run_with_annotation(
|
||||||
/// To edit state between turns, call [`unlock()`](Engine::unlock) first.
|
|
||||||
pub async fn run(
|
|
||||||
self,
|
self,
|
||||||
|
history: &mut History<A>,
|
||||||
user_input: impl Into<String>,
|
user_input: impl Into<String>,
|
||||||
) -> Result<EngineRunOutput<C>, EngineError> {
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
let mut locked = self.lock();
|
) -> Result<EngineRunOutput<C, A>, EngineError> {
|
||||||
let result = locked.run(user_input).await?;
|
let mut locked = self.lock(history);
|
||||||
|
let result = locked
|
||||||
|
.run_with_annotation(history, user_input, annotate)
|
||||||
|
.await?;
|
||||||
Ok(EngineRunOutput {
|
Ok(EngineRunOutput {
|
||||||
engine: locked,
|
engine: locked,
|
||||||
result,
|
result,
|
||||||
@@ -1896,9 +1959,13 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
|||||||
/// Resume from Paused, consuming self and transitioning to Locked.
|
/// Resume from Paused, consuming self and transitioning to Locked.
|
||||||
///
|
///
|
||||||
/// Used after `unlock()` → edit → resume.
|
/// Used after `unlock()` → edit → resume.
|
||||||
pub async fn resume(self) -> Result<EngineRunOutput<C>, EngineError> {
|
pub async fn resume_with_annotation(
|
||||||
let mut locked = self.lock();
|
self,
|
||||||
let result = locked.resume().await?;
|
history: &mut History<A>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
|
) -> Result<EngineRunOutput<C, A>, EngineError> {
|
||||||
|
let mut locked = self.lock(history);
|
||||||
|
let result = locked.resume_with_annotation(history, annotate).await?;
|
||||||
Ok(EngineRunOutput {
|
Ok(EngineRunOutput {
|
||||||
engine: locked,
|
engine: locked,
|
||||||
result,
|
result,
|
||||||
@@ -1918,9 +1985,9 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
|||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Panics if a pending tool factory produces a duplicate name.
|
/// Panics if a pending tool factory produces a duplicate name.
|
||||||
pub fn lock(self) -> Engine<C, Locked> {
|
pub fn lock(self, history: &History<A>) -> Engine<C, Locked, A> {
|
||||||
self.tool_server.flush_pending();
|
self.tool_server.flush_pending();
|
||||||
let locked_prefix_len = self.history.len();
|
let locked_prefix_len = history.len();
|
||||||
Engine {
|
Engine {
|
||||||
client: self.client,
|
client: self.client,
|
||||||
retry_policy: self.retry_policy,
|
retry_policy: self.retry_policy,
|
||||||
@@ -1931,9 +1998,9 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
|||||||
tool_server: self.tool_server,
|
tool_server: self.tool_server,
|
||||||
interceptor: self.interceptor,
|
interceptor: self.interceptor,
|
||||||
system_prompt: self.system_prompt,
|
system_prompt: self.system_prompt,
|
||||||
history: self.history,
|
|
||||||
locked_prefix_len,
|
locked_prefix_len,
|
||||||
turn_count: self.turn_count,
|
turn_count: self.turn_count,
|
||||||
|
active_run_turn_count: self.active_run_turn_count,
|
||||||
llm_call_count: self.llm_call_count,
|
llm_call_count: self.llm_call_count,
|
||||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||||
max_turns: self.max_turns,
|
max_turns: self.max_turns,
|
||||||
@@ -1965,15 +2032,65 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: LlmClient> Engine<C, Locked> {
|
fn unit_history_annotation(_: &Item) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: LlmClient> Engine<C, Mutable, ()> {
|
||||||
|
/// Create a new Engine (in Mutable state) using unit history annotations.
|
||||||
|
pub fn new(client: C) -> Self {
|
||||||
|
Self::new_annotated(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append unit-annotated items to caller-owned history.
|
||||||
|
pub fn append_history(
|
||||||
|
&mut self,
|
||||||
|
history: &mut History<()>,
|
||||||
|
items: impl IntoIterator<Item = Item>,
|
||||||
|
) -> Result<(), EngineError> {
|
||||||
|
let mut annotate = unit_history_annotation;
|
||||||
|
self.append_history_items(history, items, &mut annotate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace unit-annotated history from plain items.
|
||||||
|
pub fn set_history(&mut self, history: &mut History<()>, items: Vec<Item>) {
|
||||||
|
history.replace_items(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run using unit annotations.
|
||||||
|
pub async fn run(
|
||||||
|
self,
|
||||||
|
history: &mut History<()>,
|
||||||
|
user_input: impl Into<String>,
|
||||||
|
) -> Result<EngineRunOutput<C>, EngineError> {
|
||||||
|
let mut annotate = unit_history_annotation;
|
||||||
|
self.run_with_annotation(history, user_input, &mut annotate)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume using unit annotations.
|
||||||
|
pub async fn resume(
|
||||||
|
self,
|
||||||
|
history: &mut History<()>,
|
||||||
|
) -> Result<EngineRunOutput<C>, EngineError> {
|
||||||
|
let mut annotate = unit_history_annotation;
|
||||||
|
self.resume_with_annotation(history, &mut annotate).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||||
/// Execute a turn
|
/// Execute a turn
|
||||||
///
|
///
|
||||||
/// Adds a new user message to history and sends a request to the LLM.
|
/// Adds a new user message to history and sends a request to the LLM.
|
||||||
/// Automatically loops if there are tool calls.
|
/// Automatically loops if there are tool calls.
|
||||||
pub async fn run(
|
pub async fn run_with_annotation(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
history: &mut History<A>,
|
||||||
user_input: impl Into<String>,
|
user_input: impl Into<String>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
) -> Result<EngineResult, EngineError> {
|
) -> Result<EngineResult, EngineError> {
|
||||||
|
// Supplying new user input abandons any paused/yielded logical run.
|
||||||
|
self.active_run_turn_count = None;
|
||||||
self.reset_interruption_state();
|
self.reset_interruption_state();
|
||||||
// Interceptor: on_prompt_submit
|
// Interceptor: on_prompt_submit
|
||||||
let mut user_item = Item::user_message(user_input);
|
let mut user_item = Item::user_message(user_input);
|
||||||
@@ -1987,21 +2104,31 @@ impl<C: LlmClient> Engine<C, Locked> {
|
|||||||
PromptAction::Continue => Vec::new(),
|
PromptAction::Continue => Vec::new(),
|
||||||
PromptAction::ContinueWith(items) => items,
|
PromptAction::ContinueWith(items) => items,
|
||||||
};
|
};
|
||||||
self.append_history_items(std::iter::once(user_item))?;
|
self.append_history_items(history, std::iter::once(user_item), annotate)?;
|
||||||
if !extras.is_empty() {
|
if !extras.is_empty() {
|
||||||
self.append_history_items(extras)?;
|
self.append_history_items(history, extras, annotate)?;
|
||||||
}
|
}
|
||||||
let result = self.run_turn_loop().await;
|
self.start_logical_run();
|
||||||
self.finalize_interruption(result).await
|
let result = self.run_turn_loop(history, annotate).await;
|
||||||
|
let result = self.finalize_interruption(result).await;
|
||||||
|
self.finish_logical_run(&result);
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume execution (from Paused state)
|
/// Resume execution (from Paused state)
|
||||||
///
|
///
|
||||||
/// Resumes turn processing from current state without adding a new user message.
|
/// Resumes turn processing from current state without adding a new user message.
|
||||||
pub async fn resume(&mut self) -> Result<EngineResult, EngineError> {
|
pub async fn resume_with_annotation(
|
||||||
|
&mut self,
|
||||||
|
history: &mut History<A>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
|
) -> Result<EngineResult, EngineError> {
|
||||||
self.reset_interruption_state();
|
self.reset_interruption_state();
|
||||||
let result = self.run_turn_loop().await;
|
self.ensure_logical_run();
|
||||||
self.finalize_interruption(result).await
|
let result = self.run_turn_loop(history, annotate).await;
|
||||||
|
let result = self.finalize_interruption(result).await;
|
||||||
|
self.finish_logical_run(&result);
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the prefix length at lock time
|
/// Get the prefix length at lock time
|
||||||
@@ -2013,7 +2140,7 @@ impl<C: LlmClient> Engine<C, Locked> {
|
|||||||
///
|
///
|
||||||
/// Note: After this operation, subsequent requests may not hit the cache.
|
/// Note: After this operation, subsequent requests may not hit the cache.
|
||||||
/// Use only when you need to edit history.
|
/// Use only when you need to edit history.
|
||||||
pub fn unlock(self) -> Engine<C, Mutable> {
|
pub fn unlock(self) -> Engine<C, Mutable, A> {
|
||||||
Engine {
|
Engine {
|
||||||
client: self.client,
|
client: self.client,
|
||||||
retry_policy: self.retry_policy,
|
retry_policy: self.retry_policy,
|
||||||
@@ -2024,9 +2151,9 @@ impl<C: LlmClient> Engine<C, Locked> {
|
|||||||
tool_server: self.tool_server,
|
tool_server: self.tool_server,
|
||||||
interceptor: self.interceptor,
|
interceptor: self.interceptor,
|
||||||
system_prompt: self.system_prompt,
|
system_prompt: self.system_prompt,
|
||||||
history: self.history,
|
|
||||||
locked_prefix_len: 0,
|
locked_prefix_len: 0,
|
||||||
turn_count: self.turn_count,
|
turn_count: self.turn_count,
|
||||||
|
active_run_turn_count: self.active_run_turn_count,
|
||||||
llm_call_count: self.llm_call_count,
|
llm_call_count: self.llm_call_count,
|
||||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||||
max_turns: self.max_turns,
|
max_turns: self.max_turns,
|
||||||
@@ -2058,6 +2185,25 @@ impl<C: LlmClient> Engine<C, Locked> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<C: LlmClient> Engine<C, Locked, ()> {
|
||||||
|
/// Run another turn using unit annotations.
|
||||||
|
pub async fn run(
|
||||||
|
&mut self,
|
||||||
|
history: &mut History<()>,
|
||||||
|
user_input: impl Into<String>,
|
||||||
|
) -> Result<EngineResult, EngineError> {
|
||||||
|
let mut annotate = unit_history_annotation;
|
||||||
|
self.run_with_annotation(history, user_input, &mut annotate)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume using unit annotations.
|
||||||
|
pub async fn resume(&mut self, history: &mut History<()>) -> Result<EngineResult, EngineError> {
|
||||||
|
let mut annotate = unit_history_annotation;
|
||||||
|
self.resume_with_annotation(history, &mut annotate).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum FirstStreamEvent {
|
enum FirstStreamEvent {
|
||||||
Ready(ResponseStream),
|
Ready(ResponseStream),
|
||||||
Empty(ResponseStream),
|
Empty(ResponseStream),
|
||||||
|
|||||||
@@ -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 engine;
|
||||||
mod handler;
|
mod handler;
|
||||||
|
mod history;
|
||||||
mod message;
|
mod message;
|
||||||
|
|
||||||
pub(crate) mod callback;
|
pub(crate) mod callback;
|
||||||
@@ -24,6 +25,7 @@ pub use engine::{
|
|||||||
ToolRegistryError,
|
ToolRegistryError,
|
||||||
};
|
};
|
||||||
pub use handler::ToolUseBlockStart;
|
pub use handler::ToolUseBlockStart;
|
||||||
|
pub use history::{History, HistoryEntry};
|
||||||
pub use interceptor::Interceptor;
|
pub use interceptor::Interceptor;
|
||||||
pub use message::{ContentPart, Item, Message, Role};
|
pub use message::{ContentPart, Item, Message, Role};
|
||||||
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
|
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ mod private {
|
|||||||
/// - Editing message history (add, delete, clear)
|
/// - Editing message history (add, delete, clear)
|
||||||
/// - Registering tools and hooks
|
/// - Registering tools and hooks
|
||||||
///
|
///
|
||||||
/// Can transition to [`Locked`] state via `Engine::lock()`.
|
/// Can transition to [`Locked`] state via `Engine::lock(&history)`.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(output.result, agen::EngineResult::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");
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use agen::Engine;
|
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
||||||
use agen::llm_client::retry::RetryPolicy;
|
use agen::llm_client::retry::RetryPolicy;
|
||||||
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
|
use agen::{Engine, History};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ async fn test_callback_llm_retry_event() {
|
|||||||
max_attempts: 2,
|
max_attempts: 2,
|
||||||
total_timeout: Duration::from_secs(1),
|
total_timeout: Duration::from_secs(1),
|
||||||
});
|
});
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
let notices = Arc::new(Mutex::new(Vec::new()));
|
let notices = Arc::new(Mutex::new(Vec::new()));
|
||||||
let sink = notices.clone();
|
let sink = notices.clone();
|
||||||
@@ -65,7 +66,7 @@ async fn test_callback_llm_retry_event() {
|
|||||||
sink.lock().unwrap().push((llm_call, notice.clone()));
|
sink.lock().unwrap().push((llm_call, notice.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = engine.run("retry once").await;
|
let result = engine.run(&mut history, "retry once").await;
|
||||||
assert!(result.is_ok(), "engine should succeed after one retry");
|
assert!(result.is_ok(), "engine should succeed after one retry");
|
||||||
|
|
||||||
let notices = notices.lock().unwrap();
|
let notices = notices.lock().unwrap();
|
||||||
@@ -91,6 +92,7 @@ async fn test_callback_text_block_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
||||||
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -109,7 +111,7 @@ async fn test_callback_text_block_events() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let result = engine.run("Greet me").await;
|
let result = engine.run(&mut history, "Greet me").await;
|
||||||
assert!(result.is_ok(), "Engine should complete");
|
assert!(result.is_ok(), "Engine should complete");
|
||||||
|
|
||||||
let deltas = text_deltas.lock().unwrap();
|
let deltas = text_deltas.lock().unwrap();
|
||||||
@@ -137,6 +139,7 @@ async fn test_callback_tool_call_complete() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
||||||
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -155,7 +158,7 @@ async fn test_callback_tool_call_complete() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let _ = engine.run("Weather please").await;
|
let _ = engine.run(&mut history, "Weather please").await;
|
||||||
|
|
||||||
let starts = tool_starts.lock().unwrap();
|
let starts = tool_starts.lock().unwrap();
|
||||||
assert_eq!(starts.len(), 1);
|
assert_eq!(starts.len(), 1);
|
||||||
@@ -183,6 +186,7 @@ async fn test_callback_turn_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
||||||
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -198,7 +202,7 @@ async fn test_callback_turn_events() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let result = engine.run("Do something").await;
|
let result = engine.run(&mut history, "Do something").await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let starts = turn_starts.lock().unwrap();
|
let starts = turn_starts.lock().unwrap();
|
||||||
@@ -254,6 +258,7 @@ async fn test_callback_tool_result_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
engine.register_tool(fixed_tool(
|
engine.register_tool(fixed_tool(
|
||||||
"fixed",
|
"fixed",
|
||||||
@@ -276,7 +281,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();
|
let observed = captured.lock().unwrap();
|
||||||
assert_eq!(observed.len(), 1);
|
assert_eq!(observed.len(), 1);
|
||||||
@@ -330,6 +335,7 @@ async fn test_callback_tool_result_error_path() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
engine.register_tool(erroring_tool("erroring", "boom"));
|
engine.register_tool(erroring_tool("erroring", "boom"));
|
||||||
|
|
||||||
@@ -345,7 +351,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();
|
let observed = captured.lock().unwrap();
|
||||||
assert_eq!(observed.len(), 1);
|
assert_eq!(observed.len(), 1);
|
||||||
@@ -374,6 +380,7 @@ async fn test_callback_usage_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
@@ -383,7 +390,7 @@ async fn test_callback_usage_events() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let _ = engine.run("Hello").await;
|
let _ = engine.run(&mut history, "Hello").await;
|
||||||
|
|
||||||
let usages = usage_events.lock().unwrap();
|
let usages = usage_events.lock().unwrap();
|
||||||
assert_eq!(usages.len(), 1);
|
assert_eq!(usages.len(), 1);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use agen::Engine;
|
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
|
use agen::{Engine, History};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
|
|
||||||
@@ -134,9 +134,10 @@ async fn test_engine_simple_text_response() {
|
|||||||
|
|
||||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
// Send a simple message (Mutable::run consumes self, returns tuple)
|
// 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!(result.is_ok(), "Engine should complete successfully");
|
||||||
}
|
}
|
||||||
@@ -156,6 +157,7 @@ async fn test_engine_tool_call() {
|
|||||||
|
|
||||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
// Register tool
|
// Register tool
|
||||||
let weather_tool = MockWeatherTool::new();
|
let weather_tool = MockWeatherTool::new();
|
||||||
@@ -163,7 +165,9 @@ async fn test_engine_tool_call() {
|
|||||||
engine.register_tool(weather_tool.definition());
|
engine.register_tool(weather_tool.definition());
|
||||||
|
|
||||||
// Send message (Mutable::run consumes self, returns tuple)
|
// 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
|
// Verify tool was called
|
||||||
// Note: max_turns=1 so no request is sent after tool result
|
// Note: max_turns=1 so no request is sent after tool result
|
||||||
@@ -195,9 +199,10 @@ async fn test_engine_with_programmatic_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
// Mutable::run consumes self, returns tuple
|
// 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!(result.is_ok(), "Engine should complete successfully");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use agen::Item;
|
use agen::Item;
|
||||||
|
use agen::interceptor::{
|
||||||
|
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
|
||||||
|
};
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use agen::{Engine, EngineError};
|
use agen::{Engine, EngineError, EngineResult, History};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
|
|
||||||
@@ -39,36 +42,37 @@ fn test_mutable_set_system_prompt() {
|
|||||||
fn test_mutable_history_manipulation() {
|
fn test_mutable_history_manipulation() {
|
||||||
let client = MockLlmClient::new(vec![]);
|
let client = MockLlmClient::new(vec![]);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
// Initial state is empty
|
// Initial state is empty
|
||||||
assert!(engine.history().is_empty());
|
assert!(history.is_empty());
|
||||||
|
|
||||||
// Add to history
|
// Add to history
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::user_message("Hello")])
|
.append_history(&mut history, vec![Item::user_message("Hello")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::assistant_message("Hi there!")])
|
.append_history(&mut history, vec![Item::assistant_message("Hi there!")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(engine.history().len(), 2);
|
assert_eq!(history.len(), 2);
|
||||||
|
|
||||||
// Append to history via the callback-aware API.
|
// Append to history via the callback-aware API.
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::user_message("How are you?")])
|
.append_history(&mut history, vec![Item::user_message("How are you?")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(engine.history().len(), 3);
|
assert_eq!(history.len(), 3);
|
||||||
|
|
||||||
// Clear history
|
// Clear history
|
||||||
engine.clear_history();
|
engine.clear_history(&mut history);
|
||||||
assert!(engine.history().is_empty());
|
assert!(history.is_empty());
|
||||||
|
|
||||||
// Set history
|
// Set history
|
||||||
let items = vec![
|
let items = vec![
|
||||||
Item::user_message("Test"),
|
Item::user_message("Test"),
|
||||||
Item::assistant_message("Response"),
|
Item::assistant_message("Response"),
|
||||||
];
|
];
|
||||||
engine.set_history(items);
|
engine.set_history(&mut history, items);
|
||||||
assert_eq!(engine.history().len(), 2);
|
assert_eq!(history.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that Engine can be constructed using builder pattern
|
/// Verify that Engine can be constructed using builder pattern
|
||||||
@@ -76,9 +80,10 @@ fn test_mutable_history_manipulation() {
|
|||||||
fn test_mutable_builder_pattern() {
|
fn test_mutable_builder_pattern() {
|
||||||
let client = MockLlmClient::new(vec![]);
|
let client = MockLlmClient::new(vec![]);
|
||||||
let engine = Engine::new(client).system_prompt("System prompt");
|
let engine = Engine::new(client).system_prompt("System prompt");
|
||||||
|
let history: History = History::new();
|
||||||
|
|
||||||
assert_eq!(engine.get_system_prompt(), Some("System prompt"));
|
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.
|
/// 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 = Arc::new(Mutex::new(Vec::new()));
|
||||||
let observed_for_callback = Arc::clone(&observed);
|
let observed_for_callback = Arc::clone(&observed);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
engine.on_history_append(move |item| {
|
engine.on_history_append(move |item| {
|
||||||
if let Some(text) = item.as_text() {
|
if let Some(text) = item.as_text() {
|
||||||
observed_for_callback.lock().unwrap().push(text.to_string());
|
observed_for_callback.lock().unwrap().push(text.to_string());
|
||||||
@@ -96,18 +102,21 @@ fn test_mutable_append_history() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::user_message("First")])
|
.append_history(&mut history, vec![Item::user_message("First")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
engine
|
engine
|
||||||
.append_history(vec![
|
.append_history(
|
||||||
|
&mut history,
|
||||||
|
vec![
|
||||||
Item::assistant_message("Response 1"),
|
Item::assistant_message("Response 1"),
|
||||||
Item::user_message("Second"),
|
Item::user_message("Second"),
|
||||||
Item::assistant_message("Response 2"),
|
Item::assistant_message("Response 2"),
|
||||||
])
|
],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(engine.history().len(), 4);
|
assert_eq!(history.len(), 4);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
observed.lock().unwrap().as_slice(),
|
observed.lock().unwrap().as_slice(),
|
||||||
["First", "Response 1", "Second", "Response 2"]
|
["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 tool = CountingTool::new("count_tool");
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
engine.register_tool(tool.definition());
|
engine.register_tool(tool.definition());
|
||||||
engine.on_history_append(|item| {
|
engine.on_history_append(|item| {
|
||||||
if item.is_tool_call() {
|
if item.is_tool_call() {
|
||||||
@@ -191,15 +201,15 @@ async fn history_append_failure_stops_before_tool_execution() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut engine = engine.lock();
|
let mut engine = engine.lock(&history);
|
||||||
let error = engine.run("use the tool").await.unwrap_err();
|
let error = engine.run(&mut history, "use the tool").await.unwrap_err();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC")
|
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC")
|
||||||
);
|
);
|
||||||
assert_eq!(tool.call_count(), 0);
|
assert_eq!(tool.call_count(), 0);
|
||||||
assert_eq!(engine.history().len(), 1);
|
assert_eq!(history.len(), 1);
|
||||||
assert_eq!(engine.history()[0].as_text(), Some("use the tool"));
|
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() {
|
fn test_lock_transition() {
|
||||||
let client = MockLlmClient::new(vec![]);
|
let client = MockLlmClient::new(vec![]);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
engine.set_system_prompt("System");
|
engine.set_system_prompt("System");
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::user_message("Hello")])
|
.append_history(&mut history, vec![Item::user_message("Hello")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::assistant_message("Hi")])
|
.append_history(&mut history, vec![Item::assistant_message("Hi")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Lock
|
// Lock
|
||||||
let locked_engine = engine.lock();
|
let locked_engine = engine.lock(&history);
|
||||||
|
|
||||||
// History and system prompt are still accessible in Locked state
|
// History and system prompt are still accessible in Locked state
|
||||||
assert_eq!(locked_engine.get_system_prompt(), Some("System"));
|
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);
|
assert_eq!(locked_engine.locked_prefix_len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,21 +245,22 @@ fn test_lock_transition() {
|
|||||||
fn test_unlock_transition() {
|
fn test_unlock_transition() {
|
||||||
let client = MockLlmClient::new(vec![]);
|
let client = MockLlmClient::new(vec![]);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::user_message("Hello")])
|
.append_history(&mut history, vec![Item::user_message("Hello")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let locked_engine = engine.lock();
|
let locked_engine = engine.lock(&history);
|
||||||
|
|
||||||
// Unlock
|
// Unlock
|
||||||
let mut engine = locked_engine.unlock();
|
let mut engine = locked_engine.unlock();
|
||||||
|
|
||||||
// History operations are available again in Mutable state
|
// History operations are available again in Mutable state
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::assistant_message("Hi")])
|
.append_history(&mut history, vec![Item::assistant_message("Hi")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
engine.clear_history();
|
engine.clear_history(&mut history);
|
||||||
assert!(engine.history().is_empty());
|
assert!(history.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -269,20 +281,20 @@ async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
// Execute (Mutable::run consumes self, returns EngineRunOutput)
|
// Execute (Mutable::run consumes self, returns EngineRunOutput)
|
||||||
let out = engine.run("Hi there").await?;
|
let _out = engine.run(&mut history, "Hi there").await?;
|
||||||
let engine = out.engine;
|
|
||||||
|
|
||||||
// History is updated
|
// History is updated
|
||||||
let history = engine.history();
|
let entries = history.entries();
|
||||||
assert_eq!(history.len(), 2); // user + assistant
|
assert_eq!(history.len(), 2); // user + assistant
|
||||||
|
|
||||||
// User message
|
// User message
|
||||||
assert_eq!(history[0].as_text(), Some("Hi there"));
|
assert_eq!(entries[0].item.as_text(), Some("Hi there"));
|
||||||
|
|
||||||
// Assistant message
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -313,35 +325,36 @@ async fn test_locked_multi_turn_history_accumulation() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let engine = Engine::new(client).system_prompt("You are helpful.");
|
let engine = Engine::new(client).system_prompt("You are helpful.");
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
// Lock (after setting system prompt)
|
// 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
|
assert_eq!(locked_engine.locked_prefix_len(), 0); // No items yet
|
||||||
|
|
||||||
// Turn 1
|
// Turn 1
|
||||||
let result1 = locked_engine.run("Hello!").await;
|
let result1 = locked_engine.run(&mut history, "Hello!").await;
|
||||||
assert!(result1.is_ok());
|
assert!(result1.is_ok());
|
||||||
assert_eq!(locked_engine.history().len(), 2); // user + assistant
|
assert_eq!(history.len(), 2); // user + assistant
|
||||||
|
|
||||||
// Turn 2
|
// Turn 2
|
||||||
let result2 = locked_engine.run("Can you help me?").await;
|
let result2 = locked_engine.run(&mut history, "Can you help me?").await;
|
||||||
assert!(result2.is_ok());
|
assert!(result2.is_ok());
|
||||||
assert_eq!(locked_engine.history().len(), 4); // 2 * (user + assistant)
|
assert_eq!(history.len(), 4); // 2 * (user + assistant)
|
||||||
|
|
||||||
// Verify history contents
|
// Verify history contents
|
||||||
let history = locked_engine.history();
|
let entries = history.entries();
|
||||||
|
|
||||||
// Turn 1 user message
|
// 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
|
// 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
|
// 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
|
// 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
|
/// Verify that locked_prefix_len correctly records history length at lock time
|
||||||
@@ -367,26 +380,36 @@ async fn test_locked_prefix_len_tracking() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
// Add items beforehand
|
// Add items beforehand
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::user_message("Pre-existing message 1")])
|
.append_history(
|
||||||
|
&mut history,
|
||||||
|
vec![Item::user_message("Pre-existing message 1")],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
engine
|
engine
|
||||||
.append_history(vec![Item::assistant_message("Pre-existing response 1")])
|
.append_history(
|
||||||
|
&mut history,
|
||||||
|
vec![Item::assistant_message("Pre-existing response 1")],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(engine.history().len(), 2);
|
assert_eq!(history.len(), 2);
|
||||||
|
|
||||||
// Lock
|
// 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
|
assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time
|
||||||
|
|
||||||
// Execute turn
|
// Execute turn
|
||||||
locked_engine.run("New message").await.unwrap();
|
locked_engine
|
||||||
|
.run(&mut history, "New message")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// History grows but locked_prefix_len remains unchanged
|
// 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
|
assert_eq!(locked_engine.locked_prefix_len(), 2); // Unchanged
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,18 +436,19 @@ async fn test_turn_count_increment() -> Result<(), EngineError> {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
assert_eq!(engine.turn_count(), 0);
|
assert_eq!(engine.turn_count(), 0);
|
||||||
assert_eq!(engine.llm_call_count(), 0);
|
assert_eq!(engine.llm_call_count(), 0);
|
||||||
|
|
||||||
// First run consumes Mutable, returns EngineRunOutput
|
// 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);
|
assert_eq!(engine.turn_count(), 1);
|
||||||
// Retry not yet implemented → AgentTurn:LlmCall is 1:1.
|
// Retry not yet implemented → AgentTurn:LlmCall is 1:1.
|
||||||
assert_eq!(engine.llm_call_count(), 1);
|
assert_eq!(engine.llm_call_count(), 1);
|
||||||
|
|
||||||
// Subsequent runs on Locked take &mut self
|
// Subsequent runs on Locked take &mut self
|
||||||
engine.run("Second").await?;
|
engine.run(&mut history, "Second").await?;
|
||||||
assert_eq!(engine.turn_count(), 2);
|
assert_eq!(engine.turn_count(), 2);
|
||||||
assert_eq!(engine.llm_call_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 engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
engine
|
engine
|
||||||
.append_history(vec![
|
.append_history(
|
||||||
Item::user_message("Hello"),
|
&mut history,
|
||||||
Item::assistant_message("Hi"),
|
vec![Item::user_message("Hello"), Item::assistant_message("Hi")],
|
||||||
])
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Lock -> Unlock
|
// Lock -> Unlock
|
||||||
let locked = engine.lock();
|
let locked = engine.lock(&history);
|
||||||
assert_eq!(locked.locked_prefix_len(), 2);
|
assert_eq!(locked.locked_prefix_len(), 2);
|
||||||
|
|
||||||
let mut unlocked = locked.unlock();
|
let mut unlocked = locked.unlock();
|
||||||
|
|
||||||
// Edit history
|
// Edit history
|
||||||
unlocked.clear_history();
|
unlocked.clear_history(&mut history);
|
||||||
unlocked
|
unlocked
|
||||||
.append_history(vec![Item::user_message("Fresh start")])
|
.append_history(&mut history, vec![Item::user_message("Fresh start")])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Re-lock
|
// Re-lock
|
||||||
let relocked = unlocked.lock();
|
let relocked = unlocked.lock(&history);
|
||||||
assert_eq!(relocked.history().len(), 1);
|
assert_eq!(history.len(), 1);
|
||||||
assert_eq!(relocked.locked_prefix_len(), 1);
|
assert_eq!(relocked.locked_prefix_len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,19 +533,23 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
let tool_a = CountingTool::new("tool_a");
|
let tool_a = CountingTool::new("tool_a");
|
||||||
engine.register_tool(tool_a.definition());
|
engine.register_tool(tool_a.definition());
|
||||||
|
|
||||||
let mut locked = engine.lock();
|
let mut locked = engine.lock(&history);
|
||||||
locked.run("first").await.expect("first run");
|
locked.run(&mut history, "first").await.expect("first run");
|
||||||
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
|
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
|
||||||
|
|
||||||
let mut unlocked = locked.unlock();
|
let mut unlocked = locked.unlock();
|
||||||
let tool_b = CountingTool::new("tool_b");
|
let tool_b = CountingTool::new("tool_b");
|
||||||
unlocked.register_tool(tool_b.definition());
|
unlocked.register_tool(tool_b.definition());
|
||||||
|
|
||||||
let mut relocked = unlocked.lock();
|
let mut relocked = unlocked.lock(&history);
|
||||||
relocked.run("second").await.expect("second run");
|
relocked
|
||||||
|
.run(&mut history, "second")
|
||||||
|
.await
|
||||||
|
.expect("second run");
|
||||||
|
|
||||||
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
|
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");
|
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
|
||||||
@@ -535,8 +564,9 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
|||||||
fn test_system_prompt_preserved_in_locked_state() {
|
fn test_system_prompt_preserved_in_locked_state() {
|
||||||
let client = MockLlmClient::new(vec![]);
|
let client = MockLlmClient::new(vec![]);
|
||||||
let engine = Engine::new(client).system_prompt("Important system prompt");
|
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"));
|
assert_eq!(locked.get_system_prompt(), Some("Important system prompt"));
|
||||||
|
|
||||||
let unlocked = locked.unlock();
|
let unlocked = locked.unlock();
|
||||||
@@ -551,13 +581,229 @@ fn test_system_prompt_preserved_in_locked_state() {
|
|||||||
fn test_system_prompt_change_after_unlock() {
|
fn test_system_prompt_change_after_unlock() {
|
||||||
let client = MockLlmClient::new(vec![]);
|
let client = MockLlmClient::new(vec![]);
|
||||||
let engine = Engine::new(client).system_prompt("Original prompt");
|
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();
|
let mut unlocked = locked.unlock();
|
||||||
|
|
||||||
unlocked.set_system_prompt("New prompt");
|
unlocked.set_system_prompt("New prompt");
|
||||||
assert_eq!(unlocked.get_system_prompt(), Some("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"));
|
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 responses = vec![completed_text_events(), completed_text_events()];
|
||||||
|
let mut engine = Engine::new(MockLlmClient::with_responses(responses));
|
||||||
|
let mut history: History = History::new();
|
||||||
|
engine.set_max_turns(Some(1));
|
||||||
|
let mut engine = engine.lock(&history);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
engine.run(&mut history, "first").await.unwrap(),
|
||||||
|
EngineResult::Finished
|
||||||
|
);
|
||||||
|
assert_eq!(engine.turn_count(), 1);
|
||||||
|
assert_eq!(engine.active_run_turn_count(), None);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
engine.run(&mut history, "second").await.unwrap(),
|
||||||
|
EngineResult::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 engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
let mut history: History = History::new();
|
||||||
|
engine.set_max_turns(Some(1));
|
||||||
|
engine.set_interceptor(YieldOnce {
|
||||||
|
calls: AtomicUsize::new(0),
|
||||||
|
});
|
||||||
|
let mut engine = engine.lock(&history);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
engine.run(&mut history, "start").await.unwrap(),
|
||||||
|
EngineResult::Yielded
|
||||||
|
);
|
||||||
|
assert_eq!(engine.turn_count(), 0);
|
||||||
|
assert_eq!(engine.active_run_turn_count(), Some(0));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
engine.resume(&mut history).await.unwrap(),
|
||||||
|
EngineResult::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 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));
|
||||||
|
let mut history: History = History::new();
|
||||||
|
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_eq!(
|
||||||
|
engine.run(&mut history, "call it").await.unwrap(),
|
||||||
|
EngineResult::Paused
|
||||||
|
);
|
||||||
|
assert_eq!(engine.turn_count(), 1);
|
||||||
|
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||||
|
assert_eq!(tool.call_count(), 0);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
engine.resume(&mut history).await.unwrap(),
|
||||||
|
EngineResult::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 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);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
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_eq!(
|
||||||
|
engine.run(&mut history, "pause").await.unwrap(),
|
||||||
|
EngineResult::Paused
|
||||||
|
);
|
||||||
|
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
engine.run(&mut history, "replace").await.unwrap(),
|
||||||
|
EngineResult::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 engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
let mut history: History = History::new();
|
||||||
|
engine.set_max_turns(Some(1));
|
||||||
|
engine.set_interceptor(ContinueTurnOnce {
|
||||||
|
calls: AtomicUsize::new(0),
|
||||||
|
});
|
||||||
|
let mut engine = engine.lock(&history);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
engine.run(&mut history, "start").await.unwrap(),
|
||||||
|
EngineResult::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 engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||||
|
let mut history: History = History::new();
|
||||||
|
engine.set_max_turns(Some(1));
|
||||||
|
engine.set_turn_count(7);
|
||||||
|
engine.set_last_run_interrupted(true);
|
||||||
|
engine.set_active_run_turn_count(Some(1));
|
||||||
|
let mut engine = engine.lock(&history);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
engine.resume(&mut history).await.unwrap(),
|
||||||
|
EngineResult::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,12 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use agen::Engine;
|
|
||||||
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
|
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||||
use agen::tool::{
|
use agen::tool::{
|
||||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
||||||
};
|
};
|
||||||
|
use agen::{Engine, History};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
@@ -145,6 +145,7 @@ async fn test_parallel_tool_execution() {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
let tool1 = SlowTool::new("slow_tool_1", 100);
|
let tool1 = SlowTool::new("slow_tool_1", 100);
|
||||||
let tool2 = SlowTool::new("slow_tool_2", 100);
|
let tool2 = SlowTool::new("slow_tool_2", 100);
|
||||||
let tool3 = SlowTool::new("slow_tool_3", 100);
|
let tool3 = SlowTool::new("slow_tool_3", 100);
|
||||||
@@ -159,7 +160,7 @@ async fn test_parallel_tool_execution() {
|
|||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// 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();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
// Verify all tools were called
|
// Verify all tools were called
|
||||||
@@ -205,13 +206,14 @@ async fn test_tool_execution_context_order_and_batch_id() {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
let contexts = Arc::new(Mutex::new(Vec::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_a", contexts.clone()).definition());
|
||||||
engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition());
|
engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition());
|
||||||
engine.register_tool(ContextRecordingTool::new("record_c", 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();
|
let mut contexts = contexts.lock().unwrap().clone();
|
||||||
contexts.sort_by_key(|ctx| ctx.call_index);
|
contexts.sort_by_key(|ctx| ctx.call_index);
|
||||||
@@ -256,11 +258,12 @@ async fn test_tool_execution_context_batch_id_changes_between_batches() {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
let contexts = Arc::new(Mutex::new(Vec::new()));
|
let contexts = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
engine.register_tool(ContextRecordingTool::new("record", contexts.clone()).definition());
|
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();
|
let contexts = contexts.lock().unwrap().clone();
|
||||||
assert_eq!(contexts.len(), 2);
|
assert_eq!(contexts.len(), 2);
|
||||||
@@ -298,6 +301,7 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
let executed_contexts = Arc::new(Mutex::new(Vec::new()));
|
let executed_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||||
let pre_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()));
|
let post_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -344,7 +348,9 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
|
|||||||
post_contexts: post_contexts.clone(),
|
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();
|
let mut pre_contexts = pre_contexts.lock().unwrap().clone();
|
||||||
pre_contexts.sort_by_key(|ctx| ctx.call_index);
|
pre_contexts.sort_by_key(|ctx| ctx.call_index);
|
||||||
@@ -389,6 +395,7 @@ async fn test_before_tool_call_skip() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
let allowed_tool = SlowTool::new("allowed_tool", 10);
|
let allowed_tool = SlowTool::new("allowed_tool", 10);
|
||||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||||
@@ -416,7 +423,7 @@ async fn test_before_tool_call_skip() {
|
|||||||
engine.set_interceptor(BlockingPolicy);
|
engine.set_interceptor(BlockingPolicy);
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// 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
|
// allowed_tool is called, but blocked_tool is not
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -457,6 +464,7 @@ async fn test_post_tool_call_modification() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct SimpleTool;
|
struct SimpleTool;
|
||||||
@@ -503,7 +511,7 @@ async fn test_post_tool_call_modification() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// 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!(result.is_ok(), "Engine should complete");
|
||||||
|
|
||||||
@@ -540,6 +548,7 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||||
let blocked_clone = blocked_tool.clone();
|
let blocked_clone = blocked_tool.clone();
|
||||||
engine.register_tool(blocked_tool.definition());
|
engine.register_tool(blocked_tool.definition());
|
||||||
@@ -558,10 +567,13 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
|||||||
|
|
||||||
engine.set_interceptor(SyntheticPolicy);
|
engine.set_interceptor(SyntheticPolicy);
|
||||||
|
|
||||||
let result = engine.run("Test synthetic result").await.unwrap();
|
let _result = engine
|
||||||
|
.run(&mut history, "Test synthetic result")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
|
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,
|
item,
|
||||||
agen::Item::ToolResult {
|
agen::Item::ToolResult {
|
||||||
call_id,
|
call_id,
|
||||||
|
|||||||
@@ -13,12 +13,12 @@
|
|||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use agen::Engine;
|
|
||||||
use agen::Item;
|
use agen::Item;
|
||||||
use agen::llm_client::event::{
|
use agen::llm_client::event::{
|
||||||
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
|
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
|
||||||
StatusEvent,
|
StatusEvent,
|
||||||
};
|
};
|
||||||
|
use agen::{Engine, History};
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
|
|
||||||
fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> {
|
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 client = MockLlmClient::new(events);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
let out = engine.run("question?").await.expect("run ok");
|
let mut history: History = History::new();
|
||||||
let engine = out.engine;
|
let _out = engine.run(&mut history, "question?").await.expect("run ok");
|
||||||
|
|
||||||
let history = engine.history();
|
let entries = history.entries();
|
||||||
// user / reasoning / assistant_message
|
// user / reasoning / assistant_message
|
||||||
assert_eq!(history.len(), 3, "history: {history:?}");
|
assert_eq!(history.len(), 3, "history: {history:?}");
|
||||||
|
|
||||||
assert!(matches!(history[0], Item::Message { .. }));
|
assert!(matches!(entries[0].item, Item::Message { .. }));
|
||||||
match &history[1] {
|
match &entries[1].item {
|
||||||
Item::Reasoning {
|
Item::Reasoning {
|
||||||
text, signature, ..
|
text, signature, ..
|
||||||
} => {
|
} => {
|
||||||
@@ -82,7 +82,7 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
|
|||||||
}
|
}
|
||||||
other => panic!("expected Reasoning, got {other:?}"),
|
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 が
|
/// 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 client = MockLlmClient::new(events);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
let out = engine.run("q").await.expect("run ok");
|
let mut history: History = History::new();
|
||||||
let engine = out.engine;
|
let _out = engine.run(&mut history, "q").await.expect("run ok");
|
||||||
|
|
||||||
let history = engine.history();
|
let entries = history.entries();
|
||||||
match &history[1] {
|
match &entries[1].item {
|
||||||
Item::Reasoning {
|
Item::Reasoning {
|
||||||
text,
|
text,
|
||||||
summary,
|
summary,
|
||||||
@@ -155,13 +155,13 @@ async fn reasoning_precedes_text_in_assistant_burst() {
|
|||||||
}));
|
}));
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
let out = engine.run("q").await.expect("run ok");
|
let mut history: History = History::new();
|
||||||
let engine = out.engine;
|
let _out = engine.run(&mut history, "q").await.expect("run ok");
|
||||||
|
|
||||||
let history = engine.history();
|
let entries = history.entries();
|
||||||
// user / reasoning(先頭) / assistant_message
|
// user / reasoning(先頭) / assistant_message
|
||||||
assert!(matches!(history[1], Item::Reasoning { .. }));
|
assert!(matches!(entries[1].item, Item::Reasoning { .. }));
|
||||||
assert_eq!(history[2].as_text(), Some("intermediate"));
|
assert_eq!(entries[2].item.as_text(), Some("intermediate"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
|
/// 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 engine = Engine::new(client);
|
||||||
|
let mut history: History = History::new();
|
||||||
// resume: 既存 history を流し込む
|
// resume: 既存 history を流し込む
|
||||||
engine.set_history(vec![
|
engine.set_history(
|
||||||
|
&mut history,
|
||||||
|
vec![
|
||||||
Item::user_message("prior question"),
|
Item::user_message("prior question"),
|
||||||
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
||||||
Item::assistant_message("prior answer"),
|
Item::assistant_message("prior answer"),
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
|
|
||||||
let _ = engine.run("follow up").await.expect("run ok");
|
let _ = engine.run(&mut history, "follow up").await.expect("run ok");
|
||||||
|
|
||||||
let req = captured
|
let req = captured
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use agen::Engine;
|
use agen::{Engine, History};
|
||||||
use agen::llm_client::capability::{
|
use agen::llm_client::capability::{
|
||||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||||
};
|
};
|
||||||
@@ -22,7 +22,8 @@ fn main() {
|
|||||||
cap,
|
cap,
|
||||||
);
|
);
|
||||||
let engine = Engine::new(client);
|
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 def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused"));
|
||||||
let _ = locked.register_tool(def);
|
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
|
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>`
|
| ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>`
|
||||||
|
|
|
|
||||||
= note: the method was found for
|
= note: the method was found for
|
||||||
- `Engine<C>`
|
- `Engine<C, Mutable, A>`
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||||
|
|
||||||
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||||
|
|
||||||
@@ -44,8 +45,13 @@ pub struct CreateBackendWorkspaceRepositoryRecord {
|
|||||||
pub repository_id: String,
|
pub repository_id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub uri: String,
|
pub provider: Option<String>,
|
||||||
|
pub source: RepositorySource,
|
||||||
pub default_ref: Option<String>,
|
pub default_ref: Option<String>,
|
||||||
|
pub source_revision: u64,
|
||||||
|
pub source_fingerprint: String,
|
||||||
|
pub observed_status: RepositoryObservedStatus,
|
||||||
|
pub observed_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ struct BackendWorkerLaunchOptions {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct BackendWorkerLaunchRuntime {
|
struct BackendWorkerLaunchRuntime {
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
can_spawn_worker: bool,
|
worker_creation_available: bool,
|
||||||
working_directory_required: bool,
|
working_directory_required: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
let runtime = options
|
let runtime = options
|
||||||
.runtimes
|
.runtimes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required)
|
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
BackendWorkspaceClientError::InvalidTarget(
|
BackendWorkspaceClientError::InvalidTarget(
|
||||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
||||||
@@ -473,8 +473,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
|||||||
.map_err(ticket_client_error)
|
.map_err(ticket_client_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, _queued_by: &str) -> ticket::Result<()> {
|
fn queue_ready(
|
||||||
self.send_unit::<()>(
|
&self,
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
_queued_by: &str,
|
||||||
|
) -> ticket::Result<ticket::TicketQueueOutcome> {
|
||||||
|
self.send_json::<(), _>(
|
||||||
Method::POST,
|
Method::POST,
|
||||||
&format!(
|
&format!(
|
||||||
"/tickets/{}/workflow/queue",
|
"/tickets/{}/workflow/queue",
|
||||||
@@ -773,7 +777,7 @@ mod tests {
|
|||||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||||
(
|
(
|
||||||
"200 OK",
|
"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",
|
"200 OK",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
|||||||
match slug {
|
match slug {
|
||||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||||
slug: CODER_REVIEW_FLOW_SLUG,
|
slug: CODER_REVIEW_FLOW_SLUG,
|
||||||
revision: 3,
|
revision: 4,
|
||||||
path: "builtin/flows/coder-review.dcdl",
|
path: "builtin/flows/coder-review.dcdl",
|
||||||
content: CODER_REVIEW_FLOW_SOURCE,
|
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] {
|
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||||
slug: CODER_REVIEW_FLOW_SLUG,
|
slug: CODER_REVIEW_FLOW_SLUG,
|
||||||
revision: 3,
|
revision: 4,
|
||||||
path: "builtin/flows/coder-review.dcdl",
|
path: "builtin/flows/coder-review.dcdl",
|
||||||
content: CODER_REVIEW_FLOW_SOURCE,
|
content: CODER_REVIEW_FLOW_SOURCE,
|
||||||
}];
|
}];
|
||||||
@@ -46,6 +46,30 @@ pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
||||||
assert!(!builtin_flow_sources().is_empty());
|
assert!(!builtin_flow_sources().is_empty());
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
|
use crate::schema::{EvidenceKind, EvidenceOrigin, SourceEvidenceRef, SourceRef};
|
||||||
|
|
||||||
/// Current flat staging schema version.
|
/// Current flat staging schema version.
|
||||||
pub const STAGING_SCHEMA_VERSION: u32 = 2;
|
pub const STAGING_SCHEMA_VERSION: u32 = 2;
|
||||||
@@ -80,6 +80,8 @@ pub struct StagingEvidence {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub entry_range: Option<[u64; 2]>,
|
pub entry_range: Option<[u64; 2]>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub origin: Option<EvidenceOrigin>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub excerpt: Option<String>,
|
pub excerpt: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
@@ -159,6 +161,7 @@ mod tests {
|
|||||||
id: "E001".into(),
|
id: "E001".into(),
|
||||||
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
|
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
|
||||||
entry_range: Some([10, 12]),
|
entry_range: Some([10, 12]),
|
||||||
|
origin: None,
|
||||||
excerpt: Some("extract candidate taxonomy".into()),
|
excerpt: Some("extract candidate taxonomy".into()),
|
||||||
summary: Some("User and assistant discussed staging kinds".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.
|
/// Host-resolved source/evidence metadata for an individual staging claim.
|
||||||
///
|
///
|
||||||
/// This deliberately stores only bounded anchor metadata: stable ids, entry
|
/// 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.
|
/// Host-assigned evidence id within the referenced evidence set.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub evidence_id: Option<String>,
|
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.
|
/// Extensible evidence kind tag.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub evidence_kind: Option<EvidenceKind>,
|
pub evidence_kind: Option<EvidenceKind>,
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ mod decision;
|
|||||||
mod request;
|
mod request;
|
||||||
mod summary;
|
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 decision::{DecisionFrontmatter, DecisionStatus};
|
||||||
pub use request::RequestFrontmatter;
|
pub use request::RequestFrontmatter;
|
||||||
pub use summary::SummaryFrontmatter;
|
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))?;
|
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 {
|
if conflict {
|
||||||
return Err(MergeRequestError::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();
|
let now = i.now.to_rfc3339();
|
||||||
@@ -575,12 +575,16 @@ impl MergeRequestStore {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
if subject != i.current_subject_ref {
|
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 {
|
let e = ReviewCancelledEvent {
|
||||||
event_id: Uuid::now_v7().to_string(),
|
event_id: Uuid::now_v7().to_string(),
|
||||||
sequence: next_seq(&t, &ws, &mr)?,
|
sequence: next_seq(&t, &ws, &mr)?,
|
||||||
request_event_id: req,
|
request_event_id: req,
|
||||||
subject_ref: subject,
|
subject_ref: subject,
|
||||||
reason: "selector_from moved before submission".into(),
|
reason,
|
||||||
created_at: i.now,
|
created_at: i.now,
|
||||||
};
|
};
|
||||||
insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?;
|
insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?;
|
||||||
@@ -667,9 +671,27 @@ impl MergeRequestStore {
|
|||||||
}
|
}
|
||||||
match (&i.current_subject_ref, &review) {
|
match (&i.current_subject_ref, &review) {
|
||||||
(None, _) => b.push("selector_from could not be resolved".into()),
|
(None, _) => b.push("selector_from could not be resolved".into()),
|
||||||
(Some(_), None) => b.push("current source ref has no valid review".into()),
|
(Some(subject_ref), None) => {
|
||||||
(_, Some(r)) if r.decision == ReviewDecision::RequestChanges => {
|
let previous_review_subject = mr.thread.iter().rev().find_map(|event| match event {
|
||||||
b.push("current source ref requests changes".into())
|
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()
|
.is_err()
|
||||||
);
|
);
|
||||||
let mr = s.get("W", "T").unwrap();
|
let mr = s.get("W", "T").unwrap();
|
||||||
|
let cancellation = mr.thread.iter().find_map(|event| match event {
|
||||||
|
MergeRequestThreadEvent::ReviewCancelled(value) => Some(value),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
assert!(
|
assert!(
|
||||||
mr.thread
|
cancellation
|
||||||
.iter()
|
.as_ref()
|
||||||
.any(|e| matches!(e, MergeRequestThreadEvent::ReviewCancelled(_)))
|
.is_some_and(|value| value.reason.contains("selector_from moved")
|
||||||
|
&& value.reason.contains("fresh review"))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mr.effective_review("source-a").map(|r| &r.event_id),
|
mr.effective_review("source-a").map(|r| &r.event_id),
|
||||||
Some(&approved.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]
|
#[test]
|
||||||
fn review_revocation_invalidates_readiness() {
|
fn review_revocation_invalidates_readiness() {
|
||||||
let (_d, s) = fixture();
|
let (_d, s) = fixture();
|
||||||
|
|||||||
+85
-28
@@ -281,11 +281,44 @@ impl Method {
|
|||||||
/// Presentation category for an Internal Worker exposed through its parent's
|
/// Presentation category for an Internal Worker exposed through its parent's
|
||||||
/// protocol stream. Internal Workers never become independently addressable
|
/// protocol stream. Internal Workers never become independently addressable
|
||||||
/// protocol subjects.
|
/// 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))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum InternalWorkerKind {
|
pub enum InternalWorkerKind {
|
||||||
SubWorker,
|
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.
|
/// Stable presentation identity for one parent-owned Internal Worker session.
|
||||||
@@ -607,23 +640,18 @@ pub enum Event {
|
|||||||
/// This is not part of LLM history or prompt context; clients may display it
|
/// This is not part of LLM history or prompt context; clients may display it
|
||||||
/// briefly as operational status.
|
/// briefly as operational status.
|
||||||
MemoryWorker(MemoryWorkerEvent),
|
MemoryWorker(MemoryWorkerEvent),
|
||||||
/// Worker has started compacting the current session.
|
/// Worker has started compacting the current session, or bound the run to its
|
||||||
///
|
/// observable Internal Worker. Revisions upsert one stable lifecycle item.
|
||||||
/// Fired immediately before a compaction run. Success is signalled by
|
CompactStart {
|
||||||
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
|
lifecycle: CompactionLifecycle,
|
||||||
/// 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,
|
|
||||||
},
|
},
|
||||||
/// 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 {
|
CompactFailed {
|
||||||
error: String,
|
lifecycle: CompactionLifecycle,
|
||||||
},
|
},
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
@@ -1377,7 +1405,7 @@ mod tests {
|
|||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "completions");
|
assert_eq!(parsed["event"], "completions");
|
||||||
assert_eq!(parsed["data"]["kind"], "file");
|
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.
|
// is_dir defaults to false on inbound payloads that omit it.
|
||||||
let inbound =
|
let inbound =
|
||||||
@@ -1732,45 +1760,74 @@ mod tests {
|
|||||||
assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64);
|
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]
|
#[test]
|
||||||
fn event_compact_start_roundtrip() {
|
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();
|
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();
|
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]
|
#[test]
|
||||||
fn event_compact_done_roundtrip() {
|
fn event_compact_done_roundtrip() {
|
||||||
let id = uuid::Uuid::parse_str("0192f0e8-4d84-7d6e-a000-000000000001").unwrap();
|
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 json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "compact_done");
|
assert_eq!(parsed["event"], "compact_done");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed["data"]["new_segment_id"],
|
parsed["data"]["lifecycle"]["new_segment_id"],
|
||||||
"0192f0e8-4d84-7d6e-a000-000000000001"
|
"0192f0e8-4d84-7d6e-a000-000000000001"
|
||||||
);
|
);
|
||||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
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:?}"),
|
other => panic!("expected CompactDone, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_compact_failed_roundtrip() {
|
fn event_compact_failed_roundtrip() {
|
||||||
let event = Event::CompactFailed {
|
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Failed);
|
||||||
error: "provider 429".into(),
|
lifecycle.error = Some("provider 429".into());
|
||||||
};
|
let event = Event::CompactFailed { lifecycle };
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "compact_failed");
|
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();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
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:?}"),
|
other => panic!("expected CompactFailed, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ use ts_rs::{Config, TS};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||||
CommandStreamSlice, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
||||||
InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef,
|
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||||
InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary,
|
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||||
RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent,
|
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
||||||
WorkerStatus,
|
RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, WorkerStatus,
|
||||||
subscription::{
|
subscription::{
|
||||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||||
@@ -53,6 +53,8 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<CommandStreamSlice>(&cfg, &mut output);
|
push_decl::<CommandStreamSlice>(&cfg, &mut output);
|
||||||
push_decl::<CommandSnapshot>(&cfg, &mut output);
|
push_decl::<CommandSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<CommandEvent>(&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::<ScopeRule>(&cfg, &mut output);
|
||||||
push_decl::<CompletionEntry>(&cfg, &mut output);
|
push_decl::<CompletionEntry>(&cfg, &mut output);
|
||||||
push_decl::<RewindTargetId>(&cfg, &mut output);
|
push_decl::<RewindTargetId>(&cfg, &mut output);
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//! Serializable history entries with restore-authoritative logical identity and origin.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{LoggedItem, SessionId};
|
||||||
|
|
||||||
|
/// 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 = legacy_logged_history(LoggedItem::Message {
|
||||||
|
role: LoggedRole::Assistant,
|
||||||
|
content: vec![crate::LoggedContentPart::Text {
|
||||||
|
text: "answer".into(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy Session Logs did not persist annotations. Decode helpers explicitly
|
||||||
|
/// create `LegacyUnknown`; they never infer Human/System authority from role or
|
||||||
|
/// plaintext.
|
||||||
|
pub fn legacy_logged_history(item: LoggedItem) -> LoggedHistoryEntry {
|
||||||
|
LoggedHistoryEntry {
|
||||||
|
item,
|
||||||
|
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn legacy_segment_history(
|
||||||
|
session_id: SessionId,
|
||||||
|
items: impl IntoIterator<Item = LoggedItem>,
|
||||||
|
) -> Vec<LoggedHistoryEntry> {
|
||||||
|
let _ = session_id;
|
||||||
|
items.into_iter().map(legacy_logged_history).collect()
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
pub mod event_trace;
|
pub mod event_trace;
|
||||||
pub mod fs_store;
|
pub mod fs_store;
|
||||||
|
pub mod history;
|
||||||
pub mod logged_item;
|
pub mod logged_item;
|
||||||
pub mod segment;
|
pub mod segment;
|
||||||
pub mod segment_log;
|
pub mod segment_log;
|
||||||
@@ -44,6 +45,11 @@ pub use agen::UsageRecord;
|
|||||||
pub use agen::llm_client::types::{ContentPart, Item, Role};
|
pub use agen::llm_client::types::{ContentPart, Item, Role};
|
||||||
pub use event_trace::{TraceEntry, TracePayload};
|
pub use event_trace::{TraceEntry, TracePayload};
|
||||||
pub use fs_store::FsStore;
|
pub use fs_store::FsStore;
|
||||||
|
pub use history::{
|
||||||
|
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
||||||
|
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
|
||||||
|
LoggedWorkerSubject, legacy_logged_history, legacy_segment_history,
|
||||||
|
};
|
||||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||||
pub use segment::{
|
pub use segment::{
|
||||||
SegmentStartState, append_entry, append_system_item, classify_history_item,
|
SegmentStartState, append_entry, append_system_item, classify_history_item,
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ pub fn save_run_completed(
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
result: EngineResult,
|
result: EngineResult,
|
||||||
interrupted: bool,
|
interrupted: bool,
|
||||||
|
active_run_turn_count: Option<usize>,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
append_entry(
|
append_entry(
|
||||||
store,
|
store,
|
||||||
@@ -316,6 +317,7 @@ pub fn save_run_completed(
|
|||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
interrupted,
|
interrupted,
|
||||||
result,
|
result,
|
||||||
|
active_run_turn_count,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use agen::{EngineResult, UsageRecord};
|
|||||||
use protocol::{InvokeKind, Segment};
|
use protocol::{InvokeKind, Segment};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
|
||||||
use crate::logged_item::LoggedItem;
|
use crate::logged_item::LoggedItem;
|
||||||
use crate::system_item::SystemItem;
|
use crate::system_item::SystemItem;
|
||||||
|
|
||||||
@@ -70,6 +71,20 @@ pub enum LogEntry {
|
|||||||
compacted_from: Option<SegmentOrigin>,
|
compacted_from: Option<SegmentOrigin>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Schema-v2 segment seed. Retained entries keep their stable logical
|
||||||
|
/// identity and origin across fork/compaction/restore.
|
||||||
|
AnnotatedSegmentStart {
|
||||||
|
ts: u64,
|
||||||
|
session_id: crate::SessionId,
|
||||||
|
system_prompt: Option<String>,
|
||||||
|
config: RequestConfig,
|
||||||
|
history: Vec<LoggedHistoryEntry>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
forked_from: Option<SegmentOrigin>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
compacted_from: Option<SegmentOrigin>,
|
||||||
|
},
|
||||||
|
|
||||||
/// IDLE → active marker. Records the start of a new self-driving
|
/// IDLE → active marker. Records the start of a new self-driving
|
||||||
/// cycle (Invoke range). The range extends implicitly until the
|
/// cycle (Invoke range). The range extends implicitly until the
|
||||||
/// next `Invoke` entry; this entry carries the trigger only — the
|
/// next `Invoke` entry; this entry carries the trigger only — the
|
||||||
@@ -105,14 +120,37 @@ pub enum LogEntry {
|
|||||||
extensions: Vec<SessionExtension>,
|
extensions: Vec<SessionExtension>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Schema-v2 user submission with its exact model-visible entries. Typed
|
||||||
|
/// Flow instructions and caller-attributed input remain separate entries.
|
||||||
|
AnnotatedUserInput {
|
||||||
|
ts: u64,
|
||||||
|
segments: Vec<Segment>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
extensions: Vec<SessionExtension>,
|
||||||
|
history: Vec<LoggedHistoryEntry>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Schema-v2 model output and metadata committed as one journal record.
|
||||||
|
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
|
||||||
|
|
||||||
/// One assistant-side item appended to history — assistant message,
|
/// One assistant-side item appended to history — assistant message,
|
||||||
/// reasoning, or tool call. Singular: one entry per history item so
|
/// reasoning, or tool call. Singular: one entry per history item so
|
||||||
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
|
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
|
||||||
AssistantItem { ts: u64, item: LoggedItem },
|
AssistantItem { ts: u64, item: LoggedItem },
|
||||||
|
|
||||||
|
/// Schema-v2 tool output and metadata committed as one journal record.
|
||||||
|
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
|
||||||
|
|
||||||
/// One tool-execution result appended to history.
|
/// One tool-execution result appended to history.
|
||||||
ToolResult { ts: u64, item: LoggedItem },
|
ToolResult { ts: u64, item: LoggedItem },
|
||||||
|
|
||||||
|
/// Schema-v2 typed system event and model-visible metadata committed
|
||||||
|
/// together.
|
||||||
|
AnnotatedSystemItem {
|
||||||
|
ts: u64,
|
||||||
|
entry: LoggedSystemHistoryEntry,
|
||||||
|
},
|
||||||
|
|
||||||
/// One typed agent-injected system item: notification, child-Worker
|
/// One typed agent-injected system item: notification, child-Worker
|
||||||
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
|
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
|
||||||
/// `SystemItem` carries kind metadata that the LLM
|
/// `SystemItem` carries kind metadata that the LLM
|
||||||
@@ -125,11 +163,16 @@ pub enum LogEntry {
|
|||||||
TurnEnd { ts: u64, turn_count: usize },
|
TurnEnd { ts: u64, turn_count: usize },
|
||||||
|
|
||||||
/// `run()` / `resume()` が `EngineResult` で正常終了した。
|
/// `run()` / `resume()` が `EngineResult` で正常終了した。
|
||||||
/// Audit-only metadata: replay は `interrupted` のみ反映する。
|
/// Replay restores both interruption state and any resumable logical-run
|
||||||
|
/// turn budget.
|
||||||
RunCompleted {
|
RunCompleted {
|
||||||
ts: u64,
|
ts: u64,
|
||||||
interrupted: bool,
|
interrupted: bool,
|
||||||
result: EngineResult,
|
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` で終了した。
|
/// `run()` / `resume()` が `EngineError` で終了した。
|
||||||
@@ -141,6 +184,15 @@ pub enum LogEntry {
|
|||||||
message: String,
|
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
|
/// A paused interrupted turn was explicitly abandoned without calling
|
||||||
/// `run()` or `resume()` again. Replay clears the interrupted marker so
|
/// `run()` or `resume()` again. Replay clears the interrupted marker so
|
||||||
/// the restored Worker is idle and future user input starts a normal new turn.
|
/// the restored Worker is idle and future user input starts a normal new turn.
|
||||||
@@ -209,6 +261,8 @@ pub struct RestoredState {
|
|||||||
pub config: RequestConfig,
|
pub config: RequestConfig,
|
||||||
pub history: Vec<Item>,
|
pub history: Vec<Item>,
|
||||||
pub turn_count: usize,
|
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,
|
pub last_run_interrupted: bool,
|
||||||
/// Number of entries replayed. `0` means the segment log was empty.
|
/// Number of entries replayed. `0` means the segment log was empty.
|
||||||
/// Writers track their own append count via the same counter so
|
/// Writers track their own append count via the same counter so
|
||||||
@@ -238,6 +292,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
config: RequestConfig::default(),
|
config: RequestConfig::default(),
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
turn_count: 0,
|
turn_count: 0,
|
||||||
|
active_run_turn_count: None,
|
||||||
last_run_interrupted: false,
|
last_run_interrupted: false,
|
||||||
entries_count: 0,
|
entries_count: 0,
|
||||||
usage_history: Vec::new(),
|
usage_history: Vec::new(),
|
||||||
@@ -261,10 +316,27 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
state.config = config.clone();
|
state.config = config.clone();
|
||||||
state.history = history.iter().cloned().map(Item::from).collect();
|
state.history = history.iter().cloned().map(Item::from).collect();
|
||||||
}
|
}
|
||||||
|
LogEntry::AnnotatedSegmentStart {
|
||||||
|
session_id,
|
||||||
|
system_prompt,
|
||||||
|
config,
|
||||||
|
history,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
state.session_id = Some(*session_id);
|
||||||
|
state.system_prompt = system_prompt.clone();
|
||||||
|
state.config = config.clone();
|
||||||
|
state.history = history
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|entry| Item::from(entry.item))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
LogEntry::Invoke { .. } => {
|
LogEntry::Invoke { .. } => {
|
||||||
// A terminal run record below clears or refines this. If the
|
// A terminal run record below clears or refines this. If the
|
||||||
// log ends first, restore must treat the turn as interrupted.
|
// log ends first, restore must treat the turn as interrupted.
|
||||||
state.last_run_interrupted = true;
|
state.last_run_interrupted = true;
|
||||||
|
state.active_run_turn_count = Some(0);
|
||||||
}
|
}
|
||||||
LogEntry::UserInput {
|
LogEntry::UserInput {
|
||||||
segments,
|
segments,
|
||||||
@@ -280,6 +352,29 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
LogEntry::AnnotatedUserInput {
|
||||||
|
segments,
|
||||||
|
extensions,
|
||||||
|
history,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
state
|
||||||
|
.history
|
||||||
|
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
|
||||||
|
state.user_segments.push(segments.clone());
|
||||||
|
state.extensions.extend(
|
||||||
|
extensions
|
||||||
|
.iter()
|
||||||
|
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||||
|
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
||||||
|
state.history.push(Item::from(entry.item.clone()));
|
||||||
|
}
|
||||||
|
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||||
|
state.history.push(entry.item.to_history_item());
|
||||||
|
}
|
||||||
LogEntry::AssistantItem { item, .. } => {
|
LogEntry::AssistantItem { item, .. } => {
|
||||||
state.history.push(Item::from(item.clone()));
|
state.history.push(Item::from(item.clone()));
|
||||||
}
|
}
|
||||||
@@ -290,16 +385,44 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
state.history.push(item.to_history_item());
|
state.history.push(item.to_history_item());
|
||||||
}
|
}
|
||||||
LogEntry::TurnEnd { turn_count, .. } => {
|
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;
|
state.turn_count = *turn_count;
|
||||||
}
|
}
|
||||||
LogEntry::RunCompleted { interrupted, .. } => {
|
LogEntry::RunCompleted {
|
||||||
|
interrupted,
|
||||||
|
result,
|
||||||
|
active_run_turn_count,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
state.last_run_interrupted = *interrupted;
|
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, .. } => {
|
LogEntry::RunErrored { interrupted, .. } => {
|
||||||
state.last_run_interrupted = *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 { .. } => {
|
LogEntry::PausedTurnAbandoned { .. } => {
|
||||||
state.last_run_interrupted = false;
|
state.last_run_interrupted = false;
|
||||||
|
state.active_run_turn_count = None;
|
||||||
}
|
}
|
||||||
LogEntry::ConfigChanged { config, .. } => {
|
LogEntry::ConfigChanged { config, .. } => {
|
||||||
state.config = config.clone();
|
state.config = config.clone();
|
||||||
@@ -397,6 +520,7 @@ mod tests {
|
|||||||
ts: 3200,
|
ts: 3200,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
result: EngineResult::Finished,
|
result: EngineResult::Finished,
|
||||||
|
active_run_turn_count: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(state.history.len(), 2);
|
assert_eq!(state.history.len(), 2);
|
||||||
@@ -695,10 +819,93 @@ mod tests {
|
|||||||
ts: 100,
|
ts: 100,
|
||||||
interrupted: true,
|
interrupted: true,
|
||||||
result: EngineResult::Paused,
|
result: EngineResult::Paused,
|
||||||
|
active_run_turn_count: Some(1),
|
||||||
},
|
},
|
||||||
LogEntry::PausedTurnAbandoned { ts: 200 },
|
LogEntry::PausedTurnAbandoned { ts: 200 },
|
||||||
]);
|
]);
|
||||||
assert!(!state.last_run_interrupted);
|
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::SegmentStart {
|
||||||
|
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::SegmentStart {
|
||||||
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
const SESSION_SCHEMA_VERSION: u32 = 1;
|
const SESSION_SCHEMA_VERSION: u32 = 2;
|
||||||
|
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
|
||||||
const SESSION_FILE: &str = "session.json";
|
const SESSION_FILE: &str = "session.json";
|
||||||
const SEGMENTS_DIR: &str = "segments";
|
const SEGMENTS_DIR: &str = "segments";
|
||||||
|
|
||||||
@@ -44,16 +45,23 @@ impl WorkerSessionStore {
|
|||||||
fs::create_dir_all(root.join(SEGMENTS_DIR))?;
|
fs::create_dir_all(root.join(SEGMENTS_DIR))?;
|
||||||
let session_id = match fs::read(root.join(SESSION_FILE)) {
|
let session_id = match fs::read(root.join(SESSION_FILE)) {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||||
if manifest.schema_version != SESSION_SCHEMA_VERSION {
|
match manifest.schema_version {
|
||||||
|
SESSION_SCHEMA_VERSION => {}
|
||||||
|
LEGACY_SESSION_SCHEMA_VERSION => {
|
||||||
|
validate_legacy_segment_logs(&root)?;
|
||||||
|
manifest.schema_version = SESSION_SCHEMA_VERSION;
|
||||||
|
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
|
||||||
|
}
|
||||||
|
version => {
|
||||||
return Err(StoreError::Corrupt {
|
return Err(StoreError::Corrupt {
|
||||||
line: 0,
|
line: 0,
|
||||||
message: format!(
|
message: format!(
|
||||||
"unsupported Worker Session schema version {}, expected {}",
|
"unsupported Worker Session schema version {version}, expected {SESSION_SCHEMA_VERSION}"
|
||||||
manifest.schema_version, SESSION_SCHEMA_VERSION
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Some(manifest.session_id)
|
Some(manifest.session_id)
|
||||||
}
|
}
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||||
@@ -278,6 +286,37 @@ impl Store for WorkerSessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_legacy_segment_logs(root: &Path) -> Result<(), StoreError> {
|
||||||
|
let segments = root.join(SEGMENTS_DIR);
|
||||||
|
if !segments.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(&segments)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !name.ends_with(".jsonl") || name.ends_with(".trace.jsonl") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let contents = fs::read_to_string(&path)?;
|
||||||
|
for (line_index, line) in contents.lines().enumerate() {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
serde_json::from_str::<LogEntry>(line).map_err(|error| StoreError::Corrupt {
|
||||||
|
line: line_index + 1,
|
||||||
|
message: format!(
|
||||||
|
"cannot migrate legacy Worker Session log {}: {error}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
|
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
|
||||||
let mut bytes = serde_json::to_vec_pretty(value)?;
|
let mut bytes = serde_json::to_vec_pretty(value)?;
|
||||||
bytes.push(b'\n');
|
bytes.push(b'\n');
|
||||||
@@ -405,6 +444,54 @@ mod tests {
|
|||||||
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_v1_logs_are_validated_and_promoted_to_v2() {
|
||||||
|
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_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]
|
#[test]
|
||||||
fn reopen_preserves_session_and_segment_ids() {
|
fn reopen_preserves_session_and_segment_ids() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ fn round_trip_write_and_read() {
|
|||||||
ts: 3200,
|
ts: 3200,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
result: EngineResult::Finished,
|
result: EngineResult::Finished,
|
||||||
|
active_run_turn_count: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use agen::Engine;
|
|
||||||
use agen::interceptor::{Interceptor, TurnEndAction};
|
use agen::interceptor::{Interceptor, TurnEndAction};
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||||
use agen::llm_client::types::{Item, RequestConfig};
|
use agen::llm_client::types::{Item, RequestConfig};
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
|
use agen::{Engine, History};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
||||||
@@ -94,15 +95,47 @@ fn make_store() -> (tempfile::TempDir, FsStore) {
|
|||||||
(dir, store)
|
(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.
|
/// Run a worker turn and persist via session-store functions.
|
||||||
/// Takes ownership of the worker (needed for lock/unlock) and returns it.
|
/// Takes ownership of the worker (needed for lock/unlock) and returns it.
|
||||||
async fn run_and_persist(
|
async fn run_and_persist(
|
||||||
worker: Engine<MockLlmClient>,
|
mut worker: TestWorker,
|
||||||
store: &FsStore,
|
store: &FsStore,
|
||||||
session_id: session_store::SessionId,
|
session_id: session_store::SessionId,
|
||||||
segment_id: session_store::SegmentId,
|
segment_id: session_store::SegmentId,
|
||||||
input: &str,
|
input: &str,
|
||||||
) -> (Engine<MockLlmClient>, agen::EngineResult) {
|
) -> (TestWorker, agen::EngineResult) {
|
||||||
// Mirror Worker's run-entry contract: log the user input as segments
|
// Mirror Worker's run-entry contract: log the user input as segments
|
||||||
// before the worker pushes its flattened user_message; save_delta
|
// before the worker pushes its flattened user_message; save_delta
|
||||||
// skips the resulting user_message item to avoid double-write.
|
// skips the resulting user_message item to avoid double-write.
|
||||||
@@ -114,13 +147,14 @@ async fn run_and_persist(
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let history_before = worker.history().len();
|
let history_before = worker.history.len();
|
||||||
|
|
||||||
let mut locked = worker.lock();
|
let mut locked = worker.engine.lock(&worker.history);
|
||||||
let result = locked.run(input).await;
|
let result = locked.run(&mut worker.history, input).await;
|
||||||
let worker = locked.unlock();
|
worker.engine = locked.unlock();
|
||||||
|
|
||||||
let new_items = &worker.history()[history_before..];
|
let projected = worker.history();
|
||||||
|
let new_items = &projected[history_before..];
|
||||||
session_store::save_delta(store, session_id, segment_id, new_items).unwrap();
|
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();
|
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
|
||||||
|
|
||||||
@@ -132,6 +166,7 @@ async fn run_and_persist(
|
|||||||
segment_id,
|
segment_id,
|
||||||
r.clone(),
|
r.clone(),
|
||||||
worker.last_run_interrupted(),
|
worker.last_run_interrupted(),
|
||||||
|
worker.active_run_turn_count(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -159,14 +194,14 @@ async fn run_and_persist(
|
|||||||
async fn session_run_logs_entries() {
|
async fn session_run_logs_entries() {
|
||||||
let (_dir, store) = make_store();
|
let (_dir, store) = make_store();
|
||||||
let client = MockLlmClient::new(simple_text_events());
|
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(
|
let (sid, segid) = session_store::create_segment(
|
||||||
&store,
|
&store,
|
||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: worker.history(),
|
history: &worker.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -203,7 +238,7 @@ async fn session_run_logs_entries() {
|
|||||||
async fn session_restore_round_trip() {
|
async fn session_restore_round_trip() {
|
||||||
let (_dir, store) = make_store();
|
let (_dir, store) = make_store();
|
||||||
let client = MockLlmClient::new(simple_text_events());
|
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.");
|
worker.set_system_prompt("You are helpful.");
|
||||||
|
|
||||||
let (sid, segid) = session_store::create_segment(
|
let (sid, segid) = session_store::create_segment(
|
||||||
@@ -211,7 +246,7 @@ async fn session_restore_round_trip() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: worker.history(),
|
history: &worker.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -242,7 +277,7 @@ async fn session_restore_round_trip() {
|
|||||||
async fn session_run_with_tool_call() {
|
async fn session_run_with_tool_call() {
|
||||||
let (_dir, store) = make_store();
|
let (_dir, store) = make_store();
|
||||||
let client = MockLlmClient::with_responses(tool_call_events());
|
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.register_tool(weather_tool_definition());
|
||||||
|
|
||||||
let (sid, segid) = session_store::create_segment(
|
let (sid, segid) = session_store::create_segment(
|
||||||
@@ -250,7 +285,7 @@ async fn session_run_with_tool_call() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: worker.history(),
|
history: &worker.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -276,7 +311,7 @@ async fn session_resume_after_pause() {
|
|||||||
|
|
||||||
// First run: tool call with pause policy → Paused
|
// First run: tool call with pause policy → Paused
|
||||||
let client = MockLlmClient::with_responses(tool_call_events());
|
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.register_tool(weather_tool_definition());
|
||||||
worker.set_interceptor(PausePolicy);
|
worker.set_interceptor(PausePolicy);
|
||||||
|
|
||||||
@@ -285,7 +320,7 @@ async fn session_resume_after_pause() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: worker.history(),
|
history: &worker.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -309,13 +344,14 @@ async fn session_resume_after_pause() {
|
|||||||
// Restore state and verify
|
// Restore state and verify
|
||||||
let state = session_store::restore(&store, sid, segid).unwrap();
|
let state = session_store::restore(&store, sid, segid).unwrap();
|
||||||
assert!(state.last_run_interrupted);
|
assert!(state.last_run_interrupted);
|
||||||
|
assert_eq!(state.active_run_turn_count, Some(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn session_fork_creates_new_session() {
|
async fn session_fork_creates_new_session() {
|
||||||
let (_dir, store) = make_store();
|
let (_dir, store) = make_store();
|
||||||
let client = MockLlmClient::new(simple_text_events());
|
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");
|
worker.set_system_prompt("System prompt");
|
||||||
|
|
||||||
let (sid, segid) = session_store::create_segment(
|
let (sid, segid) = session_store::create_segment(
|
||||||
@@ -323,7 +359,7 @@ async fn session_fork_creates_new_session() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: worker.history(),
|
history: &worker.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -336,7 +372,7 @@ async fn session_fork_creates_new_session() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: worker.history(),
|
history: &worker.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -357,14 +393,14 @@ async fn session_fork_creates_new_session() {
|
|||||||
async fn session_fork_at_truncates_within_session() {
|
async fn session_fork_at_truncates_within_session() {
|
||||||
let (_dir, store) = make_store();
|
let (_dir, store) = make_store();
|
||||||
let client = MockLlmClient::new(simple_text_events());
|
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(
|
let (sid, segid) = session_store::create_segment(
|
||||||
&store,
|
&store,
|
||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: worker.history(),
|
history: &worker.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -402,14 +438,14 @@ async fn session_fork_at_truncates_within_session() {
|
|||||||
async fn session_config_changed_logged() {
|
async fn session_config_changed_logged() {
|
||||||
let (_dir, store) = make_store();
|
let (_dir, store) = make_store();
|
||||||
let client = MockLlmClient::new(vec![]);
|
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(
|
let (sid, segid) = session_store::create_segment(
|
||||||
&store,
|
&store,
|
||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: worker.history(),
|
history: &worker.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -435,14 +471,14 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
|
|
||||||
// Create a segment
|
// Create a segment
|
||||||
let client_a = MockLlmClient::new(simple_text_events());
|
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(
|
let (sid, original_segid) = session_store::create_segment(
|
||||||
&store,
|
&store,
|
||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker_a.get_system_prompt(),
|
system_prompt: worker_a.get_system_prompt(),
|
||||||
config: worker_a.request_config(),
|
config: worker_a.request_config(),
|
||||||
history: worker_a.history(),
|
history: &worker_a.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -468,7 +504,7 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker_a.get_system_prompt(),
|
system_prompt: worker_a.get_system_prompt(),
|
||||||
config: worker_a.request_config(),
|
config: worker_a.request_config(),
|
||||||
history: worker_a.history(),
|
history: &worker_a.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -520,14 +556,14 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
async fn nested_past_fork_leaves_ancestors_immutable() {
|
async fn nested_past_fork_leaves_ancestors_immutable() {
|
||||||
let (_dir, store) = make_store();
|
let (_dir, store) = make_store();
|
||||||
let client = MockLlmClient::new(simple_text_events());
|
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(
|
let (sid, root_segid) = session_store::create_segment(
|
||||||
&store,
|
&store,
|
||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: worker.history(),
|
history: &worker.history(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+1103
-198
File diff suppressed because it is too large
Load Diff
@@ -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, \
|
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.";
|
state_changed event, effective target, and planning -> ready transition atomically.";
|
||||||
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
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, \
|
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
|
||||||
and rejects unresolved blocking relations.";
|
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 \
|
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
||||||
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
||||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
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)
|
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)
|
self.backend.queue_ready(id, queued_by)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1219,12 +1223,22 @@ impl Tool for TicketQueueTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||||
let queued_by = default_author();
|
let queued_by = default_author();
|
||||||
self.backend
|
let outcome = self
|
||||||
|
.backend
|
||||||
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||||
.map_err(|error| backend_error("TicketQueue", error))?;
|
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||||
Ok(json_output(
|
Ok(json_output(
|
||||||
format!("Queued ticket {} for Orchestrator", params.ticket),
|
format!(
|
||||||
json!({ "ticket": params.ticket, "state": "queued", "queued_by": queued_by, "ok": true }),
|
"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
|
||||||
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,10 +131,11 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
|||||||
assert!(
|
assert!(
|
||||||
msg.contains("outside allowed read scope")
|
msg.contains("outside allowed read scope")
|
||||||
|| msg.contains("outside allowed write scope")
|
|| msg.contains("outside allowed write scope")
|
||||||
|
|| msg.contains("outside allowed scope")
|
||||||
|| msg.contains("has not been read"),
|
|| msg.contains("has not been read"),
|
||||||
"symlink escape not rejected: {msg}"
|
"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!(
|
assert!(
|
||||||
msg.contains("add the symlink target"),
|
msg.contains("add the symlink target"),
|
||||||
"symlink escape diagnostic should include remediation: {msg}"
|
"symlink escape diagnostic should include remediation: {msg}"
|
||||||
@@ -233,12 +234,16 @@ async fn absolute_path_is_rejected() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(format!("{err}").contains("invalid Workdir path"));
|
let msg = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("invalid logical filesystem path"),
|
||||||
|
"absolute path was not rejected as invalid: {msg}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn directory_target_is_rejected_for_read() {
|
async fn directory_target_is_rejected_for_read() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (_dir, _spill, reg) = setup();
|
||||||
let read = reg.get("Read");
|
let read = reg.get("Read");
|
||||||
let err = read
|
let err = read
|
||||||
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ async fn write_then_grep_finds_content() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn glob_finds_written_files() {
|
async fn glob_finds_written_files() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (_dir, _spill, reg) = setup();
|
||||||
let write = reg.get("Write");
|
let write = reg.get("Write");
|
||||||
let glob = reg.get("Glob");
|
let glob = reg.get("Glob");
|
||||||
|
|
||||||
@@ -229,7 +229,10 @@ async fn absolute_path_is_rejected() {
|
|||||||
.await;
|
.await;
|
||||||
// Absolute paths are rejected at the logical WorkdirSession boundary.
|
// Absolute paths are rejected at the logical WorkdirSession boundary.
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}");
|
assert!(
|
||||||
|
msg.contains("invalid logical filesystem path"),
|
||||||
|
"unexpected: {msg}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -340,7 +343,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let a = dir.path().join("a.txt");
|
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();
|
std::fs::write(&a, "one\n").unwrap();
|
||||||
|
|
||||||
// Read `a` — should appear in recency.
|
// Read `a` — should appear in recency.
|
||||||
|
|||||||
+48
-13
@@ -1342,13 +1342,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::CompactStart => {
|
Event::CompactStart { .. } => {
|
||||||
|
if self.last_streaming_compact_mut().is_none() {
|
||||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Event::CompactDone { new_segment_id } => {
|
}
|
||||||
|
Event::CompactDone { lifecycle } => {
|
||||||
self.session_context_tokens = 0;
|
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() {
|
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||||
let elapsed_secs = match evt {
|
let elapsed_secs = match evt {
|
||||||
CompactEvent::Streaming { started_at } => {
|
CompactEvent::Streaming { started_at } => {
|
||||||
@@ -1367,7 +1374,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() {
|
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||||
let elapsed_secs = match evt {
|
let elapsed_secs = match evt {
|
||||||
CompactEvent::Streaming { started_at } => {
|
CompactEvent::Streaming { started_at } => {
|
||||||
@@ -2486,7 +2496,7 @@ fn event_is_stale_after_rewind(event: &Event) -> bool {
|
|||||||
event,
|
event,
|
||||||
Event::Alert(_)
|
Event::Alert(_)
|
||||||
| Event::MemoryWorker(_)
|
| Event::MemoryWorker(_)
|
||||||
| Event::CompactStart
|
| Event::CompactStart { .. }
|
||||||
| Event::CompactDone { .. }
|
| Event::CompactDone { .. }
|
||||||
| Event::CompactFailed { .. }
|
| Event::CompactFailed { .. }
|
||||||
| Event::SegmentRotated { .. }
|
| Event::SegmentRotated { .. }
|
||||||
@@ -4076,13 +4086,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]
|
#[test]
|
||||||
fn compact_done_replaces_live_block() {
|
fn compact_done_replaces_live_block() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
||||||
|
|
||||||
app.handle_worker_event(Event::CompactStart);
|
app.handle_worker_event(Event::CompactStart {
|
||||||
app.handle_worker_event(Event::CompactDone { new_segment_id: id });
|
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_eq!(compact_block_count(&app), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -4098,10 +4129,12 @@ mod completion_flow_tests {
|
|||||||
fn compact_failed_replaces_live_block() {
|
fn compact_failed_replaces_live_block() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
||||||
app.handle_worker_event(Event::CompactStart);
|
app.handle_worker_event(Event::CompactStart {
|
||||||
app.handle_worker_event(Event::CompactFailed {
|
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||||
error: "provider 429".into(),
|
|
||||||
});
|
});
|
||||||
|
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_eq!(compact_block_count(&app), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -4117,7 +4150,9 @@ mod completion_flow_tests {
|
|||||||
fn shutdown_marks_live_compact_incomplete() {
|
fn shutdown_marks_live_compact_incomplete() {
|
||||||
let mut app = App::new("test".into());
|
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);
|
app.handle_worker_event(Event::Shutdown);
|
||||||
|
|
||||||
assert!(app.quit);
|
assert!(app.quit);
|
||||||
@@ -4208,9 +4243,9 @@ mod completion_flow_tests {
|
|||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.session_context_tokens = 42_000;
|
app.session_context_tokens = 42_000;
|
||||||
|
|
||||||
app.handle_worker_event(Event::CompactDone {
|
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||||
new_segment_id: uuid::Uuid::nil(),
|
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
|
||||||
});
|
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||||
|
|
||||||
assert_eq!(app.session_context_tokens, 0);
|
assert_eq!(app.session_context_tokens, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-51
@@ -4,6 +4,7 @@ use std::fmt;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use client::ticket_role::{
|
use client::ticket_role::{
|
||||||
@@ -580,6 +581,7 @@ pub(crate) enum IntakeRegistryUpdate {
|
|||||||
pub(crate) struct ReadyTicketPlanningReturnRequest {
|
pub(crate) struct ReadyTicketPlanningReturnRequest {
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
|
ticket_key: String,
|
||||||
user_instruction: String,
|
user_instruction: String,
|
||||||
followup: ReadyTicketPlanningReturnFollowup,
|
followup: ReadyTicketPlanningReturnFollowup,
|
||||||
}
|
}
|
||||||
@@ -2041,11 +2043,18 @@ impl DashboardApp {
|
|||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let ticket_id = ticket.id.clone();
|
let ticket_id = ticket.id.clone();
|
||||||
|
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
|
||||||
|
Ok(ticket_key) => ticket_key.to_string(),
|
||||||
|
Err(error) => {
|
||||||
|
self.notice = Some(error);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut context =
|
let mut context =
|
||||||
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
|
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
|
||||||
context.ticket = Some(TicketRef::id(ticket_id.clone()));
|
context.ticket = Some(TicketRef::id(ticket_id.clone()));
|
||||||
context.user_instruction = Some(format!(
|
context.user_instruction = Some(format!(
|
||||||
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
|
"Continue Intake for existing Ticket {ticket_key}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
|
||||||
));
|
));
|
||||||
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
|
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
|
||||||
Ok(store) => store,
|
Ok(store) => store,
|
||||||
@@ -2058,7 +2067,7 @@ impl DashboardApp {
|
|||||||
Ok(Some(claim)) => {
|
Ok(Some(claim)) => {
|
||||||
let status = local_claim_status_for_pod(&claim.worker_name, &self.list);
|
let status = local_claim_status_for_pod(&claim.worker_name, &self.list);
|
||||||
self.notice = Some(existing_ticket_claim_notice(
|
self.notice = Some(existing_ticket_claim_notice(
|
||||||
&ticket_id,
|
&ticket_key,
|
||||||
&claim.worker_name,
|
&claim.worker_name,
|
||||||
status,
|
status,
|
||||||
));
|
));
|
||||||
@@ -2086,7 +2095,7 @@ impl DashboardApp {
|
|||||||
self.sending = true;
|
self.sending = true;
|
||||||
self.notice = Some(format!(
|
self.notice = Some(format!(
|
||||||
"Launching Ticket Intake for {} as {}…",
|
"Launching Ticket Intake for {} as {}…",
|
||||||
ticket_id, planned.worker_name
|
ticket_key, planned.worker_name
|
||||||
));
|
));
|
||||||
Some(IntakeLaunchRequest {
|
Some(IntakeLaunchRequest {
|
||||||
context,
|
context,
|
||||||
@@ -2157,10 +2166,17 @@ impl DashboardApp {
|
|||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let ticket_id = ticket.id.clone();
|
let ticket_id = ticket.id.clone();
|
||||||
|
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
|
||||||
|
Ok(ticket_key) => ticket_key.to_string(),
|
||||||
|
Err(error) => {
|
||||||
|
self.notice = Some(error);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
if ticket.workflow_state != TicketWorkflowState::Ready {
|
if ticket.workflow_state != TicketWorkflowState::Ready {
|
||||||
self.notice = Some(format!(
|
self.notice = Some(format!(
|
||||||
"Ticket {} is {}; expected ready before returning to planning.",
|
"Ticket {} is {}; expected ready before returning to planning.",
|
||||||
ticket_id,
|
ticket_key,
|
||||||
ticket.workflow_state.as_str()
|
ticket.workflow_state.as_str()
|
||||||
));
|
));
|
||||||
return None;
|
return None;
|
||||||
@@ -2212,7 +2228,7 @@ impl DashboardApp {
|
|||||||
TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake);
|
TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake);
|
||||||
context.ticket = Some(TicketRef::id(ticket_id.clone()));
|
context.ticket = Some(TicketRef::id(ticket_id.clone()));
|
||||||
context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction(
|
context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction(
|
||||||
&ticket_id,
|
&ticket_key,
|
||||||
&user_instruction,
|
&user_instruction,
|
||||||
));
|
));
|
||||||
let peer_registration = self.prepare_intake_peer_registration(&mut context);
|
let peer_registration = self.prepare_intake_peer_registration(&mut context);
|
||||||
@@ -2236,11 +2252,12 @@ impl DashboardApp {
|
|||||||
self.sending = true;
|
self.sending = true;
|
||||||
self.notice = Some(format!(
|
self.notice = Some(format!(
|
||||||
"Returning ready Ticket {} to planning for refinement…",
|
"Returning ready Ticket {} to planning for refinement…",
|
||||||
ticket_id
|
ticket_key
|
||||||
));
|
));
|
||||||
Some(ReadyTicketPlanningReturnRequest {
|
Some(ReadyTicketPlanningReturnRequest {
|
||||||
workspace_root,
|
workspace_root,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
|
ticket_key,
|
||||||
user_instruction,
|
user_instruction,
|
||||||
followup,
|
followup,
|
||||||
})
|
})
|
||||||
@@ -3917,21 +3934,35 @@ fn bounded_refinement_instruction(input: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str) -> String {
|
fn required_ticket_handoff_key(resource_key: Option<&str>) -> Result<&str, String> {
|
||||||
|
let resource_key = resource_key.ok_or_else(|| {
|
||||||
|
"Ticket handoff is unavailable because the canonical T-* resource key is missing. Refresh the panel and retry."
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
let sequence = resource_key.strip_prefix("T-").filter(|sequence| {
|
||||||
|
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
});
|
||||||
|
sequence.map(|_| resource_key).ok_or_else(|| {
|
||||||
|
"Ticket handoff is unavailable because the canonical T-* resource key is invalid. Refresh the panel and retry."
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_ready_ticket_refinement_thread_body(ticket_key: &str, instruction: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"Panel returned ready Ticket {ticket_id} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
|
"Panel returned ready Ticket {ticket_key} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String {
|
fn build_ready_ticket_refinement_launch_instruction(ticket_key: &str, instruction: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
|
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_key}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_ready_ticket_refinement_notify(ticket_id: &str, instruction: &str) -> String {
|
fn build_ready_ticket_refinement_notify(ticket_key: &str, instruction: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"Ticket {ticket_id} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
|
"Ticket {ticket_key} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3960,10 +3991,12 @@ async fn dispatch_ready_ticket_planning_return(
|
|||||||
let ticket = backend
|
let ticket = backend
|
||||||
.show(id.clone())
|
.show(id.clone())
|
||||||
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
||||||
|
let ticket_key =
|
||||||
|
required_ticket_handoff_key(Some(&request.ticket_key)).map_err(TicketActionError::Stale)?;
|
||||||
if ticket.meta.workflow_state != TicketWorkflowState::Ready {
|
if ticket.meta.workflow_state != TicketWorkflowState::Ready {
|
||||||
return Err(TicketActionError::Stale(format!(
|
return Err(TicketActionError::Stale(format!(
|
||||||
"Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.",
|
"Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.",
|
||||||
ticket.meta.id,
|
ticket_key,
|
||||||
ticket.meta.workflow_state.as_str()
|
ticket.meta.workflow_state.as_str()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -3972,7 +4005,7 @@ async fn dispatch_ready_ticket_planning_return(
|
|||||||
TicketWorkflowState::Planning.as_str(),
|
TicketWorkflowState::Planning.as_str(),
|
||||||
"panel_return_to_planning",
|
"panel_return_to_planning",
|
||||||
MarkdownText::from(build_ready_ticket_refinement_thread_body(
|
MarkdownText::from(build_ready_ticket_refinement_thread_body(
|
||||||
&ticket.meta.id,
|
ticket_key,
|
||||||
&request.user_instruction,
|
&request.user_instruction,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
@@ -3986,7 +4019,7 @@ async fn dispatch_ready_ticket_planning_return(
|
|||||||
ReadyTicketPlanningReturnOutcome {
|
ReadyTicketPlanningReturnOutcome {
|
||||||
notice: format!(
|
notice: format!(
|
||||||
"Ticket {} returned to planning for refinement; launching Ticket Intake…",
|
"Ticket {} returned to planning for refinement; launching Ticket Intake…",
|
||||||
ticket.meta.id
|
ticket_key
|
||||||
),
|
),
|
||||||
followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request),
|
followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request),
|
||||||
}
|
}
|
||||||
@@ -3996,19 +4029,19 @@ async fn dispatch_ready_ticket_planning_return(
|
|||||||
socket_path,
|
socket_path,
|
||||||
} => {
|
} => {
|
||||||
let message =
|
let message =
|
||||||
build_ready_ticket_refinement_notify(&ticket.meta.id, &request.user_instruction);
|
build_ready_ticket_refinement_notify(ticket_key, &request.user_instruction);
|
||||||
match send_notify_only(&socket_path, message, true).await {
|
match send_notify_only(&socket_path, message, true).await {
|
||||||
Ok(()) => ReadyTicketPlanningReturnOutcome {
|
Ok(()) => ReadyTicketPlanningReturnOutcome {
|
||||||
notice: format!(
|
notice: format!(
|
||||||
"Ticket {} returned to planning for refinement; notified live Intake Worker {}.",
|
"Ticket {} returned to planning for refinement; notified live Intake Worker {}.",
|
||||||
ticket.meta.id, worker_name
|
ticket_key, worker_name
|
||||||
),
|
),
|
||||||
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
||||||
},
|
},
|
||||||
Err(error) => ReadyTicketPlanningReturnOutcome {
|
Err(error) => ReadyTicketPlanningReturnOutcome {
|
||||||
notice: bounded_panel_diagnostic(format!(
|
notice: bounded_panel_diagnostic(format!(
|
||||||
"Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}",
|
"Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}",
|
||||||
ticket.meta.id, worker_name, error
|
ticket_key, worker_name, error
|
||||||
)),
|
)),
|
||||||
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
||||||
},
|
},
|
||||||
@@ -4019,7 +4052,7 @@ async fn dispatch_ready_ticket_planning_return(
|
|||||||
ReadyTicketPlanningReturnOutcome {
|
ReadyTicketPlanningReturnOutcome {
|
||||||
notice: format!(
|
notice: format!(
|
||||||
"Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…",
|
"Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…",
|
||||||
ticket.meta.id, worker_name
|
ticket_key, worker_name
|
||||||
),
|
),
|
||||||
followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request),
|
followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request),
|
||||||
}
|
}
|
||||||
@@ -4028,7 +4061,7 @@ async fn dispatch_ready_ticket_planning_return(
|
|||||||
ReadyTicketPlanningReturnOutcome {
|
ReadyTicketPlanningReturnOutcome {
|
||||||
notice: bounded_panel_diagnostic(format!(
|
notice: bounded_panel_diagnostic(format!(
|
||||||
"Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.",
|
"Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.",
|
||||||
ticket.meta.id, worker_name
|
ticket_key, worker_name
|
||||||
)),
|
)),
|
||||||
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
||||||
}
|
}
|
||||||
@@ -4125,7 +4158,10 @@ async fn dispatch_ticket_action(
|
|||||||
let config = TicketConfig::load_workspace(&request.workspace_root)
|
let config = TicketConfig::load_workspace(&request.workspace_root)
|
||||||
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
|
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
|
||||||
let backend = LocalTicketBackend::new(config.backend_root())
|
let backend = LocalTicketBackend::new(config.backend_root())
|
||||||
.with_record_language(config.ticket_record_language());
|
.with_record_language(config.ticket_record_language())
|
||||||
|
.with_target_authority(Arc::new(DashboardTicketTargetAuthority {
|
||||||
|
workspace_root: request.workspace_root.clone(),
|
||||||
|
}));
|
||||||
if request.action == NextUserAction::Close {
|
if request.action == NextUserAction::Close {
|
||||||
return dispatch_panel_close(&backend, &request.ticket_id);
|
return dispatch_panel_close(&backend, &request.ticket_id);
|
||||||
}
|
}
|
||||||
@@ -4201,17 +4237,32 @@ async fn dispatch_panel_queue(
|
|||||||
"root-ticket-state-after-orchestration-merge",
|
"root-ticket-state-after-orchestration-merge",
|
||||||
&preflight.root_top_level,
|
&preflight.root_top_level,
|
||||||
)?;
|
)?;
|
||||||
backend
|
let queue_outcome = backend
|
||||||
.queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel")
|
.queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel")
|
||||||
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
||||||
|
let expected_queue_tickets = preflight
|
||||||
|
.queue_tickets
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
let actual_queue_tickets = queue_outcome
|
||||||
|
.queued_tickets
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
if actual_queue_tickets != expected_queue_tickets {
|
||||||
|
return Err(TicketActionError::Stale(format!(
|
||||||
|
"Queue dependency plan changed after confirmation for Ticket {ticket_id}; reload and retry"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let commit = commit_panel_queue_ticket_record(&preflight)?;
|
let commit = commit_panel_queue_ticket_record(&preflight)?;
|
||||||
let sync = sync_panel_queue_to_orchestration(&preflight, &commit)?;
|
let sync = sync_panel_queue_to_orchestration(&preflight, &commit)?;
|
||||||
verify_panel_queue_synced(&preflight, &commit)?;
|
verify_panel_queue_synced(&preflight, &commit)?;
|
||||||
let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await;
|
let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await;
|
||||||
Ok(TicketActionOutcome {
|
Ok(TicketActionOutcome {
|
||||||
notice: format!(
|
notice: format!(
|
||||||
"Queued Ticket {}; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
|
"Queued Ticket closure [{}]; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
|
||||||
ticket_id,
|
queue_outcome.queued_tickets.join(", "),
|
||||||
commit.sha,
|
commit.sha,
|
||||||
root_merge.sentence(),
|
root_merge.sentence(),
|
||||||
sync.sentence(),
|
sync.sentence(),
|
||||||
@@ -4220,12 +4271,52 @@ async fn dispatch_panel_queue(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DashboardTicketTargetAuthority {
|
||||||
|
workspace_root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ticket::TicketTargetAuthority for DashboardTicketTargetAuthority {
|
||||||
|
fn resolve_target(
|
||||||
|
&self,
|
||||||
|
_workspace_id: &str,
|
||||||
|
repository_id: Option<&str>,
|
||||||
|
ref_selector: Option<&str>,
|
||||||
|
) -> ticket::Result<ticket::ResolvedTicketTarget> {
|
||||||
|
let repository_id = repository_id.unwrap_or("main");
|
||||||
|
if repository_id != "main" {
|
||||||
|
return Err(ticket::TicketError::UnknownTargetRepository(
|
||||||
|
repository_id.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ref_selector = ref_selector.unwrap_or("HEAD");
|
||||||
|
git_capture(
|
||||||
|
&self.workspace_root,
|
||||||
|
&[
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
&format!("{ref_selector}^{{commit}}"),
|
||||||
|
],
|
||||||
|
"resolve Queue Ticket target",
|
||||||
|
)
|
||||||
|
.map_err(|reason| ticket::TicketError::InvalidTargetSelector {
|
||||||
|
repository_id: repository_id.to_string(),
|
||||||
|
selector: ref_selector.to_string(),
|
||||||
|
reason,
|
||||||
|
})?;
|
||||||
|
Ok(ticket::ResolvedTicketTarget {
|
||||||
|
repository_id: repository_id.to_string(),
|
||||||
|
ref_selector: ref_selector.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct PanelQueueHandoffPreflight {
|
struct PanelQueueHandoffPreflight {
|
||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
root_top_level: PathBuf,
|
root_top_level: PathBuf,
|
||||||
orchestration: OrchestrationWorktreeLayout,
|
orchestration: OrchestrationWorktreeLayout,
|
||||||
ticket_record_dir: PathBuf,
|
queue_tickets: Vec<String>,
|
||||||
|
ticket_record_dirs: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -4393,27 +4484,53 @@ fn prepare_panel_queue_handoff(
|
|||||||
&root_top_level,
|
&root_top_level,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let ticket_record_dir = backend.root().join(ticket_id);
|
let dependency_check = backend
|
||||||
|
.dependency_check(TicketIdOrSlug::Id(ticket_id.to_owned()))
|
||||||
|
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
||||||
|
if !dependency_check.queue_guard.can_queue_for_orchestrator {
|
||||||
|
return Err(queue_check_failed(
|
||||||
|
"dependency-queue-plan",
|
||||||
|
ticket_id,
|
||||||
|
&root_top_level,
|
||||||
|
dependency_check
|
||||||
|
.queue_guard
|
||||||
|
.blocked_reason
|
||||||
|
.or(dependency_check.queue_guard.reason)
|
||||||
|
.unwrap_or_else(|| "Queue dependency validation failed".to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let queue_tickets = dependency_check.queue_tickets;
|
||||||
|
let mut ticket_record_dirs = Vec::with_capacity(queue_tickets.len());
|
||||||
|
for queue_ticket in &queue_tickets {
|
||||||
|
let ticket_record_dir = backend.root().join(queue_ticket);
|
||||||
if !ticket_record_dir.join("item.md").is_file() {
|
if !ticket_record_dir.join("item.md").is_file() {
|
||||||
return Err(queue_check_failed(
|
return Err(queue_check_failed(
|
||||||
"target-ticket-record",
|
"target-ticket-record",
|
||||||
ticket_id,
|
&queue_ticket,
|
||||||
&ticket_record_dir,
|
&ticket_record_dir,
|
||||||
"target Ticket item.md is missing".to_string(),
|
"Queue Ticket item.md is missing".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let clean_stage = if queue_ticket == ticket_id {
|
||||||
|
"root-ticket-clean"
|
||||||
|
} else {
|
||||||
|
"queue-dependency-clean"
|
||||||
|
};
|
||||||
ensure_git_path_clean(
|
ensure_git_path_clean(
|
||||||
"root-ticket-clean",
|
clean_stage,
|
||||||
ticket_id,
|
&queue_ticket,
|
||||||
&root_top_level,
|
&root_top_level,
|
||||||
&ticket_record_dir,
|
&ticket_record_dir,
|
||||||
)?;
|
)?;
|
||||||
|
ticket_record_dirs.push(ticket_record_dir);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(PanelQueueHandoffPreflight {
|
Ok(PanelQueueHandoffPreflight {
|
||||||
ticket_id: ticket_id.to_string(),
|
ticket_id: ticket_id.to_string(),
|
||||||
root_top_level,
|
root_top_level,
|
||||||
orchestration,
|
orchestration,
|
||||||
ticket_record_dir,
|
queue_tickets,
|
||||||
|
ticket_record_dirs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4504,37 +4621,36 @@ fn sync_orchestration_to_root_before_queue(
|
|||||||
fn commit_panel_queue_ticket_record(
|
fn commit_panel_queue_ticket_record(
|
||||||
preflight: &PanelQueueHandoffPreflight,
|
preflight: &PanelQueueHandoffPreflight,
|
||||||
) -> Result<PanelQueueCommit, TicketActionError> {
|
) -> Result<PanelQueueCommit, TicketActionError> {
|
||||||
let ticket_rel = path_relative_to_root(
|
let ticket_rels = preflight
|
||||||
|
.ticket_record_dirs
|
||||||
|
.iter()
|
||||||
|
.map(|ticket_record_dir| {
|
||||||
|
path_relative_to_root(
|
||||||
&preflight.root_top_level,
|
&preflight.root_top_level,
|
||||||
&preflight.ticket_record_dir,
|
ticket_record_dir,
|
||||||
"target-ticket-record",
|
"target-ticket-record",
|
||||||
&preflight.ticket_id,
|
&preflight.ticket_id,
|
||||||
)?;
|
)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
let mut add = Command::new("git");
|
let mut add = Command::new("git");
|
||||||
add.arg("-C")
|
add.arg("-C")
|
||||||
.arg(&preflight.root_top_level)
|
.arg(&preflight.root_top_level)
|
||||||
.arg("add")
|
.arg("add")
|
||||||
.arg("--")
|
.arg("--")
|
||||||
.arg(&ticket_rel);
|
.args(&ticket_rels);
|
||||||
run_git_command(add, "stage Queue Ticket record").map_err(|message| {
|
run_git_command(add, "stage Queue Ticket records").map_err(|message| {
|
||||||
queue_check_failed(
|
queue_check_failed(
|
||||||
"queue-commit-stage",
|
"queue-commit-stage",
|
||||||
&preflight.ticket_id,
|
&preflight.ticket_id,
|
||||||
&preflight.ticket_record_dir,
|
&preflight.root_top_level,
|
||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let ticket_rel_string = git_path_string(&ticket_rel);
|
|
||||||
let staged = git_capture(
|
let staged = git_capture(
|
||||||
&preflight.root_top_level,
|
&preflight.root_top_level,
|
||||||
&[
|
&["diff", "--cached", "--name-only"],
|
||||||
"diff",
|
|
||||||
"--cached",
|
|
||||||
"--name-only",
|
|
||||||
"--",
|
|
||||||
ticket_rel_string.as_str(),
|
|
||||||
],
|
|
||||||
"list staged Queue Ticket files",
|
"list staged Queue Ticket files",
|
||||||
)
|
)
|
||||||
.map_err(|message| {
|
.map_err(|message| {
|
||||||
@@ -4545,19 +4661,31 @@ fn commit_panel_queue_ticket_record(
|
|||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let allowed = ticket_rels
|
||||||
|
.iter()
|
||||||
|
.map(|path| format!("{}/", git_path_string(path).trim_end_matches('/')))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let staged_paths = staged
|
let staged_paths = staged
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| !line.trim().is_empty())
|
.filter(|line| !line.trim().is_empty())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if staged_paths.is_empty() {
|
if staged_paths.is_empty()
|
||||||
|
|| staged_paths
|
||||||
|
.iter()
|
||||||
|
.any(|path| !allowed.iter().any(|root| path.starts_with(root)))
|
||||||
|
{
|
||||||
return Err(queue_check_failed(
|
return Err(queue_check_failed(
|
||||||
"queue-commit-pathscope",
|
"queue-commit-pathscope",
|
||||||
&preflight.ticket_id,
|
&preflight.ticket_id,
|
||||||
&preflight.ticket_record_dir,
|
&preflight.root_top_level,
|
||||||
"Queue mutation produced no staged Ticket record changes".to_string(),
|
"Queue mutation staged no Ticket records or included files outside the confirmed dependency closure"
|
||||||
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let message = format!("ticket: queue {}", preflight.ticket_id);
|
let message = format!(
|
||||||
|
"chore: queue Ticket dependency closure {}",
|
||||||
|
preflight.ticket_id
|
||||||
|
);
|
||||||
let mut commit = Command::new("git");
|
let mut commit = Command::new("git");
|
||||||
commit
|
commit
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
@@ -4567,8 +4695,8 @@ fn commit_panel_queue_ticket_record(
|
|||||||
.arg("-m")
|
.arg("-m")
|
||||||
.arg(message)
|
.arg(message)
|
||||||
.arg("--")
|
.arg("--")
|
||||||
.arg(&ticket_rel);
|
.args(&ticket_rels);
|
||||||
run_git_command(commit, "commit Queue Ticket record").map_err(|message| {
|
run_git_command(commit, "commit Queue Ticket records").map_err(|message| {
|
||||||
queue_check_failed(
|
queue_check_failed(
|
||||||
"queue-commit-create",
|
"queue-commit-create",
|
||||||
&preflight.ticket_id,
|
&preflight.ticket_id,
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|ticket| ticket.blocked_reason.as_deref())
|
.and_then(|ticket| ticket.blocked_reason.as_deref())
|
||||||
{
|
{
|
||||||
parts.push(format!("Gate: waiting for {blocked_reason}"));
|
parts.push(format!("Dependencies: {blocked_reason}"));
|
||||||
} else {
|
} else {
|
||||||
parts.push("Gate: clear".to_string());
|
parts.push("Gate: clear".to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ fn planning_return_request(
|
|||||||
ReadyTicketPlanningReturnRequest {
|
ReadyTicketPlanningReturnRequest {
|
||||||
workspace_root: temp.path().to_path_buf(),
|
workspace_root: temp.path().to_path_buf(),
|
||||||
ticket_id,
|
ticket_id,
|
||||||
|
ticket_key: "T-482".to_string(),
|
||||||
user_instruction: instruction.to_string(),
|
user_instruction: instruction.to_string(),
|
||||||
followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
|
followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
|
||||||
worker_name: "stale-intake".to_string(),
|
worker_name: "stale-intake".to_string(),
|
||||||
@@ -494,6 +495,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(request.ticket_id, "20260608-000123-ready");
|
assert_eq!(request.ticket_id, "20260608-000123-ready");
|
||||||
|
assert_eq!(request.ticket_key, "T-1");
|
||||||
assert_eq!(request.user_instruction, "clarify expected behavior");
|
assert_eq!(request.user_instruction, "clarify expected behavior");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
request.followup,
|
request.followup,
|
||||||
@@ -515,6 +517,7 @@ async fn planning_return_with_launch_followup_changes_state_before_launch_follow
|
|||||||
let request = ReadyTicketPlanningReturnRequest {
|
let request = ReadyTicketPlanningReturnRequest {
|
||||||
workspace_root: temp.path().to_path_buf(),
|
workspace_root: temp.path().to_path_buf(),
|
||||||
ticket_id: ticket_id.clone(),
|
ticket_id: ticket_id.clone(),
|
||||||
|
ticket_key: "T-482".to_string(),
|
||||||
user_instruction: "launch intake after state change".to_string(),
|
user_instruction: "launch intake after state change".to_string(),
|
||||||
followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
|
followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
|
||||||
context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake),
|
context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake),
|
||||||
@@ -1846,24 +1849,23 @@ fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() {
|
fn ready_ticket_with_dependency_context_keeps_queue_action_available() {
|
||||||
let mut row = panel_test_ticket_row(
|
let mut row = panel_test_ticket_row(
|
||||||
"00001WAITING",
|
"00001WAITING",
|
||||||
"Ready but gated",
|
"Ready with dependency context",
|
||||||
ActionPriority::Background,
|
ActionPriority::ReadyForQueue,
|
||||||
NextUserAction::Wait,
|
NextUserAction::Queue,
|
||||||
"ready",
|
"ready",
|
||||||
);
|
);
|
||||||
row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string());
|
|
||||||
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
|
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
|
||||||
|
|
||||||
let lines = panel_row_lines(&row, true, 160);
|
let lines = panel_row_lines(&row, true, 160);
|
||||||
let detail = &lines[1];
|
let detail = &lines[1];
|
||||||
let detail_line = plain_line(&detail);
|
let detail_line = plain_line(&detail);
|
||||||
|
|
||||||
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
|
assert!(detail_line.contains("Dependencies: BLOCKER-1 via depends_on"));
|
||||||
assert!(detail_line.contains("Action: queue disabled"));
|
assert!(detail_line.contains("Action: Queue"));
|
||||||
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1"));
|
assert!(!detail_line.contains("Queue disabled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3426,6 +3428,27 @@ fn ticket_action_error_records_f2_diagnostic_details() {
|
|||||||
assert!(!app.panel_diagnostic_open);
|
assert!(!app.panel_diagnostic_open);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_ticket_refinement_projection_uses_only_canonical_resource_key() {
|
||||||
|
const INTERNAL_ID: &str = "00001KZVNXFNK";
|
||||||
|
let thread = build_ready_ticket_refinement_thread_body("T-482", "Clarify rollback.");
|
||||||
|
let launch = build_ready_ticket_refinement_launch_instruction("T-482", "Clarify rollback.");
|
||||||
|
let notify = build_ready_ticket_refinement_notify("T-482", "Clarify rollback.");
|
||||||
|
|
||||||
|
for projection in [&thread, &launch, ¬ify] {
|
||||||
|
assert!(projection.contains("T-482"));
|
||||||
|
assert!(!projection.contains(INTERNAL_ID));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_handoff_fails_closed_without_canonical_resource_key() {
|
||||||
|
assert_eq!(required_ticket_handoff_key(Some("T-482")), Ok("T-482"));
|
||||||
|
for invalid in [None, Some(""), Some("00001KZVNXFNK"), Some("T-key")] {
|
||||||
|
assert!(required_ticket_handoff_key(invalid).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn plain_line(line: &Line<'_>) -> String {
|
fn plain_line(line: &Line<'_>) -> String {
|
||||||
line.spans
|
line.spans
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -2203,7 +2203,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate() {
|
fn workspace_panel_blocks_ready_ticket_with_planning_relation() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
@@ -2235,12 +2235,7 @@ mod tests {
|
|||||||
assert_eq!(row.kind, PanelRowKind::Ticket);
|
assert_eq!(row.kind, PanelRowKind::Ticket);
|
||||||
assert_eq!(row.next_action, Some(NextUserAction::Wait));
|
assert_eq!(row.next_action, Some(NextUserAction::Wait));
|
||||||
assert_eq!(row.priority, ActionPriority::Background);
|
assert_eq!(row.priority, ActionPriority::Background);
|
||||||
assert!(
|
assert!(row.disabled_reason.is_some());
|
||||||
row.disabled_reason
|
|
||||||
.as_deref()
|
|
||||||
.unwrap()
|
|
||||||
.contains("Queue disabled: waiting for")
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
row.ticket
|
row.ticket
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -2253,7 +2248,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_panel_allows_ready_ticket_when_relation_prerequisite_is_queued() {
|
fn workspace_panel_queues_ready_ticket_when_relation_prerequisite_is_queued() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
@@ -2286,13 +2281,16 @@ mod tests {
|
|||||||
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
||||||
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
|
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
|
||||||
assert!(row.disabled_reason.is_none());
|
assert!(row.disabled_reason.is_none());
|
||||||
assert!(row.ticket.as_ref().unwrap().blocked_reason.is_none());
|
|
||||||
assert!(
|
assert!(
|
||||||
row.key_hint
|
row.ticket
|
||||||
.as_deref()
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("Queue allowed: prerequisites are already queued/in progress")
|
.blocked_reason
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains(&dependency.id)
|
||||||
);
|
);
|
||||||
|
assert!(row.key_hint.as_deref().unwrap().contains("Queue targets:"));
|
||||||
assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id));
|
assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -251,14 +251,8 @@ impl DelegatingWorkdirSession {
|
|||||||
self.ensure_path(path, WorkdirDelegationPermission::Write)
|
self.ensure_path(path, WorkdirDelegationPermission::Write)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_command(&self, starting: bool) -> Result<(), WorkdirError> {
|
fn ensure_command(&self) -> Result<(), WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")?;
|
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")
|
||||||
if starting && self.has_active_write_lease() {
|
|
||||||
return Err(WorkdirError::Denied(
|
|
||||||
"command execution is denied while a child holds a write delegation".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
||||||
@@ -281,20 +275,6 @@ impl DelegatingWorkdirSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_active_write_lease(&self) -> bool {
|
|
||||||
let mut leases = self
|
|
||||||
.child_write_leases
|
|
||||||
.lock()
|
|
||||||
.expect("workdir delegation lease mutex poisoned");
|
|
||||||
leases.retain(|_, lease| lease.validity.upgrade().is_some_and(|v| v.is_active()));
|
|
||||||
leases.values().any(|lease| {
|
|
||||||
lease
|
|
||||||
.rules
|
|
||||||
.iter()
|
|
||||||
.any(|rule| rule.permission == WorkdirDelegationPermission::Write)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_delegation_rules(
|
fn validate_delegation_rules(
|
||||||
&self,
|
&self,
|
||||||
rules: &[WorkdirDelegationRule],
|
rules: &[WorkdirDelegationRule],
|
||||||
@@ -311,7 +291,10 @@ impl DelegatingWorkdirSession {
|
|||||||
if !self.capabilities.supports(WorkdirSessionCapability::Read)
|
if !self.capabilities.supports(WorkdirSessionCapability::Read)
|
||||||
|| (writable
|
|| (writable
|
||||||
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|
||||||
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
|
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)
|
||||||
|
|| !self
|
||||||
|
.capabilities
|
||||||
|
.supports(WorkdirSessionCapability::Command)))
|
||||||
{
|
{
|
||||||
return Err(WorkdirError::Denied(
|
return Err(WorkdirError::Denied(
|
||||||
"parent workdir session cannot delegate the requested capabilities".into(),
|
"parent workdir session cannot delegate the requested capabilities".into(),
|
||||||
@@ -342,6 +325,7 @@ impl DelegatingWorkdirSession {
|
|||||||
if writable {
|
if writable {
|
||||||
delegated.push(WorkdirSessionCapability::Write);
|
delegated.push(WorkdirSessionCapability::Write);
|
||||||
delegated.push(WorkdirSessionCapability::Edit);
|
delegated.push(WorkdirSessionCapability::Edit);
|
||||||
|
delegated.push(WorkdirSessionCapability::Command);
|
||||||
}
|
}
|
||||||
Ok(WorkdirSessionCapabilities::from_capabilities(delegated))
|
Ok(WorkdirSessionCapabilities::from_capabilities(delegated))
|
||||||
}
|
}
|
||||||
@@ -499,12 +483,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||||
self.ensure_command(true)?;
|
self.ensure_command()?;
|
||||||
self.source.start_command(request).await
|
self.source.start_command(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||||
self.ensure_command(false)?;
|
self.ensure_command()?;
|
||||||
self.source.command_status(handle).await
|
self.source.command_status(handle).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,12 +496,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
|||||||
&self,
|
&self,
|
||||||
request: CommandOutputRequest,
|
request: CommandOutputRequest,
|
||||||
) -> Result<CommandOutput, WorkdirError> {
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
self.ensure_command(false)?;
|
self.ensure_command()?;
|
||||||
self.source.command_output(request).await
|
self.source.command_output(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||||
self.ensure_command(false)?;
|
self.ensure_command()?;
|
||||||
self.source.cancel_command(handle).await
|
self.source.cancel_command(handle).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,6 +733,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_command(
|
||||||
|
session: &WorkdirSessionHandle,
|
||||||
|
command: impl Into<String>,
|
||||||
|
tool_call_id: impl Into<String>,
|
||||||
|
) -> CommandOutput {
|
||||||
|
let handle = session
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: command.into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some(tool_call_id.into()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
session
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1024,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delegation_capable_session_forwards_command_telemetry() {
|
async fn delegation_capable_session_forwards_command_telemetry() {
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
@@ -842,6 +851,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(child.scoped_session.subscribe_command_events().is_none());
|
assert!(child.scoped_session.subscribe_command_events().is_none());
|
||||||
assert!(child.scoped_session.command_snapshot().is_empty());
|
assert!(child.scoped_session.command_snapshot().is_empty());
|
||||||
|
assert!(matches!(
|
||||||
|
child
|
||||||
|
.scoped_session
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: "printf denied".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some("read-only-command".into()),
|
||||||
|
})
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::Denied(_))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -920,7 +941,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn write_lease_blocks_parent_region_until_release() {
|
async fn write_lease_keeps_typed_parent_writes_exclusive_without_blocking_commands() {
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
fs::create_dir_all(root.path().join("leased")).unwrap();
|
fs::create_dir_all(root.path().join("leased")).unwrap();
|
||||||
fs::create_dir_all(root.path().join("other")).unwrap();
|
fs::create_dir_all(root.path().join("other")).unwrap();
|
||||||
@@ -929,6 +950,30 @@ mod tests {
|
|||||||
.delegate(request("leased", WorkdirDelegationPermission::Write))
|
.delegate(request("leased", WorkdirDelegationPermission::Write))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
child
|
||||||
|
.capabilities
|
||||||
|
.supports(WorkdirSessionCapability::Command)
|
||||||
|
);
|
||||||
|
let child_output = run_command(
|
||||||
|
&child.scoped_session,
|
||||||
|
"printf child-command",
|
||||||
|
"delegated-child-command",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(child_output.content, "child-command");
|
||||||
|
let parent_output = run_command(
|
||||||
|
&parent,
|
||||||
|
"printf parent-write > leased/from-command; printf parent-command",
|
||||||
|
"parent-command-during-child-write",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(parent_output.status, CommandStatus::Completed);
|
||||||
|
assert_eq!(parent_output.content, "parent-command");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(root.path().join("leased/from-command")).unwrap(),
|
||||||
|
"parent-write"
|
||||||
|
);
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parent.write(write("leased/file", "parent")).await,
|
parent.write(write("leased/file", "parent")).await,
|
||||||
@@ -941,6 +986,18 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
child.release();
|
child.release();
|
||||||
|
assert!(matches!(
|
||||||
|
child
|
||||||
|
.scoped_session
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: "printf revoked".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some("revoked-child-command".into()),
|
||||||
|
})
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::SessionClosed)
|
||||||
|
));
|
||||||
parent
|
parent
|
||||||
.write(write("leased/parent", "parent"))
|
.write(write("leased/parent", "parent"))
|
||||||
.await
|
.await
|
||||||
@@ -992,6 +1049,78 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn nested_write_leases_do_not_block_command_capable_ancestors() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("docs/sub")).unwrap();
|
||||||
|
let root_session = session(root.path());
|
||||||
|
let child = root_session
|
||||||
|
.delegate(request("docs", WorkdirDelegationPermission::Write))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let nested = child
|
||||||
|
.scoped_session
|
||||||
|
.delegate(request("docs/sub", WorkdirDelegationPermission::Write))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for (session, label) in [
|
||||||
|
(&root_session, "root"),
|
||||||
|
(&child.scoped_session, "child"),
|
||||||
|
(&nested.scoped_session, "nested"),
|
||||||
|
] {
|
||||||
|
let output = run_command(
|
||||||
|
session,
|
||||||
|
format!("printf {label}"),
|
||||||
|
format!("{label}-command-during-nested-write"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(output.status, CommandStatus::Completed);
|
||||||
|
assert_eq!(output.content, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
root_session.write(write("docs/root", "blocked")).await,
|
||||||
|
Err(WorkdirError::Denied(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
child
|
||||||
|
.scoped_session
|
||||||
|
.write(write("sub/child", "blocked"))
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::Denied(_))
|
||||||
|
));
|
||||||
|
nested
|
||||||
|
.scoped_session
|
||||||
|
.write(write("nested", "allowed"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
nested.release();
|
||||||
|
child.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reapplied_write_delegation_chain_forwards_command_lifecycle() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("delegated")).unwrap();
|
||||||
|
let applied = apply_delegation_chain(
|
||||||
|
session(root.path()),
|
||||||
|
[request("delegated", WorkdirDelegationPermission::Write)],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = run_command(
|
||||||
|
&applied.scoped_session,
|
||||||
|
"printf reapplied",
|
||||||
|
"reapplied-command",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(output.status, CommandStatus::Completed);
|
||||||
|
assert_eq!(output.content, "reapplied");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn applied_chain_cannot_replace_outer_provider_attenuation() {
|
async fn applied_chain_cannot_replace_outer_provider_attenuation() {
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
@@ -1036,6 +1165,17 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
parent.close().await.unwrap();
|
parent.close().await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
parent
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: "printf closed".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some("closed-parent-command".into()),
|
||||||
|
})
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::SessionClosed)
|
||||||
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
child.scoped_session.read(read("a")).await,
|
child.scoped_session.read(read("a")).await,
|
||||||
Err(WorkdirError::SessionClosed)
|
Err(WorkdirError::SessionClosed)
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ pub use fs_operation::{
|
|||||||
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
|
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
|
||||||
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
pub use local::{LocalWorkdirSession, SymlinkInfo, direct_symlink, first_symlink};
|
pub use local::{
|
||||||
|
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
|
||||||
|
};
|
||||||
pub use operation::*;
|
pub use operation::*;
|
||||||
|
|
||||||
/// Persistent, opaque identity of one materialized Workdir.
|
/// Persistent, opaque identity of one materialized Workdir.
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
//! `LocalWorkdirSession` is cheap to clone (`Arc` inside). Tool-specific session
|
//! `LocalWorkdirSession` is cheap to clone (`Arc` inside). Tool-specific session
|
||||||
//! state, such as read-before-edit tracking, remains owned by the tool layer.
|
//! state, such as read-before-edit tracking, remains owned by the tool layer.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::fmt::Debug;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
use std::io::{Read as _, Seek as _, SeekFrom};
|
use std::io::{Read as _, Seek as _, SeekFrom};
|
||||||
@@ -228,6 +229,8 @@ struct LocalWorkdirSessionInner {
|
|||||||
next_command_id: AtomicU64,
|
next_command_id: AtomicU64,
|
||||||
commands: Mutex<HashMap<String, LocalCommand>>,
|
commands: Mutex<HashMap<String, LocalCommand>>,
|
||||||
command_telemetry: CommandTelemetry,
|
command_telemetry: CommandTelemetry,
|
||||||
|
command_environment: BTreeMap<String, String>,
|
||||||
|
resources: StdMutex<Vec<Arc<dyn WorkdirSessionResource>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for LocalWorkdirSessionInner {
|
impl Drop for LocalWorkdirSessionInner {
|
||||||
@@ -242,6 +245,9 @@ impl Drop for LocalWorkdirSessionInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait WorkdirSessionResource: Debug + Send + Sync {}
|
||||||
|
impl<T> WorkdirSessionResource for T where T: Debug + Send + Sync {}
|
||||||
|
|
||||||
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
||||||
///
|
///
|
||||||
/// The wrapped [`SharedScope`] is shared with every clone of this
|
/// The wrapped [`SharedScope`] is shared with every clone of this
|
||||||
@@ -318,6 +324,26 @@ impl LocalWorkdirSession {
|
|||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
scope: SharedScope,
|
scope: SharedScope,
|
||||||
capabilities: WorkdirSessionCapabilities,
|
capabilities: WorkdirSessionCapabilities,
|
||||||
|
) -> Self {
|
||||||
|
Self::materialized_bound_with_environment(
|
||||||
|
workdir,
|
||||||
|
root,
|
||||||
|
cwd,
|
||||||
|
scope,
|
||||||
|
capabilities,
|
||||||
|
BTreeMap::new(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn materialized_bound_with_environment(
|
||||||
|
workdir: Workdir,
|
||||||
|
root: PathBuf,
|
||||||
|
cwd: PathBuf,
|
||||||
|
scope: SharedScope,
|
||||||
|
capabilities: WorkdirSessionCapabilities,
|
||||||
|
command_environment: BTreeMap<String, String>,
|
||||||
|
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(LocalWorkdirSessionInner {
|
inner: Arc::new(LocalWorkdirSessionInner {
|
||||||
@@ -331,6 +357,8 @@ impl LocalWorkdirSession {
|
|||||||
next_command_id: AtomicU64::new(1),
|
next_command_id: AtomicU64::new(1),
|
||||||
commands: Mutex::new(HashMap::new()),
|
commands: Mutex::new(HashMap::new()),
|
||||||
command_telemetry: CommandTelemetry::new(),
|
command_telemetry: CommandTelemetry::new(),
|
||||||
|
command_environment,
|
||||||
|
resources: StdMutex::new(resources),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -669,9 +697,18 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
let (completion_tx, completion) = watch::channel(false);
|
let (completion_tx, completion) = watch::channel(false);
|
||||||
let command_id = handle.0.clone();
|
let command_id = handle.0.clone();
|
||||||
let telemetry = self.inner.command_telemetry.clone();
|
let telemetry = self.inner.command_telemetry.clone();
|
||||||
|
let command_environment = self.inner.command_environment.clone();
|
||||||
let (cancel, cancel_rx) = watch::channel(false);
|
let (cancel, cancel_rx) = watch::channel(false);
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
let output = run_command(cwd, request, command_id, telemetry, cancel_rx).await;
|
let output = run_command(
|
||||||
|
cwd,
|
||||||
|
request,
|
||||||
|
command_id,
|
||||||
|
telemetry,
|
||||||
|
command_environment,
|
||||||
|
cancel_rx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let _ = completion_tx.send(true);
|
let _ = completion_tx.send(true);
|
||||||
output
|
output
|
||||||
});
|
});
|
||||||
@@ -840,6 +877,9 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
LocalCommand::Completed(_) => {}
|
LocalCommand::Completed(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Ok(mut resources) = self.inner.resources.lock() {
|
||||||
|
resources.clear();
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,6 +949,7 @@ async fn run_command(
|
|||||||
request: CommandRequest,
|
request: CommandRequest,
|
||||||
command_id: String,
|
command_id: String,
|
||||||
telemetry: CommandTelemetry,
|
telemetry: CommandTelemetry,
|
||||||
|
command_environment: BTreeMap<String, String>,
|
||||||
mut cancel: watch::Receiver<bool>,
|
mut cancel: watch::Receiver<bool>,
|
||||||
) -> Result<CommandOutput, WorkdirError> {
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
|
let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
|
||||||
@@ -925,6 +966,7 @@ async fn run_command(
|
|||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(&request.command)
|
.arg(&request.command)
|
||||||
.current_dir(&cwd)
|
.current_dir(&cwd)
|
||||||
|
.envs(command_environment)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::from(stdout_file))
|
.stdout(Stdio::from(stdout_file))
|
||||||
.stderr(Stdio::from(stderr_file))
|
.stderr(Stdio::from(stderr_file))
|
||||||
@@ -2319,6 +2361,31 @@ mod tests {
|
|||||||
assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None)));
|
assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn closing_session_releases_runtime_resources() {
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Resource(Arc<AtomicBool>);
|
||||||
|
impl Drop for Resource {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let released = Arc::new(AtomicBool::new(false));
|
||||||
|
let session = LocalWorkdirSession::materialized_bound_with_environment(
|
||||||
|
Workdir::new("resource-session"),
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
SharedScope::new(Scope::writable(dir.path()).unwrap()),
|
||||||
|
WorkdirSessionCapabilities::ALL,
|
||||||
|
BTreeMap::from([("SSH_AUTH_SOCK".to_string(), "test-socket".to_string())]),
|
||||||
|
vec![Arc::new(Resource(released.clone()))],
|
||||||
|
);
|
||||||
|
WorkdirSession::close(&session).await.unwrap();
|
||||||
|
assert!(released.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn provider_cancels_active_command() {
|
async fn provider_cancels_active_command() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ impl RuntimeWorkerRef {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum MaterializerKind {
|
pub enum MaterializerKind {
|
||||||
#[default]
|
#[default]
|
||||||
|
RuntimeGitCache,
|
||||||
|
/// Legacy persisted value from the pre-cache local `git worktree` materializer.
|
||||||
LocalGitWorktree,
|
LocalGitWorktree,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +111,8 @@ pub struct WorkingDirectoryProvenance {
|
|||||||
pub creation_selector: Option<String>,
|
pub creation_selector: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub creation_ref: Option<String>,
|
pub creation_ref: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub creation_tree: Option<String>,
|
||||||
pub materializer_kind: MaterializerKind,
|
pub materializer_kind: MaterializerKind,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||||
@@ -122,6 +126,10 @@ pub struct WorkingDirectoryCurrentObservation {
|
|||||||
pub current_selector: Option<String>,
|
pub current_selector: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub current_ref: Option<String>,
|
pub current_ref: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_tree: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub observed_at_epoch_seconds: Option<u64>,
|
||||||
pub status: WorkingDirectoryStatusKind,
|
pub status: WorkingDirectoryStatusKind,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cleanliness: Option<String>,
|
pub cleanliness: Option<String>,
|
||||||
@@ -141,9 +149,15 @@ pub struct WorkingDirectorySummary {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub creation_ref: Option<String>,
|
pub creation_ref: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub creation_tree: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub current_selector: Option<String>,
|
pub current_selector: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub current_ref: Option<String>,
|
pub current_ref: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_tree: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub observed_at_epoch_seconds: Option<u64>,
|
||||||
pub materializer_kind: MaterializerKind,
|
pub materializer_kind: MaterializerKind,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||||
@@ -166,6 +180,7 @@ impl WorkingDirectorySummary {
|
|||||||
WorkingDirectoryProvenance {
|
WorkingDirectoryProvenance {
|
||||||
creation_selector: self.creation_selector.clone(),
|
creation_selector: self.creation_selector.clone(),
|
||||||
creation_ref: self.creation_ref.clone(),
|
creation_ref: self.creation_ref.clone(),
|
||||||
|
creation_tree: self.creation_tree.clone(),
|
||||||
materializer_kind: self.materializer_kind.clone(),
|
materializer_kind: self.materializer_kind.clone(),
|
||||||
cleanup_target: self.cleanup_target.clone(),
|
cleanup_target: self.cleanup_target.clone(),
|
||||||
}
|
}
|
||||||
@@ -175,6 +190,8 @@ impl WorkingDirectorySummary {
|
|||||||
WorkingDirectoryCurrentObservation {
|
WorkingDirectoryCurrentObservation {
|
||||||
current_selector: self.current_selector.clone(),
|
current_selector: self.current_selector.clone(),
|
||||||
current_ref: self.current_ref.clone(),
|
current_ref: self.current_ref.clone(),
|
||||||
|
current_tree: self.current_tree.clone(),
|
||||||
|
observed_at_epoch_seconds: self.observed_at_epoch_seconds,
|
||||||
status: self.status.clone(),
|
status: self.status.clone(),
|
||||||
cleanliness: self.cleanliness.clone(),
|
cleanliness: self.cleanliness.clone(),
|
||||||
primary_worker_id: self.primary_worker_id.clone(),
|
primary_worker_id: self.primary_worker_id.clone(),
|
||||||
@@ -211,6 +228,7 @@ pub struct WorkingDirectoryListResponse {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkingDirectoryDetailResponse {
|
pub struct WorkingDirectoryDetailResponse {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
pub item: WorkingDirectorySummary,
|
pub item: WorkingDirectorySummary,
|
||||||
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
|
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
|
||||||
}
|
}
|
||||||
@@ -246,8 +264,11 @@ mod tests {
|
|||||||
repository_id: "repo".to_string(),
|
repository_id: "repo".to_string(),
|
||||||
creation_selector: Some("develop".to_string()),
|
creation_selector: Some("develop".to_string()),
|
||||||
creation_ref: Some("abc123".to_string()),
|
creation_ref: Some("abc123".to_string()),
|
||||||
|
creation_tree: Some("tree123".to_string()),
|
||||||
current_selector: Some("work/ticket".to_string()),
|
current_selector: Some("work/ticket".to_string()),
|
||||||
current_ref: Some("def456".to_string()),
|
current_ref: Some("def456".to_string()),
|
||||||
|
current_tree: Some("tree456".to_string()),
|
||||||
|
observed_at_epoch_seconds: Some(1_777_777_777),
|
||||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||||
cleanup_target: Some(WorkingDirectoryCleanupTarget {
|
cleanup_target: Some(WorkingDirectoryCleanupTarget {
|
||||||
kind: "git_worktree".to_string(),
|
kind: "git_worktree".to_string(),
|
||||||
@@ -268,8 +289,11 @@ mod tests {
|
|||||||
repository_id: "repo".to_string(),
|
repository_id: "repo".to_string(),
|
||||||
creation_selector: None,
|
creation_selector: None,
|
||||||
creation_ref: None,
|
creation_ref: None,
|
||||||
|
creation_tree: None,
|
||||||
current_selector: None,
|
current_selector: None,
|
||||||
current_ref: Some("987fed".to_string()),
|
current_ref: Some("987fed".to_string()),
|
||||||
|
current_tree: None,
|
||||||
|
observed_at_epoch_seconds: None,
|
||||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||||
cleanup_target: None,
|
cleanup_target: None,
|
||||||
status: WorkingDirectoryStatusKind::Active,
|
status: WorkingDirectoryStatusKind::Active,
|
||||||
@@ -306,6 +330,7 @@ mod tests {
|
|||||||
|
|
||||||
let detail = WorkingDirectoryDetailResponse {
|
let detail = WorkingDirectoryDetailResponse {
|
||||||
workspace_id: decoded.workspace_id.clone(),
|
workspace_id: decoded.workspace_id.clone(),
|
||||||
|
runtime_id: "arcadia".to_string(),
|
||||||
item: decoded.items[0].clone(),
|
item: decoded.items[0].clone(),
|
||||||
diagnostics: decoded.diagnostics.clone(),
|
diagnostics: decoded.diagnostics.clone(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,9 +41,12 @@ tar.workspace = true
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
url.workspace = true
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
|
zeroize.workspace = true
|
||||||
tower = { workspace = true, features = ["util"], optional = true }
|
tower = { workspace = true, features = ["util"], optional = true }
|
||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
|
workspace-api = { path = "../workspace-api" }
|
||||||
workdir.workspace = true
|
workdir.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|||||||
use ring::rand::{SecureRandom, SystemRandom};
|
use ring::rand::{SecureRandom, SystemRandom};
|
||||||
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -14,6 +15,11 @@ pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-pro
|
|||||||
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
|
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
|
||||||
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
|
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
|
||||||
pub const WORKER_REMOVE_PERMISSION: &str = "workspace:worker-remove";
|
pub const WORKER_REMOVE_PERMISSION: &str = "workspace:worker-remove";
|
||||||
|
pub const RUNTIME_REQUEST_SOURCE_PROOF_HEADER: &str = "x-yoi-runtime-request-proof";
|
||||||
|
pub const WORKSPACE_REQUEST_PERMISSION: &str = "workspace:request";
|
||||||
|
pub const BACKEND_RESOURCE_FETCH_PERMISSION: &str = "workspace:resource-fetch";
|
||||||
|
const RUNTIME_REQUEST_SOURCE_PROOF_PREFIX: &str = "yoi-runtime-request-v1";
|
||||||
|
const RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-runtime-request-v1.";
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RuntimeAuthError {
|
pub enum RuntimeAuthError {
|
||||||
@@ -33,6 +39,10 @@ pub enum RuntimeAuthError {
|
|||||||
InvalidTokenFormat,
|
InvalidTokenFormat,
|
||||||
#[error("malformed capability token claims: {0}")]
|
#[error("malformed capability token claims: {0}")]
|
||||||
MalformedClaims(#[from] serde_json::Error),
|
MalformedClaims(#[from] serde_json::Error),
|
||||||
|
#[error("runtime request proof contains an invalid `{0}` claim")]
|
||||||
|
InvalidClaim(&'static str),
|
||||||
|
#[error("runtime request proof does not match the HTTP request")]
|
||||||
|
ClaimMismatch,
|
||||||
#[error("unknown token issuer `{0}`")]
|
#[error("unknown token issuer `{0}`")]
|
||||||
UnknownIssuer(String),
|
UnknownIssuer(String),
|
||||||
#[error("invalid token signature")]
|
#[error("invalid token signature")]
|
||||||
@@ -224,6 +234,162 @@ pub fn verify_capability_token(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeRequestSourceClaims {
|
||||||
|
pub iss: String,
|
||||||
|
pub aud: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worker_id: Option<String>,
|
||||||
|
pub permission: String,
|
||||||
|
pub method: String,
|
||||||
|
pub path: String,
|
||||||
|
pub body_digest: String,
|
||||||
|
pub iat: i64,
|
||||||
|
pub exp: i64,
|
||||||
|
pub jti: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeRequestSourceSigner {
|
||||||
|
identity_id: String,
|
||||||
|
private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeRequestSourceExpectation<'a> {
|
||||||
|
pub identity_id: &'a str,
|
||||||
|
pub audience: &'a str,
|
||||||
|
pub workspace_id: &'a str,
|
||||||
|
pub worker_id: Option<&'a str>,
|
||||||
|
pub permission: &'a str,
|
||||||
|
pub method: &'a str,
|
||||||
|
pub path: &'a str,
|
||||||
|
pub body_digest: &'a str,
|
||||||
|
pub now_unix: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_body_digest(body: &[u8]) -> String {
|
||||||
|
URL_SAFE_NO_PAD.encode(Sha256::digest(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeRequestSourceSigner {
|
||||||
|
pub fn from_identity(identity: &RuntimeIdentityMaterial) -> Self {
|
||||||
|
Self {
|
||||||
|
identity_id: identity.identity_id.clone(),
|
||||||
|
private_key: identity.private_key.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn issue(
|
||||||
|
&self,
|
||||||
|
audience: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: Option<&str>,
|
||||||
|
permission: &str,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: &[u8],
|
||||||
|
now_unix: i64,
|
||||||
|
ttl_seconds: u64,
|
||||||
|
) -> Result<String, RuntimeAuthError> {
|
||||||
|
for (name, value) in [
|
||||||
|
("audience", audience),
|
||||||
|
("workspace_id", workspace_id),
|
||||||
|
("permission", permission),
|
||||||
|
("method", method),
|
||||||
|
("path", path),
|
||||||
|
] {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(RuntimeAuthError::InvalidClaim(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if worker_id.is_some_and(str::is_empty) {
|
||||||
|
return Err(RuntimeAuthError::InvalidClaim("worker_id"));
|
||||||
|
}
|
||||||
|
let ttl_seconds = i64::try_from(ttl_seconds).unwrap_or(i64::MAX);
|
||||||
|
let claims = RuntimeRequestSourceClaims {
|
||||||
|
iss: self.identity_id.clone(),
|
||||||
|
aud: audience.to_owned(),
|
||||||
|
workspace_id: workspace_id.to_owned(),
|
||||||
|
worker_id: worker_id.map(str::to_owned),
|
||||||
|
permission: permission.to_owned(),
|
||||||
|
method: method.to_owned(),
|
||||||
|
path: path.to_owned(),
|
||||||
|
body_digest: request_body_digest(body),
|
||||||
|
iat: now_unix,
|
||||||
|
exp: now_unix.saturating_add(ttl_seconds),
|
||||||
|
jti: new_token_id()?,
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_vec(&claims)?;
|
||||||
|
let payload = URL_SAFE_NO_PAD.encode(payload);
|
||||||
|
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
|
||||||
|
let private = decode_private_key(&self.private_key)?;
|
||||||
|
let key_pair = Ed25519KeyPair::from_pkcs8(&private)
|
||||||
|
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
||||||
|
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref());
|
||||||
|
Ok(format!(
|
||||||
|
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_runtime_request_source_claims(
|
||||||
|
proof: &str,
|
||||||
|
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
|
||||||
|
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?;
|
||||||
|
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
|
||||||
|
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||||
|
}
|
||||||
|
let payload = URL_SAFE_NO_PAD.decode(payload)?;
|
||||||
|
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_runtime_request_source(
|
||||||
|
proof: &str,
|
||||||
|
public_key: &str,
|
||||||
|
expected: &RuntimeRequestSourceExpectation<'_>,
|
||||||
|
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
|
||||||
|
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?;
|
||||||
|
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
|
||||||
|
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||||
|
}
|
||||||
|
let signature = URL_SAFE_NO_PAD.decode(signature)?;
|
||||||
|
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
|
||||||
|
let public_key = decode_public_key(public_key)?;
|
||||||
|
UnparsedPublicKey::new(&ED25519, public_key)
|
||||||
|
.verify(signing_input.as_bytes(), &signature)
|
||||||
|
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
|
||||||
|
let claims = decode_runtime_request_source_claims(proof)?;
|
||||||
|
if claims.iss != expected.identity_id
|
||||||
|
|| claims.aud != expected.audience
|
||||||
|
|| claims.workspace_id != expected.workspace_id
|
||||||
|
|| claims.worker_id.as_deref() != expected.worker_id
|
||||||
|
|| claims.permission != expected.permission
|
||||||
|
|| claims.method != expected.method
|
||||||
|
|| claims.path != expected.path
|
||||||
|
|| claims.body_digest != expected.body_digest
|
||||||
|
{
|
||||||
|
return Err(RuntimeAuthError::ClaimMismatch);
|
||||||
|
}
|
||||||
|
if claims.iat > expected.now_unix || claims.exp < expected.now_unix {
|
||||||
|
return Err(RuntimeAuthError::Expired);
|
||||||
|
}
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
|
||||||
|
let mut parts = proof.split('.');
|
||||||
|
let prefix = parts.next().unwrap_or_default();
|
||||||
|
let payload = parts.next().unwrap_or_default();
|
||||||
|
let signature = parts.next().unwrap_or_default();
|
||||||
|
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
|
||||||
|
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||||
|
}
|
||||||
|
Ok((prefix, payload, signature))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerMutationSourceClaims {
|
pub struct WorkerMutationSourceClaims {
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
@@ -592,6 +758,99 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_request_source_proof_binds_request_and_rejects_spoofed_signature() {
|
||||||
|
let trusted = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||||
|
let signer = RuntimeRequestSourceSigner::from_identity(&trusted);
|
||||||
|
let body = br#"{"ticket":"T-1"}"#;
|
||||||
|
let proof = signer
|
||||||
|
.issue(
|
||||||
|
"server-main",
|
||||||
|
"workspace-a",
|
||||||
|
Some("worker-7"),
|
||||||
|
WORKSPACE_REQUEST_PERMISSION,
|
||||||
|
"POST",
|
||||||
|
"/api/w/workspace-a/tickets/comment",
|
||||||
|
body,
|
||||||
|
90,
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let expected = RuntimeRequestSourceExpectation {
|
||||||
|
identity_id: "runtime-main",
|
||||||
|
audience: "server-main",
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
worker_id: Some("worker-7"),
|
||||||
|
permission: WORKSPACE_REQUEST_PERMISSION,
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/w/workspace-a/tickets/comment",
|
||||||
|
body_digest: &request_body_digest(body),
|
||||||
|
now_unix: 99,
|
||||||
|
};
|
||||||
|
let claims = verify_runtime_request_source(&proof, &trusted.public_key, &expected).unwrap();
|
||||||
|
assert_eq!(claims.iss, "runtime-main");
|
||||||
|
let changed_body = RuntimeRequestSourceExpectation {
|
||||||
|
body_digest: &request_body_digest(br#"{"ticket":"T-2"}"#),
|
||||||
|
..expected.clone()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
verify_runtime_request_source(&proof, &trusted.public_key, &changed_body),
|
||||||
|
Err(RuntimeAuthError::ClaimMismatch)
|
||||||
|
));
|
||||||
|
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
verify_runtime_request_source(&proof, &spoofed.public_key, &expected),
|
||||||
|
Err(RuntimeAuthError::InvalidSignature)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() {
|
||||||
|
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||||
|
let proof = RuntimeRequestSourceSigner::from_identity(&runtime)
|
||||||
|
.issue(
|
||||||
|
"server-main",
|
||||||
|
"workspace-a",
|
||||||
|
None,
|
||||||
|
BACKEND_RESOURCE_FETCH_PERMISSION,
|
||||||
|
"POST",
|
||||||
|
"/api/runtime/v1/workspaces/workspace-a/resources/fetch",
|
||||||
|
b"{}",
|
||||||
|
90,
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let digest = request_body_digest(b"{}");
|
||||||
|
let expected = RuntimeRequestSourceExpectation {
|
||||||
|
identity_id: "runtime-main",
|
||||||
|
audience: "server-main",
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
worker_id: None,
|
||||||
|
permission: BACKEND_RESOURCE_FETCH_PERMISSION,
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/runtime/v1/workspaces/workspace-a/resources/fetch",
|
||||||
|
body_digest: &digest,
|
||||||
|
now_unix: 99,
|
||||||
|
};
|
||||||
|
assert!(verify_runtime_request_source(&proof, &runtime.public_key, &expected).is_ok());
|
||||||
|
let wrong_workspace = RuntimeRequestSourceExpectation {
|
||||||
|
workspace_id: "workspace-b",
|
||||||
|
..expected.clone()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace),
|
||||||
|
Err(RuntimeAuthError::ClaimMismatch)
|
||||||
|
));
|
||||||
|
let expired = RuntimeRequestSourceExpectation {
|
||||||
|
now_unix: 101,
|
||||||
|
..expected
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
verify_runtime_request_source(&proof, &runtime.public_key, &expired),
|
||||||
|
Err(RuntimeAuthError::Expired)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capability_token_verifies_signature_audience_expiry_and_permission() {
|
fn capability_token_verifies_signature_audience_expiry_and_permission() {
|
||||||
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
|
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use crate::identity::{RuntimeWorkerRef, WorkerId, WorkerRef};
|
|||||||
use crate::interaction::WorkerInput;
|
use crate::interaction::WorkerInput;
|
||||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
|
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn is_false(value: &bool) -> bool {
|
fn is_false(value: &bool) -> bool {
|
||||||
!*value
|
!*value
|
||||||
@@ -85,9 +84,9 @@ impl std::ops::Deref for RepositorySelector {
|
|||||||
pub struct WorkingDirectoryRepository {
|
pub struct WorkingDirectoryRepository {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub uri: String,
|
pub source: workspace_api::RepositorySource,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
pub source_revision: u64,
|
||||||
pub local_path: Option<PathBuf>,
|
pub source_fingerprint: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub selector: Option<RepositorySelector>,
|
pub selector: Option<RepositorySelector>,
|
||||||
}
|
}
|
||||||
@@ -98,6 +97,74 @@ pub use workdir::workspace::{
|
|||||||
WorkingDirectorySummary,
|
WorkingDirectorySummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SensitiveString(String);
|
||||||
|
|
||||||
|
impl SensitiveString {
|
||||||
|
pub fn new(value: impl Into<String>) -> Self {
|
||||||
|
Self(value.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expose(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SensitiveString {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
zeroize::Zeroize::zeroize(&mut self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SensitiveString {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SensitiveString {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str("[REDACTED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RepositorySshMaterializationAccess {
|
||||||
|
pub credential_id: String,
|
||||||
|
pub credential_revision: u64,
|
||||||
|
pub host_trust_id: String,
|
||||||
|
pub host_trust_revision: u64,
|
||||||
|
pub access: workspace_api::RepositoryAccessMode,
|
||||||
|
pub expires_at_epoch_seconds: u64,
|
||||||
|
pub repository_id: String,
|
||||||
|
pub repository_source_fingerprint: String,
|
||||||
|
pub repository_uri: String,
|
||||||
|
pub secret_resource: crate::resource::BackendResourceHandle,
|
||||||
|
#[serde(skip, default)]
|
||||||
|
pub private_key: SensitiveString,
|
||||||
|
#[serde(skip, default)]
|
||||||
|
pub known_hosts_entry: SensitiveString,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RepositoryMaterializationContext {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub operation_id: String,
|
||||||
|
pub config_revision: u64,
|
||||||
|
pub config_projection_digest: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_generation: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WorkingDirectoryRepositoryAccessRequest {
|
||||||
|
pub working_directory_id: String,
|
||||||
|
pub materialization: RepositoryMaterializationContext,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkingDirectoryRequest {
|
pub struct WorkingDirectoryRequest {
|
||||||
pub repository: WorkingDirectoryRepository,
|
pub repository: WorkingDirectoryRepository,
|
||||||
@@ -107,6 +174,9 @@ pub struct WorkingDirectoryRequest {
|
|||||||
/// Backend can create canonical registry rows before materialization.
|
/// Backend can create canonical registry rows before materialization.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub backend_workdir_id: Option<String>,
|
pub backend_workdir_id: Option<String>,
|
||||||
|
/// Backend-authored, operation-scoped repository access and cache identity.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub materialization: Option<RepositoryMaterializationContext>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus};
|
use crate::catalog::{
|
||||||
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
|
};
|
||||||
use crate::config_bundle::ConfigBundle;
|
use crate::config_bundle::ConfigBundle;
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
@@ -319,6 +321,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
_request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_repository_access_unsupported",
|
||||||
|
"Worker execution backend does not support Repository access authorization",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -454,6 +466,14 @@ impl WorkerExecutionBackendRef {
|
|||||||
self.backend.create_working_directory(request)
|
self.backend.create_working_directory(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
self.backend
|
||||||
|
.authorize_working_directory_repository_access(request)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
self.backend.list_working_directories()
|
self.backend.list_working_directories()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use crate::auth::{
|
|||||||
};
|
};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
||||||
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
|
WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
@@ -203,6 +204,10 @@ fn runtime_http_router_with_optional_auth(
|
|||||||
"/v1/working-directories",
|
"/v1/working-directories",
|
||||||
get(list_working_directories).post(create_working_directory),
|
get(list_working_directories).post(create_working_directory),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/working-directories/repository-access",
|
||||||
|
post(authorize_working_directory_repository_access),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/v1/working-directories/{working_directory_id}/sessions",
|
"/v1/working-directories/{working_directory_id}/sessions",
|
||||||
post(open_workdir_session),
|
post(open_workdir_session),
|
||||||
@@ -335,6 +340,11 @@ pub struct RuntimeHttpWorkingDirectoriesResponse {
|
|||||||
pub working_directories: Vec<WorkingDirectoryStatus>,
|
pub working_directories: Vec<WorkingDirectoryStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeHttpRepositoryAccessResponse {
|
||||||
|
pub authorized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Working directory response used by create/detail/delete endpoints.
|
/// Working directory response used by create/detail/delete endpoints.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeHttpWorkingDirectoryResponse {
|
pub struct RuntimeHttpWorkingDirectoryResponse {
|
||||||
@@ -513,6 +523,29 @@ async fn list_workers(
|
|||||||
Ok(Json(RuntimeHttpWorkersResponse { workers }))
|
Ok(Json(RuntimeHttpWorkersResponse { workers }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn authorize_working_directory_repository_access(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
Extension(auth): Extension<RuntimeAuthContext>,
|
||||||
|
body: Result<Json<WorkingDirectoryRepositoryAccessRequest>, JsonRejection>,
|
||||||
|
) -> RestResult<RuntimeHttpRepositoryAccessResponse> {
|
||||||
|
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||||
|
if request.materialization.workspace_id != auth.workspace_id {
|
||||||
|
return Err(RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"working_directory_materialization_workspace_mismatch",
|
||||||
|
"Repository access authority does not match the authenticated Workspace",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.runtime
|
||||||
|
.authorize_working_directory_repository_access_from_resource(request)
|
||||||
|
.await
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(RuntimeHttpRepositoryAccessResponse {
|
||||||
|
authorized: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_working_directories(
|
async fn list_working_directories(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
||||||
@@ -527,12 +560,23 @@ async fn list_working_directories(
|
|||||||
|
|
||||||
async fn create_working_directory(
|
async fn create_working_directory(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
|
Extension(auth): Extension<RuntimeAuthContext>,
|
||||||
body: Result<Json<WorkingDirectoryRequest>, JsonRejection>,
|
body: Result<Json<WorkingDirectoryRequest>, JsonRejection>,
|
||||||
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
|
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
|
||||||
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||||
|
if let Some(materialization) = request.materialization.as_ref()
|
||||||
|
&& materialization.workspace_id != auth.workspace_id
|
||||||
|
{
|
||||||
|
return Err(RuntimeHttpRestError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"working_directory_materialization_workspace_mismatch",
|
||||||
|
"Repository materialization authority does not match the authenticated Workspace",
|
||||||
|
));
|
||||||
|
}
|
||||||
let working_directory = state
|
let working_directory = state
|
||||||
.runtime
|
.runtime
|
||||||
.create_working_directory(request)
|
.create_working_directory_from_resource(request)
|
||||||
|
.await
|
||||||
.map_err(RuntimeHttpRestError::runtime)?;
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
||||||
working_directory,
|
working_directory,
|
||||||
@@ -1559,6 +1603,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
if path == "/v1/workers" && *method == Method::POST {
|
if path == "/v1/workers" && *method == Method::POST {
|
||||||
return Some("workers:create");
|
return Some("workers:create");
|
||||||
}
|
}
|
||||||
|
if path == "/v1/working-directories/repository-access" && *method == Method::POST {
|
||||||
|
return Some("workdirs:operate");
|
||||||
|
}
|
||||||
if path.starts_with("/v1/workdir-sessions")
|
if path.starts_with("/v1/workdir-sessions")
|
||||||
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
|
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
|
||||||
{
|
{
|
||||||
@@ -2220,6 +2267,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workdir_routes_require_dedicated_operation_permission() {
|
fn workdir_routes_require_dedicated_operation_permission() {
|
||||||
|
assert_eq!(
|
||||||
|
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
|
||||||
|
Some("workdirs:operate")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
||||||
Some("workdirs:operate")
|
Some("workdirs:operate")
|
||||||
|
|||||||
@@ -23,10 +23,21 @@ use worker_runtime::http_server::{
|
|||||||
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
||||||
};
|
};
|
||||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||||
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
|
use worker_runtime::working_directory::RuntimeGitCacheMaterializer;
|
||||||
use worker_runtime::{Runtime, RuntimeOptions};
|
use worker_runtime::{Runtime, RuntimeOptions};
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
|
let mut arguments = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
|
if arguments.first().map(String::as_str) == Some("__repository-ssh") {
|
||||||
|
arguments.remove(0);
|
||||||
|
return match worker_runtime::working_directory::run_repository_ssh_client(&arguments) {
|
||||||
|
Ok(status) => ExitCode::from(u8::try_from(status).unwrap_or(1)),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("{error}");
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
match run() {
|
match run() {
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -160,29 +171,52 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
|||||||
};
|
};
|
||||||
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
|
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
|
||||||
.with_runtime_store_dir(runtime_store_dir);
|
.with_runtime_store_dir(runtime_store_dir);
|
||||||
if let Some(identity) = read_runtime_auth_file(&runtime_auth_path(config))?.identity {
|
let runtime_auth = read_runtime_auth_file(&runtime_auth_path(config))?;
|
||||||
|
if let Some(identity) = runtime_auth.identity.clone() {
|
||||||
|
if let [trusted_server] = runtime_auth.trusted_servers.as_slice() {
|
||||||
|
factory =
|
||||||
|
factory.with_runtime_request_identity(identity, trusted_server.server_id.clone());
|
||||||
|
} else {
|
||||||
factory = factory.with_remote_worker_mutation_identity(identity);
|
factory = factory.with_remote_worker_mutation_identity(identity);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
let mut backend_resource_client: Option<
|
||||||
|
Arc<dyn worker_runtime::resource::BackendResourceClient>,
|
||||||
|
> = None;
|
||||||
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
|
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
|
||||||
factory = factory.with_resource_client(Arc::new(
|
let identity = runtime_auth.identity.as_ref().ok_or_else(|| {
|
||||||
|
ProcessError::Auth(
|
||||||
|
"--backend-resource-endpoint requires a configured Runtime identity".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let [trusted_server] = runtime_auth.trusted_servers.as_slice() else {
|
||||||
|
return Err(ProcessError::Auth(
|
||||||
|
"--backend-resource-endpoint requires exactly one trusted Server identity"
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let client = Arc::new(
|
||||||
worker_runtime::resource::HttpBackendResourceClient::new(
|
worker_runtime::resource::HttpBackendResourceClient::new(
|
||||||
endpoint,
|
endpoint,
|
||||||
config.backend_resource_token.clone(),
|
config.backend_resource_token.clone(),
|
||||||
),
|
)
|
||||||
));
|
.with_runtime_request_source(identity, trusted_server.server_id.clone()),
|
||||||
|
);
|
||||||
|
factory = factory.with_resource_client(client.clone());
|
||||||
|
backend_resource_client = Some(client);
|
||||||
}
|
}
|
||||||
let backend = Arc::new(
|
let backend = Arc::new(
|
||||||
WorkerRuntimeExecutionBackend::new(factory)
|
WorkerRuntimeExecutionBackend::new(factory)
|
||||||
.map_err(ProcessError::WorkerAdapter)?
|
.map_err(ProcessError::WorkerAdapter)?
|
||||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||||
fs_paths.workdir_target.clone(),
|
fs_paths.workdir_target.clone(),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
match &config.http.store {
|
let runtime = match &config.http.store {
|
||||||
RuntimeHttpStoreSelection::Memory => {
|
RuntimeHttpStoreSelection::Memory => {
|
||||||
Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend)
|
Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend)
|
||||||
.map_err(ProcessError::Runtime)
|
.map_err(ProcessError::Runtime)?
|
||||||
}
|
}
|
||||||
RuntimeHttpStoreSelection::Fs { root } => {
|
RuntimeHttpStoreSelection::Fs { root } => {
|
||||||
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
|
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
|
||||||
@@ -195,12 +229,20 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
|||||||
);
|
);
|
||||||
options.display_name = config.http.display_name.clone();
|
options.display_name = config.http.display_name.clone();
|
||||||
Runtime::with_fs_store_and_execution_backend(options, backend)
|
Runtime::with_fs_store_and_execution_backend(options, backend)
|
||||||
.map_err(ProcessError::Runtime)
|
.map_err(ProcessError::Runtime)?
|
||||||
}
|
}
|
||||||
_ => Err(ProcessError::usage(
|
_ => {
|
||||||
|
return Err(ProcessError::usage(
|
||||||
"unsupported Runtime catalog store selection".to_string(),
|
"unsupported Runtime catalog store selection".to_string(),
|
||||||
)),
|
));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if let Some(client) = backend_resource_client {
|
||||||
|
runtime
|
||||||
|
.install_backend_resource_client(client)
|
||||||
|
.map_err(ProcessError::Runtime)?;
|
||||||
|
}
|
||||||
|
Ok(runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
use crate::auth::{
|
||||||
|
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||||
|
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
|
||||||
|
};
|
||||||
use crate::identity::WorkerId;
|
use crate::identity::WorkerId;
|
||||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
|
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -7,18 +11,46 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
|
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
|
||||||
"application/vnd.yoi.profile-source-archive+tar";
|
"application/vnd.yoi.profile-source-archive+tar";
|
||||||
|
pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
|
||||||
|
"application/vnd.yoi.repository-ssh-access+json";
|
||||||
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
|
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
|
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RepositorySshAccessSecret {
|
||||||
|
pub private_key: String,
|
||||||
|
pub known_hosts_entry: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RepositorySshAccessSecret {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
zeroize::Zeroize::zeroize(&mut self.private_key);
|
||||||
|
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RepositorySshAccessSecret {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("RepositorySshAccessSecret")
|
||||||
|
.field("private_key", &"[REDACTED]")
|
||||||
|
.field("known_hosts_entry", &"[REDACTED]")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum BackendResourceKind {
|
pub enum BackendResourceKind {
|
||||||
ProfileSourceArchive,
|
ProfileSourceArchive,
|
||||||
|
RepositorySshAccess,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum BackendResourceOperation {
|
pub enum BackendResourceOperation {
|
||||||
FetchArchive,
|
FetchArchive,
|
||||||
|
FetchOnce,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -62,7 +94,7 @@ pub struct BackendResourceFetchRequest {
|
|||||||
pub audit_correlation_id: String,
|
pub audit_correlation_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct BackendResourceFetchResponse {
|
pub struct BackendResourceFetchResponse {
|
||||||
pub kind: BackendResourceKind,
|
pub kind: BackendResourceKind,
|
||||||
pub resource_id: String,
|
pub resource_id: String,
|
||||||
@@ -72,6 +104,29 @@ pub struct BackendResourceFetchResponse {
|
|||||||
pub audit_correlation_id: String,
|
pub audit_correlation_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for BackendResourceFetchResponse {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("BackendResourceFetchResponse")
|
||||||
|
.field("kind", &self.kind)
|
||||||
|
.field("resource_id", &self.resource_id)
|
||||||
|
.field("digest", &self.digest)
|
||||||
|
.field("content_type", &self.content_type)
|
||||||
|
.field(
|
||||||
|
"bytes",
|
||||||
|
&format_args!("[REDACTED; {} bytes]", self.bytes.len()),
|
||||||
|
)
|
||||||
|
.field("audit_correlation_id", &self.audit_correlation_id)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for BackendResourceFetchResponse {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.bytes.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||||
#[serde(tag = "code", rename_all = "snake_case")]
|
#[serde(tag = "code", rename_all = "snake_case")]
|
||||||
pub enum BackendResourceError {
|
pub enum BackendResourceError {
|
||||||
@@ -108,6 +163,8 @@ pub trait BackendResourceClient: Send + Sync + 'static {
|
|||||||
pub struct HttpBackendResourceClient {
|
pub struct HttpBackendResourceClient {
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
bearer_token: Option<String>,
|
bearer_token: Option<String>,
|
||||||
|
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
||||||
|
request_source_audience: Option<String>,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +174,21 @@ impl HttpBackendResourceClient {
|
|||||||
Self {
|
Self {
|
||||||
endpoint: endpoint.into(),
|
endpoint: endpoint.into(),
|
||||||
bearer_token,
|
bearer_token,
|
||||||
|
request_source_signer: None,
|
||||||
|
request_source_audience: None,
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_runtime_request_source(
|
||||||
|
mut self,
|
||||||
|
identity: &RuntimeIdentityMaterial,
|
||||||
|
audience: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
|
||||||
|
self.request_source_audience = Some(audience.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "http-server")]
|
#[cfg(feature = "http-server")]
|
||||||
@@ -129,7 +198,44 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
|||||||
&self,
|
&self,
|
||||||
request: BackendResourceFetchRequest,
|
request: BackendResourceFetchRequest,
|
||||||
) -> Result<BackendResourceFetchResponse, BackendResourceError> {
|
) -> Result<BackendResourceFetchResponse, BackendResourceError> {
|
||||||
let builder = self.client.post(&self.endpoint).json(&request);
|
let body = serde_json::to_vec(&request).map_err(|error| {
|
||||||
|
BackendResourceError::InvalidResponse {
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let endpoint = reqwest::Url::parse(&self.endpoint).map_err(|error| {
|
||||||
|
BackendResourceError::Transport {
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let mut builder = self
|
||||||
|
.client
|
||||||
|
.post(endpoint.clone())
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(body.clone());
|
||||||
|
if let Some(signer) = self.request_source_signer.as_ref() {
|
||||||
|
let audience = self.request_source_audience.as_deref().ok_or_else(|| {
|
||||||
|
BackendResourceError::Unauthorized {
|
||||||
|
message: "Runtime request proof audience is unavailable".to_owned(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let proof = signer
|
||||||
|
.issue(
|
||||||
|
audience,
|
||||||
|
&request.handle.workspace_id,
|
||||||
|
None,
|
||||||
|
BACKEND_RESOURCE_FETCH_PERMISSION,
|
||||||
|
"POST",
|
||||||
|
endpoint.path(),
|
||||||
|
&body,
|
||||||
|
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
.map_err(|error| BackendResourceError::Unauthorized {
|
||||||
|
message: error.to_string(),
|
||||||
|
})?;
|
||||||
|
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
|
||||||
|
}
|
||||||
let builder = if let Some(token) = self.bearer_token.as_deref() {
|
let builder = if let Some(token) = self.bearer_token.as_deref() {
|
||||||
builder.bearer_auth(token)
|
builder.bearer_auth(token)
|
||||||
} else {
|
} else {
|
||||||
@@ -193,7 +299,7 @@ pub fn build_profile_source_archive_fetch_request(
|
|||||||
|
|
||||||
pub fn profile_source_archive_from_response(
|
pub fn profile_source_archive_from_response(
|
||||||
handle: &BackendResourceHandle,
|
handle: &BackendResourceHandle,
|
||||||
response: BackendResourceFetchResponse,
|
mut response: BackendResourceFetchResponse,
|
||||||
) -> Result<ProfileSourceArchive, BackendResourceError> {
|
) -> Result<ProfileSourceArchive, BackendResourceError> {
|
||||||
if handle.kind != BackendResourceKind::ProfileSourceArchive
|
if handle.kind != BackendResourceKind::ProfileSourceArchive
|
||||||
|| response.kind != BackendResourceKind::ProfileSourceArchive
|
|| response.kind != BackendResourceKind::ProfileSourceArchive
|
||||||
@@ -208,7 +314,7 @@ pub fn profile_source_archive_from_response(
|
|||||||
if response.content_type != handle.content_type {
|
if response.content_type != handle.content_type {
|
||||||
return Err(BackendResourceError::ContentTypeMismatch {
|
return Err(BackendResourceError::ContentTypeMismatch {
|
||||||
expected: handle.content_type.clone(),
|
expected: handle.content_type.clone(),
|
||||||
actual: response.content_type,
|
actual: response.content_type.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let actual_bytes = response.bytes.len() as u64;
|
let actual_bytes = response.bytes.len() as u64;
|
||||||
@@ -223,7 +329,7 @@ pub fn profile_source_archive_from_response(
|
|||||||
return Err(BackendResourceError::DigestMismatch {
|
return Err(BackendResourceError::DigestMismatch {
|
||||||
expected: handle.digest.clone(),
|
expected: handle.digest.clone(),
|
||||||
actual: if response.digest != handle.digest {
|
actual: if response.digest != handle.digest {
|
||||||
response.digest
|
response.digest.clone()
|
||||||
} else {
|
} else {
|
||||||
actual_digest
|
actual_digest
|
||||||
},
|
},
|
||||||
@@ -241,7 +347,7 @@ pub fn profile_source_archive_from_response(
|
|||||||
}
|
}
|
||||||
})?,
|
})?,
|
||||||
},
|
},
|
||||||
content: response.bytes,
|
content: std::mem::take(&mut response.bytes),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
|
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
|
||||||
WorkerStatus, WorkerSummary, WorkingDirectoryRequest,
|
WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
|
||||||
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
|
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{
|
use crate::config_bundle::{
|
||||||
@@ -26,6 +26,10 @@ use crate::management::{
|
|||||||
};
|
};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
||||||
|
use crate::resource::{
|
||||||
|
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest, BackendResourceKind,
|
||||||
|
REPOSITORY_SSH_ACCESS_CONTENT_TYPE, RepositorySshAccessSecret,
|
||||||
|
};
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
use crate::retention::{
|
use crate::retention::{
|
||||||
FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult,
|
FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult,
|
||||||
@@ -172,6 +176,14 @@ impl Runtime {
|
|||||||
Ok(runtime)
|
Ok(runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn install_backend_resource_client(
|
||||||
|
&self,
|
||||||
|
client: Arc<dyn BackendResourceClient>,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
self.lock()?.backend_resource_client = Some(BackendResourceClientRef(client));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Create or restore a filesystem-backed Runtime.
|
/// Create or restore a filesystem-backed Runtime.
|
||||||
///
|
///
|
||||||
/// The store is scoped by `options.root`; if the directory already exists,
|
/// The store is scoped by `options.root`; if the directory already exists,
|
||||||
@@ -366,6 +378,103 @@ impl Runtime {
|
|||||||
.map_err(RuntimeError::from)
|
.map_err(RuntimeError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_working_directory_from_resource(
|
||||||
|
&self,
|
||||||
|
mut request: WorkingDirectoryRequest,
|
||||||
|
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
|
||||||
|
if let Some(ssh) = request
|
||||||
|
.materialization
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|materialization| materialization.ssh.as_mut())
|
||||||
|
{
|
||||||
|
self.resolve_repository_access_resource(ssh).await?;
|
||||||
|
}
|
||||||
|
self.create_working_directory(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
let backend = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
state.ensure_running()?;
|
||||||
|
state.execution_backend.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::ExecutionBackendUnavailable {
|
||||||
|
message: "working directory Repository access requires an execution backend"
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.authorize_working_directory_repository_access(&request)
|
||||||
|
.map_err(RuntimeError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_repository_access_resource(
|
||||||
|
&self,
|
||||||
|
ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (client, runtime_id) = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
let client = state.backend_resource_client.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Backend Repository access resource client is unavailable".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
|
||||||
|
})?;
|
||||||
|
(client, runtime_id)
|
||||||
|
};
|
||||||
|
let mut response = client
|
||||||
|
.0
|
||||||
|
.fetch_resource(BackendResourceFetchRequest {
|
||||||
|
handle: ssh.secret_resource.clone(),
|
||||||
|
runtime_id,
|
||||||
|
worker_id: None,
|
||||||
|
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(repository_resource_error)?;
|
||||||
|
if response.kind != BackendResourceKind::RepositorySshAccess
|
||||||
|
|| response.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE
|
||||||
|
|| response.resource_id != ssh.secret_resource.resource_id
|
||||||
|
|| response.digest != ssh.secret_resource.digest
|
||||||
|
|| response.bytes.len() as u64 > ssh.secret_resource.max_bytes
|
||||||
|
{
|
||||||
|
return Err(RuntimeError::InvalidRequest(
|
||||||
|
"Backend Repository SSH access resource response was invalid".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let secret = serde_json::from_slice::<RepositorySshAccessSecret>(&response.bytes);
|
||||||
|
response.bytes.fill(0);
|
||||||
|
let mut secret = secret.map_err(|_| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Backend Repository SSH access resource payload was invalid".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
ssh.private_key =
|
||||||
|
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
|
||||||
|
ssh.known_hosts_entry =
|
||||||
|
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorize_working_directory_repository_access_from_resource(
|
||||||
|
&self,
|
||||||
|
mut request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
|
||||||
|
})?;
|
||||||
|
self.resolve_repository_access_resource(ssh).await?;
|
||||||
|
self.authorize_working_directory_repository_access(request)
|
||||||
|
}
|
||||||
|
|
||||||
/// List Runtime-owned working directories through the attached execution backend.
|
/// List Runtime-owned working directories through the attached execution backend.
|
||||||
pub fn list_working_directories(
|
pub fn list_working_directories(
|
||||||
&self,
|
&self,
|
||||||
@@ -566,12 +675,13 @@ impl Runtime {
|
|||||||
let worker_id = request.worker_id;
|
let worker_id = request.worker_id;
|
||||||
let worker_ref = WorkerRef::new(worker_id);
|
let worker_ref = WorkerRef::new(worker_id);
|
||||||
|
|
||||||
|
let durable_request = durable_create_worker_request(&request);
|
||||||
let record = WorkerRecord {
|
let record = WorkerRecord {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
worker_id: worker_id.clone(),
|
worker_id: worker_id.clone(),
|
||||||
status: WorkerStatus::Stopped,
|
status: WorkerStatus::Stopped,
|
||||||
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
|
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
|
||||||
request: request.clone(),
|
request: durable_request,
|
||||||
run_generation: 1,
|
run_generation: 1,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
execution_handle: None,
|
execution_handle: None,
|
||||||
@@ -1842,6 +1952,15 @@ struct SubscriptionSink {
|
|||||||
lagged: Arc<AtomicBool>,
|
lagged: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct BackendResourceClientRef(Arc<dyn BackendResourceClient>);
|
||||||
|
|
||||||
|
impl std::fmt::Debug for BackendResourceClientRef {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str("BackendResourceClientRef(..)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct RuntimeState {
|
struct RuntimeState {
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
@@ -1853,6 +1972,7 @@ struct RuntimeState {
|
|||||||
persistence: RuntimePersistence,
|
persistence: RuntimePersistence,
|
||||||
status: RuntimeStatus,
|
status: RuntimeStatus,
|
||||||
execution_backend: Option<WorkerExecutionBackendRef>,
|
execution_backend: Option<WorkerExecutionBackendRef>,
|
||||||
|
backend_resource_client: Option<BackendResourceClientRef>,
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
next_diagnostic_id: u64,
|
next_diagnostic_id: u64,
|
||||||
workers: BTreeMap<WorkerId, WorkerRecord>,
|
workers: BTreeMap<WorkerId, WorkerRecord>,
|
||||||
@@ -1880,6 +2000,7 @@ impl RuntimeState {
|
|||||||
persistence: RuntimePersistence::Memory,
|
persistence: RuntimePersistence::Memory,
|
||||||
status: RuntimeStatus::Running,
|
status: RuntimeStatus::Running,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
|
backend_resource_client: None,
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
next_diagnostic_id: 1,
|
next_diagnostic_id: 1,
|
||||||
workers: BTreeMap::new(),
|
workers: BTreeMap::new(),
|
||||||
@@ -1908,6 +2029,7 @@ impl RuntimeState {
|
|||||||
persistence: RuntimePersistence::Fs(store),
|
persistence: RuntimePersistence::Fs(store),
|
||||||
status: RuntimeStatus::Running,
|
status: RuntimeStatus::Running,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
|
backend_resource_client: None,
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
next_diagnostic_id: 1,
|
next_diagnostic_id: 1,
|
||||||
workers: BTreeMap::new(),
|
workers: BTreeMap::new(),
|
||||||
@@ -1959,6 +2081,7 @@ impl RuntimeState {
|
|||||||
persistence: RuntimePersistence::Fs(store),
|
persistence: RuntimePersistence::Fs(store),
|
||||||
status: persisted.status,
|
status: persisted.status,
|
||||||
execution_backend: None,
|
execution_backend: None,
|
||||||
|
backend_resource_client: None,
|
||||||
next_diagnostic_id,
|
next_diagnostic_id,
|
||||||
workers,
|
workers,
|
||||||
config_bundles: BTreeMap::new(),
|
config_bundles: BTreeMap::new(),
|
||||||
@@ -2714,6 +2837,33 @@ fn worker_status_from_run_state(run_state: WorkerExecutionRunState) -> WorkerSta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
|
||||||
|
let category = match error {
|
||||||
|
BackendResourceError::Expired => "expired",
|
||||||
|
BackendResourceError::Unauthorized { .. } => "unauthorized",
|
||||||
|
BackendResourceError::UnsupportedKind => "unsupported_kind",
|
||||||
|
BackendResourceError::MissingResource => "missing_resource",
|
||||||
|
BackendResourceError::Oversized { .. } => "oversized",
|
||||||
|
BackendResourceError::DigestMismatch { .. } => "digest_mismatch",
|
||||||
|
BackendResourceError::ContentTypeMismatch { .. } => "content_type_mismatch",
|
||||||
|
BackendResourceError::InvalidResponse { .. } => "invalid_response",
|
||||||
|
BackendResourceError::Transport { .. } => "transport",
|
||||||
|
};
|
||||||
|
RuntimeError::InvalidRequest(format!(
|
||||||
|
"Backend Repository SSH access resource fetch failed: {category}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn durable_create_worker_request(request: &CreateWorkerRequest) -> CreateWorkerRequest {
|
||||||
|
let mut durable = request.clone();
|
||||||
|
if let Some(working_directory) = durable.working_directory_request.as_mut()
|
||||||
|
&& let Some(materialization) = working_directory.materialization.as_mut()
|
||||||
|
{
|
||||||
|
materialization.ssh = None;
|
||||||
|
}
|
||||||
|
durable
|
||||||
|
}
|
||||||
|
|
||||||
fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
|
fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
|
||||||
request
|
request
|
||||||
.working_directory
|
.working_directory
|
||||||
@@ -2884,7 +3034,9 @@ fn subscription_worker_state(status: WorkerStatus) -> SubscriptionWorkerState {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, ProfileSelector, WorkingDirectoryClaim, WorkspaceApiRef,
|
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
|
||||||
|
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
|
||||||
|
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{
|
use crate::config_bundle::{
|
||||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
|
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
|
||||||
@@ -2894,6 +3046,8 @@ mod tests {
|
|||||||
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
|
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
|
||||||
WorkerExecutionRestoreRequest, WorkerExecutionRunState,
|
WorkerExecutionRestoreRequest, WorkerExecutionRunState,
|
||||||
};
|
};
|
||||||
|
use crate::working_directory::WorkingDirectoryDiagnostic;
|
||||||
|
use async_trait::async_trait;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -3115,6 +3269,243 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durable_worker_request_omits_repository_credentials() {
|
||||||
|
let mut request = task_request("worker-secret-redaction");
|
||||||
|
request.working_directory_request = Some(WorkingDirectoryRequest {
|
||||||
|
repository: WorkingDirectoryRepository {
|
||||||
|
id: "repository-1".to_string(),
|
||||||
|
provider: "git".to_string(),
|
||||||
|
source: workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||||
|
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||||
|
},
|
||||||
|
source_revision: 1,
|
||||||
|
source_fingerprint: "sha256:source".to_string(),
|
||||||
|
selector: None,
|
||||||
|
},
|
||||||
|
materializer: MaterializerKind::RuntimeGitCache,
|
||||||
|
backend_workdir_id: Some("working-directory-1".to_string()),
|
||||||
|
materialization: Some(RepositoryMaterializationContext {
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
runtime_id: "runtime-1".to_string(),
|
||||||
|
operation_id: "operation-1".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
config_projection_digest: "sha256:projection".to_string(),
|
||||||
|
cache_generation: 0,
|
||||||
|
ssh: Some(RepositorySshMaterializationAccess {
|
||||||
|
credential_id: "credential-1".to_string(),
|
||||||
|
credential_revision: 1,
|
||||||
|
host_trust_id: "host-trust-1".to_string(),
|
||||||
|
host_trust_revision: 1,
|
||||||
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
|
expires_at_epoch_seconds: u64::MAX,
|
||||||
|
repository_id: "repository-1".to_string(),
|
||||||
|
repository_source_fingerprint: "sha256:source".to_string(),
|
||||||
|
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||||
|
secret_resource: repository_resource_handle(),
|
||||||
|
private_key: SensitiveString::new("private-key-bytes"),
|
||||||
|
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let durable = durable_create_worker_request(&request);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
request
|
||||||
|
.working_directory_request
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|working_directory| working_directory.materialization.as_ref())
|
||||||
|
.and_then(|materialization| materialization.ssh.as_ref())
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
durable
|
||||||
|
.working_directory_request
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|working_directory| working_directory.materialization.as_ref())
|
||||||
|
.and_then(|materialization| materialization.ssh.as_ref())
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let serialized = serde_json::to_string(&durable).unwrap();
|
||||||
|
assert!(!serialized.contains("private-key-bytes"));
|
||||||
|
assert!(!serialized.contains("known-hosts-entry"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_resource_handle() -> crate::resource::BackendResourceHandle {
|
||||||
|
crate::resource::BackendResourceHandle {
|
||||||
|
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
scope_id: Some("repository-ssh-access".to_string()),
|
||||||
|
runtime_id: Some("runtime-1".to_string()),
|
||||||
|
worker_id: None,
|
||||||
|
resource_id: "repository-access-1".to_string(),
|
||||||
|
digest: "opaque:repository-access-1".to_string(),
|
||||||
|
operation: crate::resource::BackendResourceOperation::FetchOnce,
|
||||||
|
expires_at_unix_seconds: i64::MAX,
|
||||||
|
nonce: "repository-access-1".to_string(),
|
||||||
|
revision: "1".to_string(),
|
||||||
|
generation: None,
|
||||||
|
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
|
||||||
|
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||||
|
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||||
|
audit_correlation_id: "repository-access-1".to_string(),
|
||||||
|
profile_source_graph: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repository_access_resource_is_fetched_before_provider_authorization() {
|
||||||
|
let (runtime, backend) = runtime_and_backend();
|
||||||
|
backend
|
||||||
|
.repository_access_available
|
||||||
|
.store(true, Ordering::SeqCst);
|
||||||
|
runtime.bind_runtime_identity("runtime-1").unwrap();
|
||||||
|
let handle = repository_resource_handle();
|
||||||
|
runtime
|
||||||
|
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
|
||||||
|
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||||
|
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||||
|
resource_id: handle.resource_id.clone(),
|
||||||
|
digest: handle.digest.clone(),
|
||||||
|
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||||
|
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||||
|
private_key: "private-key-bytes".to_string(),
|
||||||
|
known_hosts_entry: "known-hosts-entry".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let request = WorkingDirectoryRepositoryAccessRequest {
|
||||||
|
working_directory_id: "working-directory-1".to_string(),
|
||||||
|
materialization: RepositoryMaterializationContext {
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
runtime_id: "runtime-1".to_string(),
|
||||||
|
operation_id: "operation-1".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
config_projection_digest: "sha256:projection".to_string(),
|
||||||
|
cache_generation: 0,
|
||||||
|
ssh: Some(RepositorySshMaterializationAccess {
|
||||||
|
credential_id: "credential-1".to_string(),
|
||||||
|
credential_revision: 1,
|
||||||
|
host_trust_id: "host-trust-1".to_string(),
|
||||||
|
host_trust_revision: 1,
|
||||||
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
|
expires_at_epoch_seconds: u64::MAX,
|
||||||
|
repository_id: "repository-1".to_string(),
|
||||||
|
repository_source_fingerprint: "sha256:source".to_string(),
|
||||||
|
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||||
|
secret_resource: handle,
|
||||||
|
private_key: SensitiveString::default(),
|
||||||
|
known_hosts_entry: SensitiveString::default(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let replay = request.clone();
|
||||||
|
runtime
|
||||||
|
.authorize_working_directory_repository_access_from_resource(request)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
runtime
|
||||||
|
.authorize_working_directory_repository_access_from_resource(replay)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let accesses = backend.repository_accesses.lock().unwrap();
|
||||||
|
assert_eq!(accesses.len(), 1);
|
||||||
|
let access = accesses[0].materialization.ssh.as_ref().unwrap();
|
||||||
|
assert_eq!(access.private_key.expose(), "private-key-bytes");
|
||||||
|
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn working_directory_create_fetches_repository_access_before_provider_call() {
|
||||||
|
let (runtime, backend) = runtime_and_backend();
|
||||||
|
backend
|
||||||
|
.repository_access_available
|
||||||
|
.store(true, Ordering::SeqCst);
|
||||||
|
runtime.bind_runtime_identity("runtime-1").unwrap();
|
||||||
|
let handle = repository_resource_handle();
|
||||||
|
runtime
|
||||||
|
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
|
||||||
|
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||||
|
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||||
|
resource_id: handle.resource_id.clone(),
|
||||||
|
digest: handle.digest.clone(),
|
||||||
|
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||||
|
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||||
|
private_key: "create-private-key-bytes".to_string(),
|
||||||
|
known_hosts_entry: "create-known-hosts-entry".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let request = WorkingDirectoryRequest {
|
||||||
|
repository: WorkingDirectoryRepository {
|
||||||
|
id: "repository-1".to_string(),
|
||||||
|
provider: "git".to_string(),
|
||||||
|
source: workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||||
|
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||||
|
},
|
||||||
|
source_revision: 1,
|
||||||
|
source_fingerprint: "sha256:source".to_string(),
|
||||||
|
selector: None,
|
||||||
|
},
|
||||||
|
materializer: MaterializerKind::RuntimeGitCache,
|
||||||
|
backend_workdir_id: Some("working-directory-1".to_string()),
|
||||||
|
materialization: Some(RepositoryMaterializationContext {
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
runtime_id: "runtime-1".to_string(),
|
||||||
|
operation_id: "operation-create".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
config_projection_digest: "sha256:projection".to_string(),
|
||||||
|
cache_generation: 0,
|
||||||
|
ssh: Some(RepositorySshMaterializationAccess {
|
||||||
|
credential_id: "credential-1".to_string(),
|
||||||
|
credential_revision: 1,
|
||||||
|
host_trust_id: "host-trust-1".to_string(),
|
||||||
|
host_trust_revision: 1,
|
||||||
|
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||||
|
expires_at_epoch_seconds: u64::MAX,
|
||||||
|
repository_id: "repository-1".to_string(),
|
||||||
|
repository_source_fingerprint: "sha256:source".to_string(),
|
||||||
|
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||||
|
secret_resource: handle,
|
||||||
|
private_key: SensitiveString::default(),
|
||||||
|
known_hosts_entry: SensitiveString::default(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
runtime
|
||||||
|
.create_working_directory_from_resource(request)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let requests = backend.working_directory_requests.lock().unwrap();
|
||||||
|
let access = requests[0]
|
||||||
|
.materialization
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|materialization| materialization.ssh.as_ref())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(access.private_key.expose(), "create-private-key-bytes");
|
||||||
|
assert_eq!(
|
||||||
|
access.known_hosts_entry.expose(),
|
||||||
|
"create-known-hosts-entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
|
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
|
||||||
let mut request = task_request(objective);
|
let mut request = task_request(objective);
|
||||||
request.workspace_api = Some(WorkspaceApiRef {
|
request.workspace_api = Some(WorkspaceApiRef {
|
||||||
@@ -3196,6 +3587,9 @@ mod tests {
|
|||||||
config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
|
config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
|
||||||
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
||||||
dispatched_inputs: Mutex<Vec<WorkerInput>>,
|
dispatched_inputs: Mutex<Vec<WorkerInput>>,
|
||||||
|
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
|
||||||
|
repository_access_available: AtomicBool,
|
||||||
|
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
|
||||||
preserve_commit_ack_submission_id: AtomicBool,
|
preserve_commit_ack_submission_id: AtomicBool,
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
|
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
|
||||||
@@ -3236,6 +3630,38 @@ mod tests {
|
|||||||
"test-execution-backend"
|
"test-execution-backend"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_working_directory(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRequest,
|
||||||
|
) -> Result<CatalogWorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
self.working_directory_requests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(request.clone());
|
||||||
|
Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_unsupported",
|
||||||
|
"Worker execution backend does not support working directory materialization",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
self.repository_accesses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(request.clone());
|
||||||
|
if self.repository_access_available.load(Ordering::SeqCst) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_repository_access_unsupported",
|
||||||
|
"Worker execution backend does not support Repository access authorization",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
||||||
self.run_generations
|
self.run_generations
|
||||||
.lock()
|
.lock()
|
||||||
@@ -3343,6 +3769,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TestRepositoryResourceClient {
|
||||||
|
response: Mutex<Option<crate::resource::BackendResourceFetchResponse>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackendResourceClient for TestRepositoryResourceClient {
|
||||||
|
async fn fetch_resource(
|
||||||
|
&self,
|
||||||
|
_request: BackendResourceFetchRequest,
|
||||||
|
) -> Result<crate::resource::BackendResourceFetchResponse, BackendResourceError> {
|
||||||
|
self.response
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.take()
|
||||||
|
.ok_or(BackendResourceError::MissingResource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn runtime_with_backend() -> Runtime {
|
fn runtime_with_backend() -> Runtime {
|
||||||
let runtime = Runtime::with_execution_backend(
|
let runtime = Runtime::with_execution_backend(
|
||||||
RuntimeOptions::default(),
|
RuntimeOptions::default(),
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex, mpsc};
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::auth::RuntimeIdentityMaterial;
|
use crate::auth::{
|
||||||
|
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||||
|
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
|
||||||
|
};
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||||
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
};
|
};
|
||||||
use crate::execution::{
|
use crate::execution::{
|
||||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
|
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
|
||||||
@@ -35,9 +38,7 @@ use crate::working_directory::{
|
|||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use protocol::{Event, Method, Segment, WorkerStatus};
|
use protocol::{Event, Method, Segment, WorkerStatus};
|
||||||
use session_store::{
|
use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore};
|
||||||
CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore, collect_state,
|
|
||||||
};
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use session_store::{FsStore, FsWorkerStore};
|
use session_store::{FsStore, FsWorkerStore};
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
@@ -65,8 +66,10 @@ const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
|
|||||||
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
|
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
|
||||||
|
|
||||||
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
|
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
|
||||||
let LogEntry::UserInput { extensions, .. } = entry else {
|
let extensions = match entry {
|
||||||
return false;
|
LogEntry::UserInput { extensions, .. }
|
||||||
|
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||||
|
_ => return false,
|
||||||
};
|
};
|
||||||
extensions.iter().any(|extension| {
|
extensions.iter().any(|extension| {
|
||||||
extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN
|
extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN
|
||||||
@@ -209,11 +212,11 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
|
|||||||
return Err(WorkerObservationError::NotFound);
|
return Err(WorkerObservationError::NotFound);
|
||||||
}
|
}
|
||||||
let entries = sink.subscribe_with_snapshot().0;
|
let entries = sink.subscribe_with_snapshot().0;
|
||||||
let state = collect_state(&entries);
|
WorkerSessionCapture::from_log_entries(
|
||||||
Ok(WorkerSessionCapture {
|
format!("runtime:{runtime_id}:worker:{worker_id}"),
|
||||||
segment_id: format!("runtime:{runtime_id}:worker:{worker_id}"),
|
&entries,
|
||||||
items: state.history,
|
)
|
||||||
})
|
.map_err(WorkerObservationError::Unavailable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +298,7 @@ pub struct ProfileRuntimeWorkerFactory {
|
|||||||
prompt_projection_cache: Arc<WorkspacePromptProjectionCache>,
|
prompt_projection_cache: Arc<WorkspacePromptProjectionCache>,
|
||||||
runtime_id: Option<String>,
|
runtime_id: Option<String>,
|
||||||
worker_mutation_identity: Option<RuntimeIdentityMaterial>,
|
worker_mutation_identity: Option<RuntimeIdentityMaterial>,
|
||||||
|
runtime_request_audience: Option<String>,
|
||||||
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
||||||
controller_transport: WorkerControllerTransport,
|
controller_transport: WorkerControllerTransport,
|
||||||
}
|
}
|
||||||
@@ -311,6 +315,7 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()),
|
prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()),
|
||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
worker_mutation_identity: None,
|
worker_mutation_identity: None,
|
||||||
|
runtime_request_audience: None,
|
||||||
embedded_worker_mutation_dispatcher: None,
|
embedded_worker_mutation_dispatcher: None,
|
||||||
controller_transport: WorkerControllerTransport::UnixSocket,
|
controller_transport: WorkerControllerTransport::UnixSocket,
|
||||||
}
|
}
|
||||||
@@ -331,6 +336,17 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_runtime_request_identity(
|
||||||
|
mut self,
|
||||||
|
identity: RuntimeIdentityMaterial,
|
||||||
|
audience: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
self.runtime_id = Some(identity.identity_id.clone());
|
||||||
|
self.worker_mutation_identity = Some(identity);
|
||||||
|
self.runtime_request_audience = Some(audience.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_embedded_worker_mutation_dispatcher(
|
pub fn with_embedded_worker_mutation_dispatcher(
|
||||||
mut self,
|
mut self,
|
||||||
runtime_id: impl Into<String>,
|
runtime_id: impl Into<String>,
|
||||||
@@ -457,13 +473,15 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
async fn resolve_profile_source_archive(
|
async fn resolve_profile_source_archive(
|
||||||
&self,
|
&self,
|
||||||
source: &ProfileSourceArchiveSource,
|
source: &ProfileSourceArchiveSource,
|
||||||
|
request_audience: Option<&str>,
|
||||||
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
|
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
|
||||||
match source {
|
match source {
|
||||||
ProfileSourceArchiveSource::Embedded { archive } => archive
|
ProfileSourceArchiveSource::Embedded { archive } => archive
|
||||||
.verify()
|
.verify()
|
||||||
.map_err(|err| format!("failed to verify embedded profile source archive: {err}")),
|
.map_err(|err| format!("failed to verify embedded profile source archive: {err}")),
|
||||||
ProfileSourceArchiveSource::Http { location } => {
|
ProfileSourceArchiveSource::Http { location } => {
|
||||||
self.fetch_profile_source_archive(location).await
|
self.fetch_profile_source_archive(location, request_audience)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,10 +489,18 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
async fn fetch_profile_source_archive(
|
async fn fetch_profile_source_archive(
|
||||||
&self,
|
&self,
|
||||||
location: &ProfileSourceArchiveHttpRef,
|
location: &ProfileSourceArchiveHttpRef,
|
||||||
|
request_audience: Option<&str>,
|
||||||
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
|
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
|
||||||
if let Some(cached) = self.profile_archive_cache.get(&location.archive.digest) {
|
if let Some(cached) = self.profile_archive_cache.get(&location.archive.digest) {
|
||||||
let response =
|
let response = fetch_profile_source_archive_http(
|
||||||
fetch_profile_source_archive_http(location, Some(&location.archive.digest)).await?;
|
location,
|
||||||
|
Some(&location.archive.digest),
|
||||||
|
self.worker_mutation_identity.as_ref(),
|
||||||
|
self.runtime_request_audience
|
||||||
|
.as_deref()
|
||||||
|
.or(request_audience),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if let Some(fetched) = response {
|
if let Some(fetched) = response {
|
||||||
self.profile_archive_cache.insert(fetched.clone());
|
self.profile_archive_cache.insert(fetched.clone());
|
||||||
fetched.verify().map_err(|err| {
|
fetched.verify().map_err(|err| {
|
||||||
@@ -486,7 +512,14 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
.map_err(|err| format!("failed to verify cached profile source archive: {err}"))
|
.map_err(|err| format!("failed to verify cached profile source archive: {err}"))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let archive = fetch_profile_source_archive_http(location, None)
|
let archive = fetch_profile_source_archive_http(
|
||||||
|
location,
|
||||||
|
None,
|
||||||
|
self.worker_mutation_identity.as_ref(),
|
||||||
|
self.runtime_request_audience
|
||||||
|
.as_deref()
|
||||||
|
.or(request_audience),
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
"profile source archive HTTP revalidation returned 304 without a cached archive"
|
"profile source archive HTTP revalidation returned 304 without a cached archive"
|
||||||
@@ -527,6 +560,7 @@ impl RuntimeWorkspaceBackendRef {
|
|||||||
worker_ref: &WorkerRef,
|
worker_ref: &WorkerRef,
|
||||||
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
|
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
|
||||||
mutation_identity: Option<&RuntimeIdentityMaterial>,
|
mutation_identity: Option<&RuntimeIdentityMaterial>,
|
||||||
|
runtime_request_audience: Option<&str>,
|
||||||
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
||||||
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
||||||
) -> WorkerWorkspaceContext {
|
) -> WorkerWorkspaceContext {
|
||||||
@@ -546,6 +580,13 @@ impl RuntimeWorkspaceBackendRef {
|
|||||||
if let Some(cache) = prompt_projection_cache {
|
if let Some(cache) = prompt_projection_cache {
|
||||||
client = client.with_prompt_projection_cache(cache);
|
client = client.with_prompt_projection_cache(cache);
|
||||||
}
|
}
|
||||||
|
if let Some(identity) = mutation_identity {
|
||||||
|
let audience = runtime_request_audience
|
||||||
|
.or_else(|| workspace_scope.map(|scope| scope.server_id.as_str()));
|
||||||
|
if let Some(audience) = audience {
|
||||||
|
client = client.with_runtime_request_source(identity, audience.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) {
|
if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) {
|
||||||
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
|
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
|
||||||
identity,
|
identity,
|
||||||
@@ -576,9 +617,40 @@ impl RuntimeWorkspaceBackendRef {
|
|||||||
async fn fetch_profile_source_archive_http(
|
async fn fetch_profile_source_archive_http(
|
||||||
location: &ProfileSourceArchiveHttpRef,
|
location: &ProfileSourceArchiveHttpRef,
|
||||||
cached_digest: Option<&str>,
|
cached_digest: Option<&str>,
|
||||||
|
identity: Option<&RuntimeIdentityMaterial>,
|
||||||
|
audience: Option<&str>,
|
||||||
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
|
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mut request = client.get(&location.url);
|
let url = reqwest::Url::parse(&location.url)
|
||||||
|
.map_err(|error| format!("profile source archive URL is invalid: {error}"))?;
|
||||||
|
let path = url.path().to_owned();
|
||||||
|
let workspace_id = path
|
||||||
|
.split('/')
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.windows(2)
|
||||||
|
.find_map(|parts| (parts[0] == "w").then_some(parts[1]))
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| "profile source archive URL is not workspace-scoped".to_owned())?;
|
||||||
|
let mut request = client.get(url);
|
||||||
|
if let Some(identity) = identity {
|
||||||
|
let audience = audience.ok_or_else(|| {
|
||||||
|
"profile source archive request proof audience is unavailable".to_owned()
|
||||||
|
})?;
|
||||||
|
let proof = RuntimeRequestSourceSigner::from_identity(identity)
|
||||||
|
.issue(
|
||||||
|
audience,
|
||||||
|
workspace_id,
|
||||||
|
None,
|
||||||
|
BACKEND_RESOURCE_FETCH_PERMISSION,
|
||||||
|
"GET",
|
||||||
|
&path,
|
||||||
|
b"",
|
||||||
|
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
request = request.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
|
||||||
|
}
|
||||||
if cached_digest == Some(location.archive.digest.as_str()) {
|
if cached_digest == Some(location.archive.digest.as_str()) {
|
||||||
if let Some(etag) = location.etag.as_deref() {
|
if let Some(etag) = location.etag.as_deref() {
|
||||||
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
||||||
@@ -620,6 +692,8 @@ async fn fetch_profile_source_archive_http(
|
|||||||
async fn fetch_profile_source_archive_http(
|
async fn fetch_profile_source_archive_http(
|
||||||
_location: &ProfileSourceArchiveHttpRef,
|
_location: &ProfileSourceArchiveHttpRef,
|
||||||
_cached_digest: Option<&str>,
|
_cached_digest: Option<&str>,
|
||||||
|
_identity: Option<&RuntimeIdentityMaterial>,
|
||||||
|
_audience: Option<&str>,
|
||||||
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
|
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
|
||||||
Err(
|
Err(
|
||||||
"HTTP profile source archive fetch requires the worker-runtime http-server feature"
|
"HTTP profile source archive fetch requires the worker-runtime http-server feature"
|
||||||
@@ -632,13 +706,17 @@ fn runtime_local_workdir_session(
|
|||||||
root: &Path,
|
root: &Path,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
scope: manifest::SharedScope,
|
scope: manifest::SharedScope,
|
||||||
|
command_environment: std::collections::BTreeMap<String, String>,
|
||||||
|
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
|
||||||
) -> WorkdirSessionHandle {
|
) -> WorkdirSessionHandle {
|
||||||
Arc::new(LocalWorkdirSession::materialized_bound(
|
Arc::new(LocalWorkdirSession::materialized_bound_with_environment(
|
||||||
Workdir::new(workdir_id),
|
Workdir::new(workdir_id),
|
||||||
root.to_path_buf(),
|
root.to_path_buf(),
|
||||||
cwd.to_path_buf(),
|
cwd.to_path_buf(),
|
||||||
scope,
|
scope,
|
||||||
WorkdirSessionCapabilities::ALL,
|
WorkdirSessionCapabilities::ALL,
|
||||||
|
command_environment,
|
||||||
|
resources,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,12 +821,19 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
&request.worker_ref,
|
&request.worker_ref,
|
||||||
request.workspace_scope.as_ref(),
|
request.workspace_scope.as_ref(),
|
||||||
self.worker_mutation_identity.as_ref(),
|
self.worker_mutation_identity.as_ref(),
|
||||||
|
self.runtime_request_audience.as_deref(),
|
||||||
self.embedded_worker_mutation_dispatcher.as_ref(),
|
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||||
Some(self.prompt_projection_cache.clone()),
|
Some(self.prompt_projection_cache.clone()),
|
||||||
);
|
);
|
||||||
let selector = profile.as_ref();
|
let selector = profile.as_ref();
|
||||||
let archive = self
|
let archive = self
|
||||||
.resolve_profile_source_archive(&request.request.profile_source)
|
.resolve_profile_source_archive(
|
||||||
|
&request.request.profile_source,
|
||||||
|
request
|
||||||
|
.workspace_scope
|
||||||
|
.as_ref()
|
||||||
|
.map(|scope| scope.server_id.as_str()),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let (mut manifest, mut loader) = {
|
let (mut manifest, mut loader) = {
|
||||||
let manifest = archive
|
let manifest = archive
|
||||||
@@ -812,6 +897,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
binding.root(),
|
binding.root(),
|
||||||
binding.cwd(),
|
binding.cwd(),
|
||||||
worker.scope().clone(),
|
worker.scope().clone(),
|
||||||
|
binding.command_environment(),
|
||||||
|
binding.session_resources(),
|
||||||
)));
|
)));
|
||||||
} else {
|
} else {
|
||||||
worker.bind_workdir_session(None);
|
worker.bind_workdir_session(None);
|
||||||
@@ -909,6 +996,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
&request.worker_ref,
|
&request.worker_ref,
|
||||||
request.workspace_scope.as_ref(),
|
request.workspace_scope.as_ref(),
|
||||||
self.worker_mutation_identity.as_ref(),
|
self.worker_mutation_identity.as_ref(),
|
||||||
|
self.runtime_request_audience.as_deref(),
|
||||||
self.embedded_worker_mutation_dispatcher.as_ref(),
|
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||||
Some(self.prompt_projection_cache.clone()),
|
Some(self.prompt_projection_cache.clone()),
|
||||||
);
|
);
|
||||||
@@ -989,6 +1077,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
binding.root(),
|
binding.root(),
|
||||||
binding.cwd(),
|
binding.cwd(),
|
||||||
worker.scope().clone(),
|
worker.scope().clone(),
|
||||||
|
binding.command_environment(),
|
||||||
|
binding.session_resources(),
|
||||||
)));
|
)));
|
||||||
} else {
|
} else {
|
||||||
worker.bind_workdir_session(None);
|
worker.bind_workdir_session(None);
|
||||||
@@ -1500,6 +1590,19 @@ where
|
|||||||
Ok(materializer.create(request)?.status())
|
Ok(materializer.create(request)?.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||||
|
let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| {
|
||||||
|
WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_materializer_unavailable",
|
||||||
|
"working directory Repository access requested, but no materializer is configured for this runtime backend",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
materializer.authorize_repository_access(request)
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
self.working_directory_materializer
|
self.working_directory_materializer
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1542,6 +1645,8 @@ where
|
|||||||
binding.root(),
|
binding.root(),
|
||||||
binding.cwd(),
|
binding.cwd(),
|
||||||
manifest::SharedScope::new(scope),
|
manifest::SharedScope::new(scope),
|
||||||
|
binding.command_environment(),
|
||||||
|
binding.session_resources(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2060,7 +2165,7 @@ mod tests {
|
|||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
use crate::management::RuntimeOptions;
|
use crate::management::RuntimeOptions;
|
||||||
use crate::observation::WorkerObservationCursor;
|
use crate::observation::WorkerObservationCursor;
|
||||||
use crate::working_directory::LocalGitWorktreeMaterializer;
|
use crate::working_directory::RuntimeGitCacheMaterializer;
|
||||||
use agen::Engine;
|
use agen::Engine;
|
||||||
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
||||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||||
@@ -2187,12 +2292,18 @@ mod tests {
|
|||||||
let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main");
|
let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main");
|
||||||
|
|
||||||
let before_restart =
|
let before_restart =
|
||||||
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None);
|
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None, None);
|
||||||
let adapter = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap();
|
let adapter = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap();
|
||||||
let (after_restore_kind, after_restore_workspace_id) = adapter
|
let (after_restore_kind, after_restore_workspace_id) = adapter
|
||||||
.run_on_adapter_runtime(async move {
|
.run_on_adapter_runtime(async move {
|
||||||
let after_restore =
|
let after_restore = backend.worker_context(
|
||||||
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None);
|
&worker_ref,
|
||||||
|
Some(&scope),
|
||||||
|
Some(&identity),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
let client = after_restore.client_handle();
|
let client = after_restore.client_handle();
|
||||||
Ok((
|
Ok((
|
||||||
client.kind().to_string(),
|
client.kind().to_string(),
|
||||||
@@ -2383,6 +2494,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let workspace_client = workspace_context.client_handle();
|
let workspace_client = workspace_context.client_handle();
|
||||||
self.observed_workspace_clients.lock().unwrap().push((
|
self.observed_workspace_clients.lock().unwrap().push((
|
||||||
@@ -2393,7 +2505,9 @@ mod tests {
|
|||||||
let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
|
let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
|
||||||
let worker = Worker::new(
|
let worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
Engine::new(self.client.clone()),
|
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
||||||
|
self.client.clone(),
|
||||||
|
),
|
||||||
store,
|
store,
|
||||||
workspace_context,
|
workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
@@ -2631,12 +2745,17 @@ mod tests {
|
|||||||
repository: WorkingDirectoryRepository {
|
repository: WorkingDirectoryRepository {
|
||||||
id: "repo-main".to_string(),
|
id: "repo-main".to_string(),
|
||||||
provider: "git".to_string(),
|
provider: "git".to_string(),
|
||||||
uri: ".".to_string(),
|
source: workspace_api::RepositorySource {
|
||||||
local_path: Some(repo.to_path_buf()),
|
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||||
|
uri: repo.display().to_string(),
|
||||||
|
},
|
||||||
|
source_revision: 1,
|
||||||
|
source_fingerprint: "sha256:test".to_string(),
|
||||||
selector: Some(RepositorySelector::from("HEAD")),
|
selector: Some(RepositorySelector::from("HEAD")),
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::RuntimeGitCache,
|
||||||
backend_workdir_id: None,
|
backend_workdir_id: None,
|
||||||
|
materialization: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2760,12 +2879,16 @@ mod tests {
|
|||||||
root.path(),
|
root.path(),
|
||||||
root.path(),
|
root.path(),
|
||||||
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||||
|
Default::default(),
|
||||||
|
Vec::new(),
|
||||||
);
|
);
|
||||||
let restored = runtime_local_workdir_session(
|
let restored = runtime_local_workdir_session(
|
||||||
"working-directory-42",
|
"working-directory-42",
|
||||||
root.path(),
|
root.path(),
|
||||||
root.path(),
|
root.path(),
|
||||||
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||||
|
Default::default(),
|
||||||
|
Vec::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(spawned.workdir().id().as_str(), "working-directory-42");
|
assert_eq!(spawned.workdir().id().as_str(), "working-directory-42");
|
||||||
@@ -2781,7 +2904,7 @@ mod tests {
|
|||||||
archive: bundle.profile_source_archive.clone().unwrap(),
|
archive: bundle.profile_source_archive.clone().unwrap(),
|
||||||
};
|
};
|
||||||
factory
|
factory
|
||||||
.resolve_profile_source_archive(&source)
|
.resolve_profile_source_archive(&source, None)
|
||||||
.await
|
.await
|
||||||
.expect("embedded archive should resolve without Backend resource client");
|
.expect("embedded archive should resolve without Backend resource client");
|
||||||
}
|
}
|
||||||
@@ -3120,14 +3243,17 @@ mod tests {
|
|||||||
matches!(
|
matches!(
|
||||||
entry,
|
entry,
|
||||||
LogEntry::UserInput { segments, .. }
|
LogEntry::UserInput { segments, .. }
|
||||||
|
| LogEntry::AnnotatedUserInput { segments, .. }
|
||||||
if segments == &vec![Segment::text("start the ticket")]
|
if segments == &vec![Segment::text("start the ticket")]
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
let submission_id = entries
|
let submission_id = entries
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|entry| {
|
.find_map(|entry| {
|
||||||
let LogEntry::UserInput { extensions, .. } = entry else {
|
let extensions = match entry {
|
||||||
return None;
|
LogEntry::UserInput { extensions, .. }
|
||||||
|
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||||
|
_ => return None,
|
||||||
};
|
};
|
||||||
extensions
|
extensions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -3237,7 +3363,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let backend = WorkerRuntimeExecutionBackend::new(factory)
|
let backend = WorkerRuntimeExecutionBackend::new(factory)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||||
runtime_base.path(),
|
runtime_base.path(),
|
||||||
));
|
));
|
||||||
let runtime =
|
let runtime =
|
||||||
@@ -3392,7 +3518,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let backend = WorkerRuntimeExecutionBackend::new(factory)
|
let backend = WorkerRuntimeExecutionBackend::new(factory)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||||
runtime_base.path(),
|
runtime_base.path(),
|
||||||
));
|
));
|
||||||
let runtime =
|
let runtime =
|
||||||
@@ -3431,7 +3557,7 @@ mod tests {
|
|||||||
let repo = create_clean_repo();
|
let repo = create_clean_repo();
|
||||||
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
|
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||||
runtime_base.path(),
|
runtime_base.path(),
|
||||||
));
|
));
|
||||||
let runtime =
|
let runtime =
|
||||||
@@ -3467,7 +3593,7 @@ mod tests {
|
|||||||
let repo = create_clean_repo();
|
let repo = create_clean_repo();
|
||||||
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
|
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||||
runtime_base.path(),
|
runtime_base.path(),
|
||||||
));
|
));
|
||||||
let runtime =
|
let runtime =
|
||||||
@@ -3481,9 +3607,15 @@ mod tests {
|
|||||||
|
|
||||||
assert!(format!("{error:?}").contains("spawn failed"));
|
assert!(format!("{error:?}").contains("spawn failed"));
|
||||||
let working_directories_root = runtime_base.path();
|
let working_directories_root = runtime_base.path();
|
||||||
let remaining_entries = fs::read_dir(working_directories_root)
|
let remaining_workdirs = fs::read_dir(working_directories_root)
|
||||||
.map(|entries| entries.count())
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
|
||||||
|
.count()
|
||||||
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
assert_eq!(remaining_entries, 0);
|
assert_eq!(remaining_workdirs, 0);
|
||||||
|
assert!(working_directories_root.join(".repository-cache").is_dir());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ use worker::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
|
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
|
||||||
WORKER_REMOVE_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
|
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION,
|
||||||
|
WORKSPACE_REQUEST_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
|
||||||
WorkerMutationSourceClaims, new_token_id,
|
WorkerMutationSourceClaims, new_token_id,
|
||||||
};
|
};
|
||||||
use crate::runtime::RuntimeWorkspaceScope;
|
use crate::runtime::RuntimeWorkspaceScope;
|
||||||
@@ -289,6 +290,8 @@ pub struct RuntimeOwnedWorkspaceClient {
|
|||||||
worker_id: String,
|
worker_id: String,
|
||||||
request_timeout: Option<Duration>,
|
request_timeout: Option<Duration>,
|
||||||
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
||||||
|
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
||||||
|
request_source_audience: Option<String>,
|
||||||
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +309,8 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
worker_id: worker_id.into(),
|
worker_id: worker_id.into(),
|
||||||
request_timeout: None,
|
request_timeout: None,
|
||||||
worker_remove: None,
|
worker_remove: None,
|
||||||
|
request_source_signer: None,
|
||||||
|
request_source_audience: None,
|
||||||
prompt_projection_cache: None,
|
prompt_projection_cache: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +320,16 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_runtime_request_source(
|
||||||
|
mut self,
|
||||||
|
identity: &RuntimeIdentityMaterial,
|
||||||
|
audience: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
|
||||||
|
self.request_source_audience = Some(audience.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn with_prompt_projection_cache(
|
pub(crate) fn with_prompt_projection_cache(
|
||||||
mut self,
|
mut self,
|
||||||
cache: Arc<WorkspacePromptProjectionCache>,
|
cache: Arc<WorkspacePromptProjectionCache>,
|
||||||
@@ -363,15 +378,21 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
let base_url = self.base_url.clone();
|
let base_url = self.base_url.clone();
|
||||||
|
let workspace_id = self.workspace_id.clone();
|
||||||
let runtime_id = self.runtime_id.clone();
|
let runtime_id = self.runtime_id.clone();
|
||||||
let worker_id = self.worker_id.clone();
|
let worker_id = self.worker_id.clone();
|
||||||
|
let request_source_signer = self.request_source_signer.clone();
|
||||||
|
let request_source_audience = self.request_source_audience.clone();
|
||||||
let request_timeout = self.request_timeout;
|
let request_timeout = self.request_timeout;
|
||||||
if tokio::runtime::Handle::try_current().is_ok() {
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
execute_runtime_owned_workspace_http(
|
execute_runtime_owned_workspace_http(
|
||||||
&base_url,
|
&base_url,
|
||||||
|
&workspace_id,
|
||||||
&runtime_id,
|
&runtime_id,
|
||||||
&worker_id,
|
&worker_id,
|
||||||
|
request_source_signer.as_ref(),
|
||||||
|
request_source_audience.as_deref(),
|
||||||
request_timeout,
|
request_timeout,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
@@ -383,8 +404,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
} else {
|
} else {
|
||||||
execute_runtime_owned_workspace_http(
|
execute_runtime_owned_workspace_http(
|
||||||
&self.base_url,
|
&self.base_url,
|
||||||
|
&self.workspace_id,
|
||||||
&self.runtime_id,
|
&self.runtime_id,
|
||||||
&self.worker_id,
|
&self.worker_id,
|
||||||
|
self.request_source_signer.as_ref(),
|
||||||
|
self.request_source_audience.as_deref(),
|
||||||
self.request_timeout,
|
self.request_timeout,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
@@ -484,8 +508,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
|
|
||||||
fn execute_runtime_owned_workspace_http(
|
fn execute_runtime_owned_workspace_http(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
|
workspace_id: &str,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
|
request_source_signer: Option<&RuntimeRequestSourceSigner>,
|
||||||
|
request_source_audience: Option<&str>,
|
||||||
request_timeout: Option<Duration>,
|
request_timeout: Option<Duration>,
|
||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
@@ -510,11 +537,33 @@ fn execute_runtime_owned_workspace_http(
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let request_label = format!("{method} {}", request.path);
|
let request_label = format!("{method} {}", request.path);
|
||||||
|
let body = request.body.unwrap_or_default();
|
||||||
let mut request_builder = client
|
let mut request_builder = client
|
||||||
.request(method, url)
|
.request(method.clone(), url)
|
||||||
.header("x-yoi-runtime-id", runtime_id)
|
.header("x-yoi-runtime-id", runtime_id)
|
||||||
.header("x-yoi-worker-id", worker_id);
|
.header("x-yoi-worker-id", worker_id);
|
||||||
if let Some(body) = request.body {
|
if let Some(signer) = request_source_signer {
|
||||||
|
let audience = request_source_audience.ok_or_else(|| {
|
||||||
|
WorkspaceClientError::Request(
|
||||||
|
"runtime request proof audience is unavailable".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let proof = signer
|
||||||
|
.issue(
|
||||||
|
audience,
|
||||||
|
workspace_id,
|
||||||
|
Some(worker_id),
|
||||||
|
WORKSPACE_REQUEST_PERMISSION,
|
||||||
|
method.as_str(),
|
||||||
|
&request.path,
|
||||||
|
body.as_bytes(),
|
||||||
|
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||||
|
request_builder = request_builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
|
||||||
|
}
|
||||||
|
if !body.is_empty() {
|
||||||
request_builder = request_builder
|
request_builder = request_builder
|
||||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
.body(body);
|
.body(body);
|
||||||
@@ -590,8 +639,8 @@ fn unix_now_seconds() -> u64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
|
WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
|
||||||
verify_worker_mutation_source_proof,
|
decode_worker_mutation_source_claims, verify_worker_mutation_source_proof,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -797,7 +846,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() {
|
fn ordinary_workspace_forwarding_stamps_runtime_identity_and_signs_path_and_query() {
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -817,21 +866,41 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||||
let client = RuntimeOwnedWorkspaceClient::new(
|
let client = RuntimeOwnedWorkspaceClient::new(
|
||||||
"workspace-a",
|
"workspace-a",
|
||||||
format!("http://{address}"),
|
format!("http://{address}"),
|
||||||
"runtime-a",
|
"runtime-a",
|
||||||
"worker-a",
|
"worker-a",
|
||||||
);
|
)
|
||||||
|
.with_runtime_request_source(&identity, "server-a");
|
||||||
let response = client
|
let response = client
|
||||||
.execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search"))
|
.execute(WorkspaceRequest::get(
|
||||||
|
"/api/w/workspace-a/tickets/search?state=planning&limit=20",
|
||||||
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status, 200);
|
assert_eq!(response.status, 200);
|
||||||
server.join().unwrap();
|
server.join().unwrap();
|
||||||
let request = received.lock().unwrap().to_ascii_lowercase();
|
let request = received.lock().unwrap().clone();
|
||||||
assert!(request.contains("x-yoi-runtime-id: runtime-a"));
|
let lowercase_request = request.to_ascii_lowercase();
|
||||||
assert!(request.contains("x-yoi-worker-id: worker-a"));
|
assert!(lowercase_request.contains("x-yoi-runtime-id: runtime-a"));
|
||||||
assert!(!request.contains("authorization:"));
|
assert!(lowercase_request.contains("x-yoi-worker-id: worker-a"));
|
||||||
|
assert!(lowercase_request.contains("x-yoi-runtime-request-proof: yoi-runtime-request-v1."));
|
||||||
|
assert!(!lowercase_request.contains("authorization:"));
|
||||||
|
let token = request
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| {
|
||||||
|
line.split_once(':').and_then(|(name, value)| {
|
||||||
|
name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
|
||||||
|
.then(|| value.trim())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.expect("runtime proof header");
|
||||||
|
let claims = decode_runtime_request_source_claims(token).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
claims.path,
|
||||||
|
"/api/w/workspace-a/tickets/search?state=planning&limit=20"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -70,7 +70,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Extract the assistant's reply from history
|
// 5. Extract the assistant's reply from history
|
||||||
let history = worker.engine().history();
|
let history = worker.history();
|
||||||
if let Some(text) = history
|
if let Some(text) = history
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use crate::compact::token_counter::{
|
|||||||
EstimateSource, savings_for_prune_impl, token_estimates_for_prune_impl,
|
EstimateSource, savings_for_prune_impl, token_estimates_for_prune_impl,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl<C: LlmClient, St: Store> Worker<C, St> {
|
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||||
/// Enable prune projection on the underlying Engine.
|
/// Enable prune projection on the underlying Engine.
|
||||||
///
|
///
|
||||||
/// Registers the config and token/savings-estimator closures on the Engine.
|
/// Registers the config and token/savings-estimator closures on the Engine.
|
||||||
|
|||||||
@@ -242,13 +242,13 @@ pub(crate) fn savings_for_prune_impl(
|
|||||||
|
|
||||||
// ── Worker に生やす公開 API ───────────────────────────────────────────────
|
// ── Worker に生やす公開 API ───────────────────────────────────────────────
|
||||||
|
|
||||||
impl<C: LlmClient, St: Store> Worker<C, St> {
|
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||||
/// 現在の history 全体の推定トークン数。
|
/// 現在の history 全体の推定トークン数。
|
||||||
///
|
///
|
||||||
/// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。
|
/// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。
|
||||||
pub fn total_tokens(&self) -> TokenEstimate {
|
pub fn total_tokens(&self) -> TokenEstimate {
|
||||||
let usage = self.usage_history();
|
let usage = self.usage_history();
|
||||||
agen::token_counter::total_tokens(self.history(), &usage)
|
agen::token_counter::total_tokens(&self.history(), &usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 任意の history index 時点でのプロンプト全長推定。
|
/// 任意の history index 時点でのプロンプト全長推定。
|
||||||
@@ -259,7 +259,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
/// pointer 以降に増えたプロンプト長を測るのに使う。
|
/// pointer 以降に増えたプロンプト長を測るのに使う。
|
||||||
pub fn total_tokens_at(&self, history_len: usize) -> TokenEstimate {
|
pub fn total_tokens_at(&self, history_len: usize) -> TokenEstimate {
|
||||||
let usage = self.usage_history();
|
let usage = self.usage_history();
|
||||||
agen::token_counter::total_tokens_at(self.history(), &usage, history_len)
|
agen::token_counter::total_tokens_at(&self.history(), &usage, history_len)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 末尾から `retained` トークン以上を残すための分割位置。
|
/// 末尾から `retained` トークン以上を残すための分割位置。
|
||||||
@@ -267,7 +267,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
/// `history[..cut.index]` が要約/破棄される側、`history[cut.index..]` が残る側。
|
/// `history[..cut.index]` が要約/破棄される側、`history[cut.index..]` が残る側。
|
||||||
pub fn split_for_retained(&self, retained: u64) -> SplitPoint {
|
pub fn split_for_retained(&self, retained: u64) -> SplitPoint {
|
||||||
let usage = self.usage_history();
|
let usage = self.usage_history();
|
||||||
split_for_retained_impl(self.history(), &usage, retained)
|
split_for_retained_impl(&self.history(), &usage, retained)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,11 @@ use workdir::LocalWorkdirSession;
|
|||||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
||||||
|
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use crate::fs_view::ReadRequirement;
|
use crate::feature::{
|
||||||
#[cfg(test)]
|
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||||
use crate::fs_view::slice_lines;
|
ToolDeclaration,
|
||||||
use crate::session_capture::{
|
|
||||||
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionCapture, ToolPart,
|
|
||||||
};
|
};
|
||||||
|
use crate::fs_view::ReadRequirement;
|
||||||
|
|
||||||
/// Aggregated output of a compact worker run.
|
/// Aggregated output of a compact worker run.
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
@@ -91,248 +90,19 @@ struct SummaryParams {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input to `search_session_log`.
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
struct SearchSessionParams {
|
|
||||||
/// Case-insensitive substring to search in compact-target history.
|
|
||||||
pub query: String,
|
|
||||||
/// 0-based item offset to start searching from.
|
|
||||||
#[serde(default)]
|
|
||||||
pub offset: Option<usize>,
|
|
||||||
/// Maximum number of hits to return.
|
|
||||||
#[serde(default)]
|
|
||||||
pub limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Input to `read_session_items`.
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
struct ReadSessionParams {
|
|
||||||
/// 0-based compact-target history item offset.
|
|
||||||
pub offset: usize,
|
|
||||||
/// Maximum number of items to return.
|
|
||||||
pub limit: usize,
|
|
||||||
/// `compact` omits tool arguments/full results; `full` includes message text and tool result content.
|
|
||||||
#[serde(default = "default_session_read_mode")]
|
|
||||||
pub mode: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_session_read_mode() -> String {
|
|
||||||
"compact".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_TOOL_MAX_OUTPUT_TOKENS: u64 = 12_000;
|
|
||||||
const SESSION_SEARCH_MAX_RESULTS: usize = 50;
|
|
||||||
const SESSION_READ_MAX_ITEMS: usize = 80;
|
|
||||||
|
|
||||||
const MARK_DESCRIPTION: &str = "Inject a file's contents into the compacted context so the \
|
const MARK_DESCRIPTION: &str = "Inject a file's contents into the compacted context so the \
|
||||||
next session starts with it already read. Use this for files the next task needs in full. \
|
next session starts with it already read. Use this for files the next task needs in full. \
|
||||||
Optionally specify `offset` (0-based line) and `limit` (line count) to inject only a slice. \
|
Optionally specify `offset` (0-based line) and `limit` (line count) to inject only a slice. \
|
||||||
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded. \
|
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded.";
|
||||||
Paths must be absolute.";
|
|
||||||
|
|
||||||
const REFERENCE_DESCRIPTION: &str = "Record a file path as a named reference in the compacted \
|
const REFERENCE_DESCRIPTION: &str = "Record a Workdir-relative file path as a named reference in \
|
||||||
context without injecting its contents. Use for files that are contextually relevant but \
|
the compacted context without injecting its contents. Use for files that are contextually \
|
||||||
whose current content the next session can fetch on demand.";
|
relevant but whose current content the next session can fetch on demand.";
|
||||||
|
|
||||||
const SUMMARY_DESCRIPTION: &str = "Provide the final structured summary text. Subsequent calls \
|
const SUMMARY_DESCRIPTION: &str = "Provide the final structured summary text. Subsequent calls \
|
||||||
replace the previous content; only the last call is used. Must be called before the compact run \
|
replace the previous content; only the last call is used. Must be called before the compact run \
|
||||||
ends or compaction fails.";
|
ends or compaction fails.";
|
||||||
|
|
||||||
const SEARCH_SESSION_DESCRIPTION: &str = "Search the compact-target session history by \
|
|
||||||
case-insensitive substring. Returns item indexes and compact snippets. Use this when the initial \
|
|
||||||
overview is not enough to identify which part of the session matters. Results are bounded; narrow \
|
|
||||||
the query if important details are omitted.";
|
|
||||||
|
|
||||||
const READ_SESSION_DESCRIPTION: &str = "Read a bounded range of compact-target session history \
|
|
||||||
items by 0-based index. mode='compact' omits tool arguments, full tool results, and reasoning \
|
|
||||||
bodies; mode='full' includes message text and tool result content but still remains bounded. Use \
|
|
||||||
this to verify details before writing the summary.";
|
|
||||||
|
|
||||||
struct SessionLogToolState {
|
|
||||||
items: Arc<Vec<Item>>,
|
|
||||||
view: SessionCapture,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SearchSessionLogTool {
|
|
||||||
state: Arc<SessionLogToolState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for SearchSessionLogTool {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
_ctx: agen::tool::ToolExecutionContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let params: SearchSessionParams = serde_json::from_str(input_json).map_err(|e| {
|
|
||||||
ToolError::InvalidArgument(format!("invalid search_session_log input: {e}"))
|
|
||||||
})?;
|
|
||||||
let query = params.query.trim().to_lowercase();
|
|
||||||
if query.is_empty() {
|
|
||||||
return Err(ToolError::InvalidArgument(
|
|
||||||
"search_session_log query must not be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let offset = params.offset.unwrap_or(0);
|
|
||||||
let limit = params
|
|
||||||
.limit
|
|
||||||
.unwrap_or(20)
|
|
||||||
.clamp(1, SESSION_SEARCH_MAX_RESULTS);
|
|
||||||
let hits = self.state.view.search(&SearchOptions {
|
|
||||||
query: params.query.clone(),
|
|
||||||
kind: None,
|
|
||||||
tool_part: None,
|
|
||||||
tool_name: None,
|
|
||||||
limit: Some(limit),
|
|
||||||
min_entry_index: Some(offset as u64),
|
|
||||||
from: None,
|
|
||||||
through: None,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
let blocks = hits
|
|
||||||
.iter()
|
|
||||||
.map(|hit| {
|
|
||||||
let part = hit
|
|
||||||
.tool_part
|
|
||||||
.map(|part| format!(" {part:?}"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let tool = hit
|
|
||||||
.tool_name
|
|
||||||
.as_ref()
|
|
||||||
.map(|name| format!(" {name}"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
format!(
|
|
||||||
"[{} {}{}{} {:?}] {}\n{}",
|
|
||||||
hit.id,
|
|
||||||
hit.kind.as_str(),
|
|
||||||
part,
|
|
||||||
tool,
|
|
||||||
hit.entry_range,
|
|
||||||
hit.label,
|
|
||||||
hit.summary
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut content = blocks.join("\n\n");
|
|
||||||
let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
|
||||||
let summary = if hits.is_empty() {
|
|
||||||
format!("No session log hits for {query:?} from item offset {offset}.")
|
|
||||||
} else if truncated {
|
|
||||||
format!(
|
|
||||||
"Found {} session log hit(s) for {query:?}; output truncated. Narrow the query.",
|
|
||||||
hits.len()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("Found {} session log hit(s) for {query:?}.", hits.len())
|
|
||||||
};
|
|
||||||
Ok(ToolOutput {
|
|
||||||
summary,
|
|
||||||
content: (!content.is_empty()).then_some(content),
|
|
||||||
|
|
||||||
attachments: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ReadSessionItemsTool {
|
|
||||||
state: Arc<SessionLogToolState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for ReadSessionItemsTool {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
_ctx: agen::tool::ToolExecutionContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let params: ReadSessionParams = serde_json::from_str(input_json).map_err(|e| {
|
|
||||||
ToolError::InvalidArgument(format!("invalid read_session_items input: {e}"))
|
|
||||||
})?;
|
|
||||||
let mode = SessionReadMode::parse(¶ms.mode)?;
|
|
||||||
let offset = params.offset.min(self.state.items.len());
|
|
||||||
let limit = params.limit.clamp(1, SESSION_READ_MAX_ITEMS);
|
|
||||||
let end = offset.saturating_add(limit).min(self.state.items.len());
|
|
||||||
let detail = match mode {
|
|
||||||
SessionReadMode::Compact => ReadDetail::Compact,
|
|
||||||
SessionReadMode::Full => ReadDetail::Full,
|
|
||||||
};
|
|
||||||
let read = if offset >= end {
|
|
||||||
crate::session_capture::ReadResult {
|
|
||||||
entries: Vec::new(),
|
|
||||||
truncated: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.state.view.read(
|
|
||||||
ReadSelector::EntryRange([offset as u64, end.saturating_sub(1) as u64]),
|
|
||||||
ReadOptions {
|
|
||||||
include_tools: true,
|
|
||||||
tool_part: ToolPart::Both,
|
|
||||||
detail,
|
|
||||||
max_items: limit,
|
|
||||||
max_bytes: 48 * 1024,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let mut content = read
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| entry.text.clone())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n\n");
|
|
||||||
let token_truncated =
|
|
||||||
truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
|
||||||
let truncated = read.truncated || token_truncated;
|
|
||||||
let summary = if truncated {
|
|
||||||
format!(
|
|
||||||
"Read session items {offset}..{end} in {mode:?} mode; output truncated. Narrow the range."
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("Read session items {offset}..{end} in {mode:?} mode.")
|
|
||||||
};
|
|
||||||
Ok(ToolOutput {
|
|
||||||
summary,
|
|
||||||
content: (!content.is_empty()).then_some(content),
|
|
||||||
|
|
||||||
attachments: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum SessionReadMode {
|
|
||||||
Compact,
|
|
||||||
Full,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionReadMode {
|
|
||||||
fn parse(value: &str) -> Result<Self, ToolError> {
|
|
||||||
match value {
|
|
||||||
"compact" => Ok(Self::Compact),
|
|
||||||
"full" => Ok(Self::Full),
|
|
||||||
other => Err(ToolError::InvalidArgument(format!(
|
|
||||||
"invalid read_session_items mode {other:?}; expected 'compact' or 'full'"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_to_token_budget(text: &mut String, max_tokens: u64) -> bool {
|
|
||||||
let max_bytes = max_tokens.saturating_mul(4) as usize;
|
|
||||||
if text.len() <= max_bytes {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let mut cut = 0;
|
|
||||||
for (idx, _) in text.char_indices() {
|
|
||||||
if idx > max_bytes {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
cut = idx;
|
|
||||||
}
|
|
||||||
text.truncate(cut);
|
|
||||||
text.push_str("\n… [session tool output truncated]");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MarkReadRequiredTool {
|
struct MarkReadRequiredTool {
|
||||||
session: WorkdirSessionHandle,
|
session: WorkdirSessionHandle,
|
||||||
ctx: Arc<Mutex<CompactWorkerContext>>,
|
ctx: Arc<Mutex<CompactWorkerContext>>,
|
||||||
@@ -508,36 +278,63 @@ pub(crate) fn write_summary_tool(ctx: Arc<Mutex<CompactWorkerContext>>) -> ToolD
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
#[derive(Clone)]
|
||||||
let view = SessionCapture::new("compact-target", (*items).clone());
|
pub(crate) struct CompactionOutputFeature {
|
||||||
let state = Arc::new(SessionLogToolState { items, view });
|
session: Option<WorkdirSessionHandle>,
|
||||||
Arc::new(move || {
|
tracker: tools::Tracker,
|
||||||
let schema = schemars::schema_for!(SearchSessionParams);
|
context: Arc<Mutex<CompactWorkerContext>>,
|
||||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
|
||||||
let meta = ToolMeta::new("search_session_log")
|
|
||||||
.description(SEARCH_SESSION_DESCRIPTION)
|
|
||||||
.input_schema(schema_value);
|
|
||||||
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
|
|
||||||
state: state.clone(),
|
|
||||||
});
|
|
||||||
(meta, tool)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
impl CompactionOutputFeature {
|
||||||
let view = SessionCapture::new("compact-target", (*items).clone());
|
pub(crate) fn new(
|
||||||
let state = Arc::new(SessionLogToolState { items, view });
|
session: Option<WorkdirSessionHandle>,
|
||||||
Arc::new(move || {
|
tracker: tools::Tracker,
|
||||||
let schema = schemars::schema_for!(ReadSessionParams);
|
context: Arc<Mutex<CompactWorkerContext>>,
|
||||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
) -> Self {
|
||||||
let meta = ToolMeta::new("read_session_items")
|
Self {
|
||||||
.description(READ_SESSION_DESCRIPTION)
|
session,
|
||||||
.input_schema(schema_value);
|
tracker,
|
||||||
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
|
context,
|
||||||
state: state.clone(),
|
}
|
||||||
});
|
}
|
||||||
(meta, tool)
|
}
|
||||||
})
|
|
||||||
|
impl FeatureModule for CompactionOutputFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
let descriptor = FeatureDescriptor::builtin("compaction-output", "Compaction Output")
|
||||||
|
.with_description("Read-only Workdir access and bounded compaction output decisions.")
|
||||||
|
.with_tool(ToolDeclaration::new("add_reference", REFERENCE_DESCRIPTION))
|
||||||
|
.with_tool(ToolDeclaration::new("write_summary", SUMMARY_DESCRIPTION));
|
||||||
|
if self.session.is_some() {
|
||||||
|
descriptor
|
||||||
|
.with_tool(ToolDeclaration::new("Read", "Read a Workdir file."))
|
||||||
|
.with_tool(ToolDeclaration::new("mark_read_required", MARK_DESCRIPTION))
|
||||||
|
} else {
|
||||||
|
descriptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
if let Some(session) = &self.session {
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
"Read",
|
||||||
|
tools::read_tool(session.clone(), self.tracker.clone()),
|
||||||
|
))?;
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
"mark_read_required",
|
||||||
|
mark_read_required_tool(session.clone(), self.context.clone()),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
"add_reference",
|
||||||
|
add_reference_tool(self.context.clone()),
|
||||||
|
))?;
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
"write_summary",
|
||||||
|
write_summary_tool(self.context.clone()),
|
||||||
|
))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interceptor that monitors compact-worker context occupancy.
|
/// Interceptor that monitors compact-worker context occupancy.
|
||||||
@@ -810,53 +607,4 @@ mod tests {
|
|||||||
assert_eq!(guard.references.len(), 1);
|
assert_eq!(guard.references.len(), 1);
|
||||||
assert_eq!(guard.references[0], PathBuf::from(p));
|
assert_eq!(guard.references[0], PathBuf::from(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn search_session_log_returns_bounded_hits_without_full_tool_content() {
|
|
||||||
let items = Arc::new(vec![
|
|
||||||
Item::user_message("investigate compact failure"),
|
|
||||||
Item::tool_result_with_content(
|
|
||||||
"call-1",
|
|
||||||
"read trace with compact failure",
|
|
||||||
"very large raw trace body with secret detail",
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
let view = SessionCapture::new("test", (*items).clone());
|
|
||||||
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
|
|
||||||
state: Arc::new(SessionLogToolState { items, view }),
|
|
||||||
});
|
|
||||||
let input = serde_json::json!({ "query": "compact", "limit": 10 }).to_string();
|
|
||||||
let out = tool.execute(&input, Default::default()).await.unwrap();
|
|
||||||
let content = out.content.unwrap();
|
|
||||||
|
|
||||||
assert!(content.contains("investigate compact failure"));
|
|
||||||
assert!(content.contains("read trace with compact failure"));
|
|
||||||
assert!(!content.contains("secret detail"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_session_items_full_mode_can_read_tool_result_content() {
|
|
||||||
let items = Arc::new(vec![Item::tool_result_with_content(
|
|
||||||
"call-1",
|
|
||||||
"read trace",
|
|
||||||
"raw trace detail",
|
|
||||||
)]);
|
|
||||||
let view = SessionCapture::new("test", (*items).clone());
|
|
||||||
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
|
|
||||||
state: Arc::new(SessionLogToolState { items, view }),
|
|
||||||
});
|
|
||||||
let input = serde_json::json!({ "offset": 0, "limit": 1, "mode": "full" }).to_string();
|
|
||||||
let out = tool.execute(&input, Default::default()).await.unwrap();
|
|
||||||
let content = out.content.unwrap();
|
|
||||||
|
|
||||||
assert!(content.contains("raw trace detail"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slice_lines_handles_offset_and_limit() {
|
|
||||||
let text = "a\nb\nc\nd";
|
|
||||||
assert_eq!(slice_lines(text, 0, None), "a\nb\nc\nd");
|
|
||||||
assert_eq!(slice_lines(text, 1, Some(2)), "b\nc");
|
|
||||||
assert_eq!(slice_lines(text, 10, None), "");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ impl WorkerController {
|
|||||||
worker.attach_alerter(alerter.clone());
|
worker.attach_alerter(alerter.clone());
|
||||||
// Also hand the raw broadcast sender so Worker-internal operations
|
// Also hand the raw broadcast sender so Worker-internal operations
|
||||||
// can emit typed lifecycle `Event`s (currently: compact progress).
|
// can emit typed lifecycle `Event`s (currently: compact progress).
|
||||||
|
worker.attach_internal_worker_registry(spawned_registry.clone());
|
||||||
worker.attach_event_tx(event_tx.clone());
|
worker.attach_event_tx(event_tx.clone());
|
||||||
|
|
||||||
// Bash spills long outputs to a per-worker subdir under the runtime
|
// Bash spills long outputs to a per-worker subdir under the runtime
|
||||||
@@ -421,7 +422,6 @@ impl WorkerController {
|
|||||||
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
||||||
|
|
||||||
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
||||||
spawned_registry.attach_parent_protocol(event_tx.clone(), worker.session_id().to_string());
|
|
||||||
let fs_for_view = register_worker_tools(
|
let fs_for_view = register_worker_tools(
|
||||||
&mut worker,
|
&mut worker,
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
@@ -873,6 +873,14 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut feature_registry = FeatureRegistryBuilder::new();
|
let mut feature_registry = FeatureRegistryBuilder::new();
|
||||||
|
if feature_config.sub_worker.enabled {
|
||||||
|
feature_registry.add_module(
|
||||||
|
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||||
|
worker.workspace_client_handle(),
|
||||||
|
spawned_registry.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if feature_config.task.enabled {
|
if feature_config.task.enabled {
|
||||||
feature_registry.add_module(task_feature);
|
feature_registry.add_module(task_feature);
|
||||||
}
|
}
|
||||||
@@ -975,6 +983,14 @@ where
|
|||||||
|
|
||||||
if feature_config.sub_worker.enabled {
|
if feature_config.sub_worker.enabled {
|
||||||
worker.register_worker_orchestration_instruction();
|
worker.register_worker_orchestration_instruction();
|
||||||
|
if !feature_config.worker.enabled {
|
||||||
|
feature_registry.add_module(
|
||||||
|
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||||
|
worker.workspace_client_handle(),
|
||||||
|
spawned_registry.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||||
@@ -1744,7 +1760,7 @@ where
|
|||||||
|
|
||||||
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>)
|
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>)
|
||||||
where
|
where
|
||||||
C: LlmClient,
|
C: LlmClient + 'static,
|
||||||
St: Store,
|
St: Store,
|
||||||
{
|
{
|
||||||
match worker.list_rewind_targets() {
|
match worker.list_rewind_targets() {
|
||||||
@@ -1770,7 +1786,7 @@ fn apply_rewind<C, St>(
|
|||||||
expected_head_entries: usize,
|
expected_head_entries: usize,
|
||||||
) -> bool
|
) -> bool
|
||||||
where
|
where
|
||||||
C: LlmClient,
|
C: LlmClient + 'static,
|
||||||
St: Store,
|
St: Store,
|
||||||
{
|
{
|
||||||
match worker.rewind_to(target, expected_head_entries) {
|
match worker.rewind_to(target, expected_head_entries) {
|
||||||
@@ -1818,7 +1834,7 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
|
|||||||
|
|
||||||
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
|
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
|
||||||
where
|
where
|
||||||
C: LlmClient,
|
C: LlmClient + 'static,
|
||||||
St: Store,
|
St: Store,
|
||||||
{
|
{
|
||||||
let manifest = worker.manifest();
|
let manifest = worker.manifest();
|
||||||
|
|||||||
+622
-73
@@ -12,7 +12,7 @@
|
|||||||
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
||||||
|
|
||||||
use std::any::{Any, type_name};
|
use std::any::{Any, type_name};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -447,6 +447,16 @@ impl ServiceVersionReq {
|
|||||||
requirement: "*".into(),
|
requirement: "*".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn exact(version: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
requirement: version.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(&self, provider_version: &str) -> bool {
|
||||||
|
self.requirement == "*" || self.requirement == provider_version
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feature-provided service declaration. This first slice records provider
|
/// Feature-provided service declaration. This first slice records provider
|
||||||
@@ -1430,11 +1440,13 @@ impl FeatureInstallContext<'_> {
|
|||||||
pub struct FeatureRegistryInstallReport {
|
pub struct FeatureRegistryInstallReport {
|
||||||
pub reports: Vec<FeatureInstallReport>,
|
pub reports: Vec<FeatureInstallReport>,
|
||||||
pub services: FeatureServiceRegistry,
|
pub services: FeatureServiceRegistry,
|
||||||
|
pub plan_error: Option<FeaturePlanError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureRegistryInstallReport {
|
impl FeatureRegistryInstallReport {
|
||||||
pub fn has_errors(&self) -> bool {
|
pub fn has_errors(&self) -> bool {
|
||||||
self.reports.iter().any(|report| {
|
self.plan_error.is_some()
|
||||||
|
|| self.reports.iter().any(|report| {
|
||||||
report
|
report
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1443,17 +1455,19 @@ impl FeatureRegistryInstallReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn error_message(&self) -> String {
|
pub fn error_message(&self) -> String {
|
||||||
self.reports
|
let mut errors = self
|
||||||
|
.plan_error
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|report| {
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
errors.extend(self.reports.iter().flat_map(|report| {
|
||||||
report
|
report
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||||
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
|
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
|
||||||
})
|
}));
|
||||||
.collect::<Vec<_>>()
|
errors.join("; ")
|
||||||
.join("; ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn installed_tool_names(&self) -> Vec<String> {
|
pub fn installed_tool_names(&self) -> Vec<String> {
|
||||||
@@ -1485,12 +1499,265 @@ pub fn dedupe_instruction_contributions(
|
|||||||
deduped
|
deduped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PlannedServiceProvider {
|
||||||
|
pub service: ServiceId,
|
||||||
|
pub provider: FeatureId,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated, deterministic installation order and its selected service providers.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct FeatureInstallPlan {
|
||||||
|
ordered_features: Vec<FeatureId>,
|
||||||
|
service_providers: BTreeMap<ServiceId, PlannedServiceProvider>,
|
||||||
|
ordered_indices: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureInstallPlan {
|
||||||
|
pub fn ordered_features(&self) -> &[FeatureId] {
|
||||||
|
&self.ordered_features
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn service_providers(&self) -> &BTreeMap<ServiceId, PlannedServiceProvider> {
|
||||||
|
&self.service_providers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Error)]
|
||||||
|
pub enum FeaturePlanError {
|
||||||
|
#[error("feature `{feature}` is registered more than once")]
|
||||||
|
DuplicateFeature { feature: FeatureId },
|
||||||
|
#[error("service `{service}` has multiple selected providers: {providers:?}")]
|
||||||
|
AmbiguousServiceProvider {
|
||||||
|
service: ServiceId,
|
||||||
|
providers: Vec<FeatureId>,
|
||||||
|
},
|
||||||
|
#[error("feature `{consumer}` requires service `{service}`, but no provider is selected")]
|
||||||
|
MissingServiceProvider {
|
||||||
|
consumer: FeatureId,
|
||||||
|
service: ServiceId,
|
||||||
|
},
|
||||||
|
#[error(
|
||||||
|
"feature `{consumer}` requires service `{service}` version `{requirement}`, but provider `{provider}` provides `{provider_version}`"
|
||||||
|
)]
|
||||||
|
ServiceVersionMismatch {
|
||||||
|
consumer: FeatureId,
|
||||||
|
service: ServiceId,
|
||||||
|
requirement: String,
|
||||||
|
provider: FeatureId,
|
||||||
|
provider_version: String,
|
||||||
|
},
|
||||||
|
#[error(
|
||||||
|
"service dependency cycle involves features {features:?} through services {services:?}"
|
||||||
|
)]
|
||||||
|
ServiceDependencyCycle {
|
||||||
|
features: Vec<FeatureId>,
|
||||||
|
services: Vec<ServiceId>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// Builder/installer for enabled feature modules.
|
/// Builder/installer for enabled feature modules.
|
||||||
#[derive(Default)]
|
|
||||||
pub struct FeatureRegistryBuilder {
|
pub struct FeatureRegistryBuilder {
|
||||||
modules: Vec<Arc<dyn FeatureModule>>,
|
modules: Vec<Arc<dyn FeatureModule>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_feature_install_plan(
|
||||||
|
descriptors: &[FeatureDescriptor],
|
||||||
|
) -> Result<FeatureInstallPlan, FeaturePlanError> {
|
||||||
|
let mut feature_ids = BTreeSet::new();
|
||||||
|
for descriptor in descriptors {
|
||||||
|
if !feature_ids.insert(descriptor.id.clone()) {
|
||||||
|
return Err(FeaturePlanError::DuplicateFeature {
|
||||||
|
feature: descriptor.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut provider_candidates: BTreeMap<ServiceId, Vec<(usize, ServiceDeclaration)>> =
|
||||||
|
BTreeMap::new();
|
||||||
|
for (index, descriptor) in descriptors.iter().enumerate() {
|
||||||
|
for service in &descriptor.provides_services {
|
||||||
|
provider_candidates
|
||||||
|
.entry(service.id.clone())
|
||||||
|
.or_default()
|
||||||
|
.push((index, service.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut selected_providers = BTreeMap::new();
|
||||||
|
for (service, candidates) in provider_candidates {
|
||||||
|
if candidates.len() > 1 {
|
||||||
|
let mut providers = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|(index, _)| descriptors[*index].id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
providers.sort();
|
||||||
|
return Err(FeaturePlanError::AmbiguousServiceProvider { service, providers });
|
||||||
|
}
|
||||||
|
let (provider_index, declaration) = candidates
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("provider candidates are non-empty");
|
||||||
|
selected_providers.insert(service, (provider_index, declaration));
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = (0..descriptors.len()).collect::<BTreeSet<_>>();
|
||||||
|
let mut adjacency = vec![BTreeSet::new(); descriptors.len()];
|
||||||
|
let mut indegree = vec![0usize; descriptors.len()];
|
||||||
|
let mut edge_services: BTreeMap<(usize, usize), BTreeSet<ServiceId>> = BTreeMap::new();
|
||||||
|
|
||||||
|
for &consumer_index in &active {
|
||||||
|
let consumer = &descriptors[consumer_index];
|
||||||
|
for requirement in &consumer.requires_services {
|
||||||
|
let provider = selected_providers.get(&requirement.id);
|
||||||
|
let Some((provider_index, declaration)) = provider else {
|
||||||
|
if requirement.required {
|
||||||
|
return Err(FeaturePlanError::MissingServiceProvider {
|
||||||
|
consumer: consumer.id.clone(),
|
||||||
|
service: requirement.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !requirement.version.matches(&declaration.version) {
|
||||||
|
return Err(FeaturePlanError::ServiceVersionMismatch {
|
||||||
|
consumer: consumer.id.clone(),
|
||||||
|
service: requirement.id.clone(),
|
||||||
|
requirement: requirement.version.requirement.clone(),
|
||||||
|
provider: descriptors[*provider_index].id.clone(),
|
||||||
|
provider_version: declaration.version.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if adjacency[*provider_index].insert(consumer_index) {
|
||||||
|
indegree[consumer_index] += 1;
|
||||||
|
}
|
||||||
|
edge_services
|
||||||
|
.entry((*provider_index, consumer_index))
|
||||||
|
.or_default()
|
||||||
|
.insert(requirement.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ready = active
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|index| indegree[*index] == 0)
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut ordered_indices = Vec::with_capacity(active.len());
|
||||||
|
while let Some(index) = ready.pop_first() {
|
||||||
|
ordered_indices.push(index);
|
||||||
|
for &dependent in &adjacency[index] {
|
||||||
|
indegree[dependent] -= 1;
|
||||||
|
if indegree[dependent] == 0 {
|
||||||
|
ready.insert(dependent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ordered_indices.len() != active.len() {
|
||||||
|
let cycle_indices = find_service_dependency_cycle(&active, &adjacency);
|
||||||
|
let features = cycle_indices
|
||||||
|
.iter()
|
||||||
|
.map(|index| descriptors[*index].id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut services = BTreeSet::new();
|
||||||
|
for edge in cycle_indices.windows(2) {
|
||||||
|
if let Some(ids) = edge_services.get(&(edge[0], edge[1])) {
|
||||||
|
services.extend(ids.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (Some(first), Some(last)) = (cycle_indices.first(), cycle_indices.last())
|
||||||
|
&& let Some(ids) = edge_services.get(&(*last, *first))
|
||||||
|
{
|
||||||
|
services.extend(ids.iter().cloned());
|
||||||
|
}
|
||||||
|
return Err(FeaturePlanError::ServiceDependencyCycle {
|
||||||
|
features,
|
||||||
|
services: services.into_iter().collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let service_providers = selected_providers
|
||||||
|
.into_iter()
|
||||||
|
.map(|(service, (provider_index, declaration))| {
|
||||||
|
let planned = PlannedServiceProvider {
|
||||||
|
service: service.clone(),
|
||||||
|
provider: descriptors[provider_index].id.clone(),
|
||||||
|
version: declaration.version,
|
||||||
|
};
|
||||||
|
(service, planned)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let ordered_features = ordered_indices
|
||||||
|
.iter()
|
||||||
|
.map(|index| descriptors[*index].id.clone())
|
||||||
|
.collect();
|
||||||
|
Ok(FeatureInstallPlan {
|
||||||
|
ordered_features,
|
||||||
|
service_providers,
|
||||||
|
ordered_indices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_service_dependency_cycle(
|
||||||
|
active: &BTreeSet<usize>,
|
||||||
|
adjacency: &[BTreeSet<usize>],
|
||||||
|
) -> Vec<usize> {
|
||||||
|
fn visit(
|
||||||
|
index: usize,
|
||||||
|
active: &BTreeSet<usize>,
|
||||||
|
adjacency: &[BTreeSet<usize>],
|
||||||
|
state: &mut [u8],
|
||||||
|
stack: &mut Vec<usize>,
|
||||||
|
) -> Option<Vec<usize>> {
|
||||||
|
state[index] = 1;
|
||||||
|
stack.push(index);
|
||||||
|
for &next in &adjacency[index] {
|
||||||
|
if !active.contains(&next) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match state[next] {
|
||||||
|
0 => {
|
||||||
|
if let Some(cycle) = visit(next, active, adjacency, state, stack) {
|
||||||
|
return Some(cycle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let start = stack
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| *candidate == next)
|
||||||
|
.expect("visiting node is present in DFS stack");
|
||||||
|
return Some(stack[start..].to_vec());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.pop();
|
||||||
|
state[index] = 2;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = vec![0u8; adjacency.len()];
|
||||||
|
let mut stack = Vec::new();
|
||||||
|
for &index in active {
|
||||||
|
if state[index] == 0
|
||||||
|
&& let Some(cycle) = visit(index, active, adjacency, &mut state, &mut stack)
|
||||||
|
{
|
||||||
|
return cycle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FeatureRegistryBuilder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
modules: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FeatureRegistryBuilder {
|
impl FeatureRegistryBuilder {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
@@ -1523,10 +1790,14 @@ impl FeatureRegistryBuilder {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn plan(&self) -> Result<FeatureInstallPlan, FeaturePlanError> {
|
||||||
|
build_feature_install_plan(&self.descriptors())
|
||||||
|
}
|
||||||
|
|
||||||
/// Install modules into the existing Engine tool path and hook builder.
|
/// Install modules into the existing Engine tool path and hook builder.
|
||||||
pub(crate) fn install_into_engine<C: LlmClient>(
|
pub(crate) fn install_into_engine<C: LlmClient, A>(
|
||||||
self,
|
self,
|
||||||
worker: &mut Engine<C, Mutable>,
|
worker: &mut Engine<C, Mutable, A>,
|
||||||
hook_builder: &mut HookRegistryBuilder,
|
hook_builder: &mut HookRegistryBuilder,
|
||||||
) -> FeatureRegistryInstallReport {
|
) -> FeatureRegistryInstallReport {
|
||||||
let mut pending_tools = Vec::new();
|
let mut pending_tools = Vec::new();
|
||||||
@@ -1568,53 +1839,52 @@ impl FeatureRegistryBuilder {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|module| module.descriptor())
|
.map(|module| module.descriptor())
|
||||||
.collect();
|
.collect();
|
||||||
let mut service_registry = FeatureServiceRegistry::default();
|
let plan = match build_feature_install_plan(&descriptors) {
|
||||||
let mut reports = Vec::with_capacity(self.modules.len());
|
Ok(plan) => plan,
|
||||||
let mut seen_features = HashSet::new();
|
Err(error) => {
|
||||||
|
let message = error.to_string();
|
||||||
let mut pending_modules: Vec<_> = self.modules.into_iter().zip(descriptors).collect();
|
let reports = descriptors
|
||||||
let mut ordered_modules = Vec::with_capacity(pending_modules.len());
|
|
||||||
let mut declared_services = HashSet::new();
|
|
||||||
while !pending_modules.is_empty() {
|
|
||||||
let next = pending_modules
|
|
||||||
.iter()
|
.iter()
|
||||||
.position(|(_, descriptor)| {
|
.map(|descriptor| {
|
||||||
descriptor
|
let mut report = FeatureInstallReport::new(descriptor);
|
||||||
.requires_services
|
report
|
||||||
.iter()
|
.diagnostics
|
||||||
.filter(|requirement| requirement.required)
|
.push(FeatureDiagnostic::error(message.clone()));
|
||||||
.all(|requirement| declared_services.contains(&requirement.id))
|
report.mark_skipped(
|
||||||
})
|
FeatureContributionKind::Diagnostic,
|
||||||
.unwrap_or(0);
|
descriptor.id.to_string(),
|
||||||
let entry = pending_modules.remove(next);
|
"feature installation plan rejected before installation",
|
||||||
declared_services.extend(
|
|
||||||
entry
|
|
||||||
.1
|
|
||||||
.provides_services
|
|
||||||
.iter()
|
|
||||||
.map(|service| service.id.clone()),
|
|
||||||
);
|
);
|
||||||
ordered_modules.push(entry);
|
report
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
return FeatureRegistryInstallReport {
|
||||||
|
reports,
|
||||||
|
services: FeatureServiceRegistry::default(),
|
||||||
|
plan_error: Some(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let mut service_registry = FeatureServiceRegistry::default();
|
||||||
|
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
|
||||||
|
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
|
||||||
|
let ordered_modules = plan
|
||||||
|
.ordered_indices
|
||||||
|
.into_iter()
|
||||||
|
.map(|index| {
|
||||||
|
(
|
||||||
|
modules[index]
|
||||||
|
.take()
|
||||||
|
.expect("planned feature module is selected exactly once"),
|
||||||
|
descriptors[index].clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
for (module, descriptor) in ordered_modules {
|
for (module, descriptor) in ordered_modules {
|
||||||
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
||||||
let mut report = FeatureInstallReport::new(&descriptor);
|
let mut report = FeatureInstallReport::new(&descriptor);
|
||||||
|
|
||||||
if !seen_features.insert(descriptor.id.clone()) {
|
|
||||||
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
|
||||||
"duplicate feature id: {}",
|
|
||||||
descriptor.id
|
|
||||||
)));
|
|
||||||
report.mark_skipped(
|
|
||||||
FeatureContributionKind::Diagnostic,
|
|
||||||
descriptor.id.to_string(),
|
|
||||||
"duplicate feature id",
|
|
||||||
);
|
|
||||||
reports.push(report);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut required_service_failed = false;
|
let mut required_service_failed = false;
|
||||||
for requirement in descriptor.requires_services.iter().cloned() {
|
for requirement in descriptor.requires_services.iter().cloned() {
|
||||||
if service_registry.provides(&requirement.id) {
|
if service_registry.provides(&requirement.id) {
|
||||||
@@ -1681,6 +1951,7 @@ impl FeatureRegistryBuilder {
|
|||||||
FeatureRegistryInstallReport {
|
FeatureRegistryInstallReport {
|
||||||
reports,
|
reports,
|
||||||
services: service_registry,
|
services: service_registry,
|
||||||
|
plan_error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1821,6 +2092,59 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PlannedServiceFeature {
|
||||||
|
descriptor: FeatureDescriptor,
|
||||||
|
install_calls: Arc<AtomicUsize>,
|
||||||
|
fail_install: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlannedServiceFeature {
|
||||||
|
fn new(descriptor: FeatureDescriptor) -> Self {
|
||||||
|
Self {
|
||||||
|
descriptor,
|
||||||
|
install_calls: Arc::new(AtomicUsize::new(0)),
|
||||||
|
fail_install: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for PlannedServiceFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
self.descriptor.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(
|
||||||
|
&self,
|
||||||
|
context: &mut FeatureInstallContext<'_>,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
self.install_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if self.fail_install {
|
||||||
|
return Err(FeatureInstallError::Install(
|
||||||
|
"injected provider failure".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for service in &self.descriptor.provides_services {
|
||||||
|
context
|
||||||
|
.services()
|
||||||
|
.provide(service.clone(), Arc::new(service.id.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provided_service(id: &'static str, version: &'static str) -> ServiceDeclaration {
|
||||||
|
ServiceDeclaration::new(ServiceId::builtin(id), version, "test service")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_service(id: &'static str, version: ServiceVersionReq) -> ServiceRequirement {
|
||||||
|
{
|
||||||
|
let mut requirement =
|
||||||
|
ServiceRequirement::required(ServiceId::builtin(id), "test dependency");
|
||||||
|
requirement.version = version;
|
||||||
|
requirement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn instruction(id: &'static str, prompt_ref: &'static str) -> FeatureInstructionDeclaration {
|
fn instruction(id: &'static str, prompt_ref: &'static str) -> FeatureInstructionDeclaration {
|
||||||
FeatureInstructionDeclaration::new(
|
FeatureInstructionDeclaration::new(
|
||||||
FeatureInstructionId::builtin(id),
|
FeatureInstructionId::builtin(id),
|
||||||
@@ -1830,6 +2154,249 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_orders_provider_before_consumer_stably() {
|
||||||
|
let consumer = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("consumer", "Consumer")
|
||||||
|
.with_service_requirement(required_service("catalog", ServiceVersionReq::any())),
|
||||||
|
);
|
||||||
|
let unrelated =
|
||||||
|
PlannedServiceFeature::new(FeatureDescriptor::builtin("unrelated", "Unrelated"));
|
||||||
|
let provider = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider", "Provider")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
);
|
||||||
|
let builder = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(consumer)
|
||||||
|
.with_module(unrelated)
|
||||||
|
.with_module(provider);
|
||||||
|
|
||||||
|
let plan = builder.plan().expect("service graph should be valid");
|
||||||
|
assert_eq!(
|
||||||
|
plan.ordered_features(),
|
||||||
|
&[
|
||||||
|
FeatureId::builtin("unrelated"),
|
||||||
|
FeatureId::builtin("provider"),
|
||||||
|
FeatureId::builtin("consumer"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_orders_compatible_optional_provider_before_consumer() {
|
||||||
|
let service = ServiceId::builtin("catalog");
|
||||||
|
let consumer = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
|
||||||
|
ServiceRequirement::optional(service.clone(), "optional catalog"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let provider = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider", "Provider")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
);
|
||||||
|
let builder = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(consumer)
|
||||||
|
.with_module(provider);
|
||||||
|
|
||||||
|
let plan = builder.plan().expect("optional binding should be valid");
|
||||||
|
assert_eq!(
|
||||||
|
plan.ordered_features(),
|
||||||
|
&[
|
||||||
|
FeatureId::builtin("provider"),
|
||||||
|
FeatureId::builtin("consumer"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut hooks = HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
let report = builder.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
assert!(!report.has_errors());
|
||||||
|
let consumer = report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.find(|report| report.feature_id == FeatureId::builtin("consumer"))
|
||||||
|
.expect("consumer report");
|
||||||
|
assert!(
|
||||||
|
consumer
|
||||||
|
.resolved_service_requirements
|
||||||
|
.iter()
|
||||||
|
.any(|requirement| requirement.id == service)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_rejects_missing_required_service() {
|
||||||
|
let builder = FeatureRegistryBuilder::new().with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("consumer", "Consumer")
|
||||||
|
.with_service_requirement(required_service("missing", ServiceVersionReq::any())),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
builder.plan(),
|
||||||
|
Err(FeaturePlanError::MissingServiceProvider {
|
||||||
|
consumer: FeatureId::builtin("consumer"),
|
||||||
|
service: ServiceId::builtin("missing"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_rejects_ambiguous_provider_independent_of_registration_order() {
|
||||||
|
fn plan(reverse: bool) -> FeaturePlanError {
|
||||||
|
let provider_a = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider-a", "Provider A")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
);
|
||||||
|
let provider_b = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider-b", "Provider B")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
);
|
||||||
|
let mut builder = FeatureRegistryBuilder::new();
|
||||||
|
if reverse {
|
||||||
|
builder.add_module(provider_b).add_module(provider_a);
|
||||||
|
} else {
|
||||||
|
builder.add_module(provider_a).add_module(provider_b);
|
||||||
|
}
|
||||||
|
builder.plan().expect_err("duplicate providers must fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected = FeaturePlanError::AmbiguousServiceProvider {
|
||||||
|
service: ServiceId::builtin("catalog"),
|
||||||
|
providers: vec![
|
||||||
|
FeatureId::builtin("provider-a"),
|
||||||
|
FeatureId::builtin("provider-b"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(plan(false), expected);
|
||||||
|
assert_eq!(plan(true), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_rejects_service_version_mismatch() {
|
||||||
|
let builder = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
|
||||||
|
required_service("catalog", ServiceVersionReq::exact("2")),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider", "Provider")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
builder.plan(),
|
||||||
|
Err(FeaturePlanError::ServiceVersionMismatch {
|
||||||
|
consumer: FeatureId::builtin("consumer"),
|
||||||
|
service: ServiceId::builtin("catalog"),
|
||||||
|
requirement: "2".into(),
|
||||||
|
provider: FeatureId::builtin("provider"),
|
||||||
|
provider_version: "1".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_reports_service_cycle_members() {
|
||||||
|
let builder = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("alpha", "Alpha")
|
||||||
|
.with_provided_service(provided_service("alpha-service", "1"))
|
||||||
|
.with_service_requirement(required_service(
|
||||||
|
"beta-service",
|
||||||
|
ServiceVersionReq::any(),
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
.with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("beta", "Beta")
|
||||||
|
.with_provided_service(provided_service("beta-service", "1"))
|
||||||
|
.with_service_requirement(required_service(
|
||||||
|
"alpha-service",
|
||||||
|
ServiceVersionReq::any(),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
|
||||||
|
let error = builder.plan().expect_err("cycle must fail before install");
|
||||||
|
let FeaturePlanError::ServiceDependencyCycle { features, services } = error else {
|
||||||
|
panic!("unexpected plan error: {error:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
features.into_iter().collect::<BTreeSet<_>>(),
|
||||||
|
BTreeSet::from([FeatureId::builtin("alpha"), FeatureId::builtin("beta")])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
services.into_iter().collect::<BTreeSet<_>>(),
|
||||||
|
BTreeSet::from([
|
||||||
|
ServiceId::builtin("alpha-service"),
|
||||||
|
ServiceId::builtin("beta-service"),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_failure_has_no_install_side_effects_and_reports_identities() {
|
||||||
|
let first = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("first", "First")
|
||||||
|
.with_provided_service(provided_service("control", "1")),
|
||||||
|
);
|
||||||
|
let first_calls = Arc::clone(&first.install_calls);
|
||||||
|
let second = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("second", "Second")
|
||||||
|
.with_provided_service(provided_service("control", "1")),
|
||||||
|
);
|
||||||
|
let second_calls = Arc::clone(&second.install_calls);
|
||||||
|
let mut hook_builder = HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
|
||||||
|
let report = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(first)
|
||||||
|
.with_module(second)
|
||||||
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
|
|
||||||
|
assert!(report.has_errors());
|
||||||
|
assert!(matches!(
|
||||||
|
report.plan_error,
|
||||||
|
Some(FeaturePlanError::AmbiguousServiceProvider { .. })
|
||||||
|
));
|
||||||
|
assert!(report.error_message().contains("builtin:control"));
|
||||||
|
assert!(report.error_message().contains("builtin:first"));
|
||||||
|
assert!(report.error_message().contains("builtin:second"));
|
||||||
|
assert_eq!(first_calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(second_calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert!(pending_tools.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_install_failure_is_fatal_and_does_not_queue_tools() {
|
||||||
|
let provider_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let provider = PlannedServiceFeature {
|
||||||
|
descriptor: FeatureDescriptor::builtin("provider", "Provider")
|
||||||
|
.with_provided_service(provided_service("control", "1")),
|
||||||
|
install_calls: Arc::clone(&provider_calls),
|
||||||
|
fail_install: true,
|
||||||
|
};
|
||||||
|
let consumer = ToolFeature {
|
||||||
|
descriptor: FeatureDescriptor::builtin("consumer", "Consumer")
|
||||||
|
.with_service_requirement(required_service("control", ServiceVersionReq::any()))
|
||||||
|
.with_tool(ToolDeclaration::new("Dependent", "dependent tool")),
|
||||||
|
contribution_name: "Dependent",
|
||||||
|
model_visible_name: "Dependent",
|
||||||
|
};
|
||||||
|
let mut hook_builder = HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
|
||||||
|
let report = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(consumer)
|
||||||
|
.with_module(provider)
|
||||||
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
|
|
||||||
|
assert!(report.has_errors());
|
||||||
|
assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert!(pending_tools.is_empty());
|
||||||
|
assert!(report.error_message().contains("builtin:provider"));
|
||||||
|
assert!(report.error_message().contains("injected provider failure"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptor_contributions_are_recorded() {
|
fn descriptor_contributions_are_recorded() {
|
||||||
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
||||||
@@ -2382,10 +2949,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let consumer = FeatureDescriptor::builtin("consumer", "Consumer")
|
let consumer = FeatureDescriptor::builtin("consumer", "Consumer")
|
||||||
.with_service_requirement(ServiceRequirement::required(service.clone(), "needs demo"));
|
.with_service_requirement(ServiceRequirement::required(service.clone(), "needs demo"));
|
||||||
let missing_service = ServiceId::builtin("missing-service");
|
|
||||||
let missing = FeatureDescriptor::builtin("missing", "Missing").with_service_requirement(
|
|
||||||
ServiceRequirement::required(missing_service, "needs missing"),
|
|
||||||
);
|
|
||||||
let optional_service = ServiceId::builtin("optional-service");
|
let optional_service = ServiceId::builtin("optional-service");
|
||||||
let optional = FeatureDescriptor::builtin("optional", "Optional").with_service_requirement(
|
let optional = FeatureDescriptor::builtin("optional", "Optional").with_service_requirement(
|
||||||
ServiceRequirement::optional(optional_service, "nice to have"),
|
ServiceRequirement::optional(optional_service, "nice to have"),
|
||||||
@@ -2399,9 +2962,6 @@ mod tests {
|
|||||||
.with_module(ServiceFeature {
|
.with_module(ServiceFeature {
|
||||||
descriptor: consumer,
|
descriptor: consumer,
|
||||||
})
|
})
|
||||||
.with_module(ServiceFeature {
|
|
||||||
descriptor: missing,
|
|
||||||
})
|
|
||||||
.with_module(ServiceFeature {
|
.with_module(ServiceFeature {
|
||||||
descriptor: optional,
|
descriptor: optional,
|
||||||
})
|
})
|
||||||
@@ -2413,18 +2973,6 @@ mod tests {
|
|||||||
report.reports[1].resolved_service_requirements[0].id,
|
report.reports[1].resolved_service_requirements[0].id,
|
||||||
service
|
service
|
||||||
);
|
);
|
||||||
let missing_report = report
|
|
||||||
.reports
|
|
||||||
.iter()
|
|
||||||
.find(|feature| feature.feature_id == FeatureId::builtin("missing"))
|
|
||||||
.unwrap();
|
|
||||||
assert!(!missing_report.installed);
|
|
||||||
assert!(
|
|
||||||
missing_report
|
|
||||||
.diagnostics
|
|
||||||
.iter()
|
|
||||||
.any(|diagnostic| diagnostic.message.contains("required service requirement"))
|
|
||||||
);
|
|
||||||
let optional_report = report
|
let optional_report = report
|
||||||
.reports
|
.reports
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2543,11 +3091,12 @@ mod tests {
|
|||||||
.with_module(ServiceFeature { descriptor })
|
.with_module(ServiceFeature { descriptor })
|
||||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
assert!(report.has_errors());
|
assert!(report.has_errors());
|
||||||
assert!(
|
assert!(matches!(
|
||||||
report
|
report.plan_error,
|
||||||
.error_message()
|
Some(FeaturePlanError::MissingServiceProvider { .. })
|
||||||
.contains("required service requirement")
|
));
|
||||||
);
|
assert!(report.error_message().contains("builtin:consumer"));
|
||||||
|
assert!(report.error_message().contains("builtin:missing-service"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -560,6 +560,7 @@ where
|
|||||||
attempt.instance_id, attempt.checked_state_revision
|
attempt.instance_id, attempt.checked_state_revision
|
||||||
)),
|
)),
|
||||||
max_turns: Some(12),
|
max_turns: Some(12),
|
||||||
|
engine_configurator: None,
|
||||||
features,
|
features,
|
||||||
required_tools: &[
|
required_tools: &[
|
||||||
"ShowOverview",
|
"ShowOverview",
|
||||||
@@ -571,6 +572,7 @@ where
|
|||||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||||
filesystem: WorkerFilesystemAuthority::None,
|
filesystem: WorkerFilesystemAuthority::None,
|
||||||
scope: Scope::empty(),
|
scope: Scope::empty(),
|
||||||
|
workdir_session: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
match run_internal_worker(spec).await {
|
match run_internal_worker(spec).await {
|
||||||
|
|||||||
@@ -398,24 +398,35 @@ impl WorkspaceHttpWorkdirBackend {
|
|||||||
workdir_output(format!("Listed {count} Workdir(s)"), &response)
|
workdir_output(format!("Listed {count} Workdir(s)"), &response)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create(&self, input: WorkdirCreateInput) -> Result<ToolOutput, ToolError> {
|
fn create(
|
||||||
let runtime_id = validate_identity(&input.runtime_id, CREATE_TOOL, "runtime_id")?;
|
&self,
|
||||||
|
input: WorkdirCreateInput,
|
||||||
|
operation_id: String,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let runtime_id = input
|
||||||
|
.runtime_id
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| validate_identity(value, CREATE_TOOL, "runtime_id"))
|
||||||
|
.transpose()?;
|
||||||
let repository_id = validate_identity(&input.repository_id, CREATE_TOOL, "repository_id")?;
|
let repository_id = validate_identity(&input.repository_id, CREATE_TOOL, "repository_id")?;
|
||||||
let selector = validate_optional_selector(input.selector)?;
|
let selector = validate_optional_selector(input.selector)?;
|
||||||
let workspace_id = encode_path_segment(self.workspace_id()?);
|
let workspace_id = encode_path_segment(self.workspace_id()?);
|
||||||
let runtime_path = encode_path_segment(runtime_id);
|
|
||||||
let request = WorkdirCreateRequest {
|
let request = WorkdirCreateRequest {
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.map(str::to_string),
|
||||||
repository_id: repository_id.to_string(),
|
repository_id: repository_id.to_string(),
|
||||||
selector,
|
selector,
|
||||||
|
operation_id,
|
||||||
};
|
};
|
||||||
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json(
|
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json(
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
format!("/api/w/{workspace_id}/runtimes/{runtime_path}/working-directories"),
|
format!("/api/w/{workspace_id}/working-directories"),
|
||||||
serde_json::to_string(&request).map_err(decode_error)?,
|
serde_json::to_string(&request).map_err(decode_error)?,
|
||||||
))?;
|
))?;
|
||||||
workdir_output(
|
workdir_output(
|
||||||
format!("Created Workdir {}", response.item.working_directory_id),
|
format!(
|
||||||
|
"Created Workdir {} on Runtime {}",
|
||||||
|
response.item.working_directory_id, response.runtime_id
|
||||||
|
),
|
||||||
&response,
|
&response,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -518,16 +529,17 @@ impl Tool for WorkspaceHttpWorkdirTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
input_json: &str,
|
input_json: &str,
|
||||||
_ctx: ToolExecutionContext,
|
ctx: ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
match self.operation {
|
match self.operation {
|
||||||
WorkdirOperation::List => {
|
WorkdirOperation::List => {
|
||||||
let _input = parse_input::<WorkdirListInput>(input_json)?;
|
let _input = parse_input::<WorkdirListInput>(input_json)?;
|
||||||
self.backend.list()
|
self.backend.list()
|
||||||
}
|
}
|
||||||
WorkdirOperation::Create => self
|
WorkdirOperation::Create => self.backend.create(
|
||||||
.backend
|
parse_input::<WorkdirCreateInput>(input_json)?,
|
||||||
.create(parse_input::<WorkdirCreateInput>(input_json)?),
|
ctx.call_id.to_string(),
|
||||||
|
),
|
||||||
WorkdirOperation::Attach => self
|
WorkdirOperation::Attach => self
|
||||||
.backend
|
.backend
|
||||||
.attach(parse_input::<WorkdirAttachInput>(input_json)?),
|
.attach(parse_input::<WorkdirAttachInput>(input_json)?),
|
||||||
@@ -635,9 +647,9 @@ fn create_schema() -> serde_json::Value {
|
|||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["runtime_id", "repository_id"],
|
"required": ["repository_id"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"runtime_id": {"type": "string", "minLength": 1},
|
"runtime_id": {"type": ["string", "null"], "minLength": 1},
|
||||||
"repository_id": {"type": "string", "minLength": 1},
|
"repository_id": {"type": "string", "minLength": 1},
|
||||||
"selector": {"type": ["string", "null"], "minLength": 1}
|
"selector": {"type": ["string", "null"], "minLength": 1}
|
||||||
}
|
}
|
||||||
@@ -677,7 +689,8 @@ struct WorkdirListInput {}
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct WorkdirCreateInput {
|
struct WorkdirCreateInput {
|
||||||
runtime_id: String,
|
#[serde(default)]
|
||||||
|
runtime_id: Option<String>,
|
||||||
repository_id: String,
|
repository_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
selector: Option<String>,
|
selector: Option<String>,
|
||||||
@@ -685,10 +698,12 @@ struct WorkdirCreateInput {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct WorkdirCreateRequest {
|
struct WorkdirCreateRequest {
|
||||||
runtime_id: String,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
runtime_id: Option<String>,
|
||||||
repository_id: String,
|
repository_id: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
selector: Option<String>,
|
selector: Option<String>,
|
||||||
|
operation_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -916,7 +931,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn schemas_expose_identities_without_paths_or_session_handles() {
|
fn schemas_expose_identities_without_paths_or_session_handles() {
|
||||||
let create = create_schema();
|
let create = create_schema();
|
||||||
assert_eq!(create["required"], json!(["runtime_id", "repository_id"]));
|
assert_eq!(create["required"], json!(["repository_id"]));
|
||||||
|
assert_eq!(
|
||||||
|
create["properties"]["runtime_id"]["type"],
|
||||||
|
json!(["string", "null"])
|
||||||
|
);
|
||||||
assert!(create["properties"].get("path").is_none());
|
assert!(create["properties"].get("path").is_none());
|
||||||
assert!(create["properties"].get("session_id").is_none());
|
assert!(create["properties"].get("session_id").is_none());
|
||||||
assert_eq!(attach_schema()["required"], json!(["workdir_id"]));
|
assert_eq!(attach_schema()["required"], json!(["workdir_id"]));
|
||||||
@@ -946,6 +965,7 @@ mod tests {
|
|||||||
})),
|
})),
|
||||||
response(json!({
|
response(json!({
|
||||||
"workspace_id": "workspace/test",
|
"workspace_id": "workspace/test",
|
||||||
|
"runtime_id": "runtime/one",
|
||||||
"item": workdir_json("wd-created"),
|
"item": workdir_json("wd-created"),
|
||||||
"diagnostics": []
|
"diagnostics": []
|
||||||
})),
|
})),
|
||||||
@@ -961,6 +981,7 @@ mod tests {
|
|||||||
})),
|
})),
|
||||||
response(json!({
|
response(json!({
|
||||||
"workspace_id": "workspace/test",
|
"workspace_id": "workspace/test",
|
||||||
|
"runtime_id": "runtime/one",
|
||||||
"item": {
|
"item": {
|
||||||
"working_directory_id": "wd-created",
|
"working_directory_id": "wd-created",
|
||||||
"repository_id": "main",
|
"repository_id": "main",
|
||||||
@@ -985,13 +1006,19 @@ mod tests {
|
|||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
let created = backend
|
let created = backend
|
||||||
.create(WorkdirCreateInput {
|
.create(
|
||||||
runtime_id: "runtime/one".to_string(),
|
WorkdirCreateInput {
|
||||||
|
runtime_id: Some("runtime/one".to_string()),
|
||||||
repository_id: "main".to_string(),
|
repository_id: "main".to_string(),
|
||||||
selector: Some("refs/heads/topic".to_string()),
|
selector: Some("refs/heads/topic".to_string()),
|
||||||
})
|
},
|
||||||
|
"call-create-1".to_string(),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(created.summary, "Created Workdir wd-created");
|
assert_eq!(
|
||||||
|
created.summary,
|
||||||
|
"Created Workdir wd-created on Runtime runtime/one"
|
||||||
|
);
|
||||||
let created: serde_json::Value =
|
let created: serde_json::Value =
|
||||||
serde_json::from_str(created.content.as_deref().unwrap()).unwrap();
|
serde_json::from_str(created.content.as_deref().unwrap()).unwrap();
|
||||||
assert_eq!(created["item"]["working_directory_id"], "wd-created");
|
assert_eq!(created["item"]["working_directory_id"], "wd-created");
|
||||||
@@ -1017,12 +1044,14 @@ mod tests {
|
|||||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
requests[1].path,
|
requests[1].path,
|
||||||
"/api/w/workspace%2Ftest/runtimes/runtime%2Fone/working-directories"
|
"/api/w/workspace%2Ftest/working-directories"
|
||||||
);
|
);
|
||||||
assert_eq!(requests[1].method, WorkspaceRequestMethod::Post);
|
assert_eq!(requests[1].method, WorkspaceRequestMethod::Post);
|
||||||
let body: serde_json::Value =
|
let body: serde_json::Value =
|
||||||
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
|
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
|
||||||
assert_eq!(body["repository_id"], "main");
|
assert_eq!(body["repository_id"], "main");
|
||||||
|
assert_eq!(body["runtime_id"], "runtime/one");
|
||||||
|
assert_eq!(body["operation_id"], "call-create-1");
|
||||||
assert_eq!(body["selector"], "refs/heads/topic");
|
assert_eq!(body["selector"], "refs/heads/topic");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
requests[2].path,
|
requests[2].path,
|
||||||
@@ -1216,16 +1245,50 @@ mod tests {
|
|||||||
assert_eq!(body["operation"]["request"]["path"], "file");
|
assert_eq!(body["operation"]["request"]["path"], "file");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_omits_runtime_for_backend_default_resolution() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||||
|
"workspace_id": "workspace/test",
|
||||||
|
"runtime_id": "arcadia",
|
||||||
|
"item": workdir_json("wd-default"),
|
||||||
|
"diagnostics": []
|
||||||
|
}))]));
|
||||||
|
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
|
||||||
|
|
||||||
|
let created = backend
|
||||||
|
.create(
|
||||||
|
WorkdirCreateInput {
|
||||||
|
runtime_id: None,
|
||||||
|
repository_id: "main".to_string(),
|
||||||
|
selector: None,
|
||||||
|
},
|
||||||
|
"call-default".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
created.summary,
|
||||||
|
"Created Workdir wd-default on Runtime arcadia"
|
||||||
|
);
|
||||||
|
let requests = client.requests();
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||||
|
assert!(body.get("runtime_id").is_none());
|
||||||
|
assert_eq!(body["operation_id"], "call-default");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_or_extra_inputs_are_rejected_before_workspace_request() {
|
fn invalid_or_extra_inputs_are_rejected_before_workspace_request() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
|
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
|
||||||
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
|
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
|
||||||
let error = backend
|
let error = backend
|
||||||
.create(WorkdirCreateInput {
|
.create(
|
||||||
runtime_id: " ".to_string(),
|
WorkdirCreateInput {
|
||||||
|
runtime_id: Some(" ".to_string()),
|
||||||
repository_id: "main".to_string(),
|
repository_id: "main".to_string(),
|
||||||
selector: None,
|
selector: None,
|
||||||
})
|
},
|
||||||
|
"call-invalid".to_string(),
|
||||||
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||||
assert!(client.requests().is_empty());
|
assert!(client.requests().is_empty());
|
||||||
|
|||||||
@@ -356,6 +356,52 @@ pub fn manage_worker_feature(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct SubWorkerControlFeature {
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubWorkerControlFeature {
|
||||||
|
pub fn new(client: Arc<dyn WorkspaceClient>, registry: Arc<SpawnedWorkerRegistry>) -> Self {
|
||||||
|
Self { client, registry }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for SubWorkerControlFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
FeatureDescriptor::builtin("sub-worker-control", "SubWorker Control")
|
||||||
|
.with_description("Parent-owned SubWorker control service provider")
|
||||||
|
.with_provided_service(ServiceDeclaration::new(
|
||||||
|
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||||
|
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||||
|
"Parent-owned SubWorker discovery and control operations",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
let control: Arc<dyn WorkerControlService> = Arc::new(WorkspaceWorkerControlService {
|
||||||
|
workspace_id: self.client.workspace_id().unwrap_or_default().to_string(),
|
||||||
|
client: self.client.clone(),
|
||||||
|
registry: Some(self.registry.clone()),
|
||||||
|
});
|
||||||
|
context.services().provide(
|
||||||
|
ServiceDeclaration::new(
|
||||||
|
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||||
|
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||||
|
"Parent-owned SubWorker discovery and control operations",
|
||||||
|
),
|
||||||
|
control,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sub_worker_control_feature(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
|
) -> SubWorkerControlFeature {
|
||||||
|
SubWorkerControlFeature::new(client, registry)
|
||||||
|
}
|
||||||
|
|
||||||
impl FeatureModule for ManageWorkerFeature {
|
impl FeatureModule for ManageWorkerFeature {
|
||||||
fn descriptor(&self) -> FeatureDescriptor {
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||||
@@ -1046,6 +1092,42 @@ mod tests {
|
|||||||
assert!(body.get("initial_text").is_none());
|
assert!(body.get("initial_text").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_and_sub_worker_providers_are_ambiguous_before_install() {
|
||||||
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
|
let runtime_dir = Arc::new(
|
||||||
|
crate::runtime::dir::RuntimeDir::create(runtime_base.path(), "feature-plan")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let registry = SpawnedWorkerRegistry::new(runtime_dir);
|
||||||
|
let client: Arc<dyn WorkspaceClient> = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
let mut builder = crate::feature::FeatureRegistryBuilder::new();
|
||||||
|
builder.add_module(sub_worker_control_feature(client.clone(), registry.clone()));
|
||||||
|
builder.add_module(manage_worker_feature(client, Some(registry), true));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
builder.plan(),
|
||||||
|
Err(crate::feature::FeaturePlanError::AmbiguousServiceProvider {
|
||||||
|
service: ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||||
|
providers: vec![
|
||||||
|
crate::feature::FeatureId::builtin("sub-worker-control"),
|
||||||
|
crate::feature::FeatureId::builtin("worker"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut hooks = crate::HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
let report = builder.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
assert!(matches!(
|
||||||
|
report.plan_error,
|
||||||
|
Some(crate::feature::FeaturePlanError::AmbiguousServiceProvider { .. })
|
||||||
|
));
|
||||||
|
assert!(pending_tools.is_empty());
|
||||||
|
assert!(report.services.providers().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
|
fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ use memory::backend::{
|
|||||||
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
|
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
|
||||||
};
|
};
|
||||||
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence};
|
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence};
|
||||||
use memory::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
|
use memory::schema::{
|
||||||
|
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef,
|
||||||
|
};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -174,17 +176,29 @@ impl Tool for StageMemoryCandidateTool {
|
|||||||
"StageMemoryCandidate requires at least one entry_ref".to_string(),
|
"StageMemoryCandidate requires at least one entry_ref".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut evidence = Vec::with_capacity(params.entry_refs.len());
|
let mut entries = Vec::with_capacity(params.entry_refs.len());
|
||||||
let mut source_refs = Vec::with_capacity(params.entry_refs.len());
|
|
||||||
for entry_ref in ¶ms.entry_refs {
|
for entry_ref in ¶ms.entry_refs {
|
||||||
let projection = self.state.view.evidence_for(entry_ref).ok_or_else(|| {
|
entries.push(self.state.view.evidence_for(entry_ref).ok_or_else(|| {
|
||||||
ToolError::InvalidArgument(format!(
|
ToolError::InvalidArgument(format!(
|
||||||
"unknown SessionEntryRef {entry_ref:?} for this extraction capture"
|
"unknown SessionEntryRef {entry_ref:?} for this extraction capture"
|
||||||
))
|
))
|
||||||
})?;
|
})?);
|
||||||
evidence.push(staging_evidence(&projection));
|
|
||||||
source_refs.push(source_evidence_ref(&projection));
|
|
||||||
}
|
}
|
||||||
|
if matches!(params.kind, CandidateKind::Preference)
|
||||||
|
&& entries.iter().any(|entry| {
|
||||||
|
!matches!(
|
||||||
|
entry.origin,
|
||||||
|
crate::WorkerHistoryProvenance::HumanInput { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let evidence = entries.iter().map(staging_evidence).collect();
|
||||||
|
let source_refs = entries.iter().map(source_evidence_ref).collect();
|
||||||
let candidate = ExtractedCandidate {
|
let candidate = ExtractedCandidate {
|
||||||
kind: params.kind,
|
kind: params.kind,
|
||||||
claim: params.claim,
|
claim: params.claim,
|
||||||
@@ -310,11 +324,65 @@ fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
|
||||||
|
use crate::WorkerHistoryProvenance as Origin;
|
||||||
|
let mut evidence = EvidenceOrigin {
|
||||||
|
kind: EvidenceOriginKind::LegacyUnknown,
|
||||||
|
account_id: None,
|
||||||
|
workspace_id: None,
|
||||||
|
runtime_id: None,
|
||||||
|
worker_id: None,
|
||||||
|
flow_selector: None,
|
||||||
|
flow_definition_id: None,
|
||||||
|
flow_definition_revision: None,
|
||||||
|
};
|
||||||
|
match origin {
|
||||||
|
Origin::HumanInput { account_id } => {
|
||||||
|
evidence.kind = EvidenceOriginKind::HumanInput;
|
||||||
|
evidence.account_id = Some(account_id.clone());
|
||||||
|
}
|
||||||
|
Origin::WorkerInput { actor } => {
|
||||||
|
evidence.kind = EvidenceOriginKind::WorkerInput;
|
||||||
|
evidence.workspace_id = actor.workspace_id.clone();
|
||||||
|
evidence.runtime_id = actor.runtime_id.clone();
|
||||||
|
evidence.worker_id = Some(actor.worker_id.clone());
|
||||||
|
}
|
||||||
|
Origin::FlowInstruction {
|
||||||
|
selector,
|
||||||
|
definition_id,
|
||||||
|
definition_revision,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
evidence.kind = EvidenceOriginKind::FlowInstruction;
|
||||||
|
evidence.flow_selector = Some(selector.clone());
|
||||||
|
evidence.flow_definition_id = Some(definition_id.clone());
|
||||||
|
evidence.flow_definition_revision = Some(*definition_revision);
|
||||||
|
}
|
||||||
|
Origin::BackendInstruction { .. } => evidence.kind = EvidenceOriginKind::BackendInstruction,
|
||||||
|
Origin::ModelOutput { worker } => {
|
||||||
|
evidence.kind = EvidenceOriginKind::ModelOutput;
|
||||||
|
evidence.workspace_id = worker.workspace_id.clone();
|
||||||
|
evidence.runtime_id = worker.runtime_id.clone();
|
||||||
|
evidence.worker_id = Some(worker.worker_id.clone());
|
||||||
|
}
|
||||||
|
Origin::ToolOutput { worker } => {
|
||||||
|
evidence.kind = EvidenceOriginKind::ToolOutput;
|
||||||
|
evidence.workspace_id = worker.workspace_id.clone();
|
||||||
|
evidence.runtime_id = worker.runtime_id.clone();
|
||||||
|
evidence.worker_id = Some(worker.worker_id.clone());
|
||||||
|
}
|
||||||
|
Origin::DerivedSummary => evidence.kind = EvidenceOriginKind::DerivedSummary,
|
||||||
|
Origin::LegacyUnknown => evidence.kind = EvidenceOriginKind::LegacyUnknown,
|
||||||
|
}
|
||||||
|
evidence
|
||||||
|
}
|
||||||
|
|
||||||
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
|
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
|
||||||
StagingEvidence {
|
StagingEvidence {
|
||||||
id: entry.entry_ref.to_string(),
|
id: entry.entry_ref.to_string(),
|
||||||
kind: evidence_kind(entry),
|
kind: evidence_kind(entry),
|
||||||
entry_range: Some(entry.entry_range),
|
entry_range: Some(entry.entry_range),
|
||||||
|
origin: Some(evidence_origin(&entry.origin)),
|
||||||
excerpt: Some(entry.excerpt.clone()),
|
excerpt: Some(entry.excerpt.clone()),
|
||||||
summary: Some(entry.summary.clone()),
|
summary: Some(entry.summary.clone()),
|
||||||
}
|
}
|
||||||
@@ -325,6 +393,7 @@ fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
|
|||||||
segment_id: Some(entry.segment_id.clone()),
|
segment_id: Some(entry.segment_id.clone()),
|
||||||
entry_range: Some(entry.entry_range),
|
entry_range: Some(entry.entry_range),
|
||||||
evidence_id: Some(entry.entry_ref.to_string()),
|
evidence_id: Some(entry.entry_ref.to_string()),
|
||||||
|
origin: Some(evidence_origin(&entry.origin)),
|
||||||
evidence_kind: Some(evidence_kind(entry)),
|
evidence_kind: Some(evidence_kind(entry)),
|
||||||
label: Some(entry.label.clone()),
|
label: Some(entry.label.clone()),
|
||||||
summary: Some(entry.summary.clone()),
|
summary: Some(entry.summary.clone()),
|
||||||
@@ -432,6 +501,15 @@ mod tests {
|
|||||||
assert!(input.contains("StageMemoryCandidate.entry_refs"));
|
assert!(input.contains("StageMemoryCandidate.entry_refs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_origin_projects_account_authority_into_evidence() {
|
||||||
|
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput {
|
||||||
|
account_id: "account-1".into(),
|
||||||
|
});
|
||||||
|
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
|
||||||
|
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_input_failures_remain_invalid_argument_tool_errors() {
|
fn backend_input_failures_remain_invalid_argument_tool_errors() {
|
||||||
let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend(
|
let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend(
|
||||||
@@ -445,6 +523,19 @@ mod tests {
|
|||||||
assert!(matches!(http, ToolError::InvalidArgument(_)));
|
assert!(matches!(http, ToolError::InvalidArgument(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn preference_rejects_legacy_unknown_before_backend_mutation() {
|
||||||
|
let tool = StageMemoryCandidateTool { state: state() };
|
||||||
|
let error = tool
|
||||||
|
.execute(
|
||||||
|
r#"{"kind":"preference","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
|
||||||
|
agen::tool::ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
|
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
|
||||||
let tool = StageMemoryCandidateTool { state: state() };
|
let tool = StageMemoryCandidateTool { state: state() };
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ struct MergeRequestTool {
|
|||||||
kind: Kind,
|
kind: Kind,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct ShowInput {
|
struct TicketInput {
|
||||||
ticket: String,
|
ticket: String,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct OpenInput {
|
struct OpenMergeRequestInput {
|
||||||
ticket: String,
|
ticket: String,
|
||||||
repository_id: String,
|
repository_id: String,
|
||||||
selector_from: String,
|
selector_from: String,
|
||||||
@@ -63,7 +63,7 @@ struct OpenInput {
|
|||||||
summary: String,
|
summary: String,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct CompleteInput {
|
struct CompleteMergeRequestInput {
|
||||||
ticket: String,
|
ticket: String,
|
||||||
operation_id: String,
|
operation_id: String,
|
||||||
approval_event_id: String,
|
approval_event_id: String,
|
||||||
@@ -86,7 +86,7 @@ enum MergeResolutionInput {
|
|||||||
ConflictsResolved,
|
ConflictsResolved,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct ReviewInput {
|
struct ReviewMergeRequestInput {
|
||||||
decision: ReviewDecisionInput,
|
decision: ReviewDecisionInput,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
body: String,
|
body: String,
|
||||||
@@ -133,19 +133,19 @@ impl Kind {
|
|||||||
|
|
||||||
fn name(self) -> &'static str {
|
fn name(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Show => "MergeRequestShow",
|
Self::Show => "ShowMergeRequest",
|
||||||
Self::Readiness => "MergeRequestReadinessCheck",
|
Self::Readiness => "CheckMergeRequestReadiness",
|
||||||
Self::Open => "MergeRequestOpen",
|
Self::Open => "OpenMergeRequest",
|
||||||
Self::Complete => "MergeRequestComplete",
|
Self::Complete => "CompleteMergeRequest",
|
||||||
Self::Review => "MergeRequestReview",
|
Self::Review => "ReviewMergeRequest",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn schema(self) -> serde_json::Value {
|
fn schema(self) -> serde_json::Value {
|
||||||
match self {
|
match self {
|
||||||
Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)),
|
Self::Show | Self::Readiness => json!(schemars::schema_for!(TicketInput)),
|
||||||
Self::Open => json!(schemars::schema_for!(OpenInput)),
|
Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
|
||||||
Self::Complete => json!(schemars::schema_for!(CompleteInput)),
|
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
|
||||||
Self::Review => json!(schemars::schema_for!(ReviewInput)),
|
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,13 +156,13 @@ impl Tool for MergeRequestTool {
|
|||||||
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
|
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
|
||||||
})?;
|
})?;
|
||||||
if matches!(self.kind, Kind::Show) {
|
if matches!(self.kind, Kind::Show) {
|
||||||
let value: ShowInput = parse(input)?;
|
let value: TicketInput = parse(input)?;
|
||||||
nonempty(&value.ticket)?;
|
nonempty(&value.ticket)?;
|
||||||
return self.show_current_merge_request(ws, &value.ticket);
|
return self.show_current_merge_request(ws, &value.ticket);
|
||||||
}
|
}
|
||||||
let (method, path, body) = match self.kind {
|
let (method, path, body) = match self.kind {
|
||||||
Kind::Readiness => {
|
Kind::Readiness => {
|
||||||
let v: ShowInput = parse(input)?;
|
let v: TicketInput = parse(input)?;
|
||||||
nonempty(&v.ticket)?;
|
nonempty(&v.ticket)?;
|
||||||
(
|
(
|
||||||
WorkspaceRequestMethod::Get,
|
WorkspaceRequestMethod::Get,
|
||||||
@@ -170,9 +170,9 @@ impl Tool for MergeRequestTool {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Kind::Show => unreachable!("MergeRequestShow is handled above"),
|
Kind::Show => unreachable!("ShowMergeRequest is handled above"),
|
||||||
Kind::Open => {
|
Kind::Open => {
|
||||||
let v: OpenInput = parse(input)?;
|
let v: OpenMergeRequestInput = parse(input)?;
|
||||||
nonempty(&v.ticket)?;
|
nonempty(&v.ticket)?;
|
||||||
(
|
(
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
@@ -183,7 +183,7 @@ impl Tool for MergeRequestTool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Kind::Complete => {
|
Kind::Complete => {
|
||||||
let v: CompleteInput = parse(input)?;
|
let v: CompleteMergeRequestInput = parse(input)?;
|
||||||
nonempty(&v.ticket)?;
|
nonempty(&v.ticket)?;
|
||||||
(
|
(
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
@@ -194,7 +194,7 @@ impl Tool for MergeRequestTool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Kind::Review => {
|
Kind::Review => {
|
||||||
let v: ReviewInput = parse(input)?;
|
let v: ReviewMergeRequestInput = parse(input)?;
|
||||||
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
||||||
ToolError::ExecutionFailed(
|
ToolError::ExecutionFailed(
|
||||||
"Review submit requires injected Reviewer capability".into(),
|
"Review submit requires injected Reviewer capability".into(),
|
||||||
@@ -397,19 +397,21 @@ impl FeatureModule for MergeRequestFeature {
|
|||||||
|
|
||||||
pub fn description(n: &str) -> Option<&'static str> {
|
pub fn description(n: &str) -> Option<&'static str> {
|
||||||
match n {
|
match n {
|
||||||
"MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."),
|
"ShowMergeRequest" => Some(
|
||||||
"MergeRequestReadinessCheck" => {
|
"Read the selector-based Merge Request, append-only thread, source-review freshness, and target-integration evidence before review, fix, or handoff decisions.",
|
||||||
Some("Resolve current provider refs and derive readiness from valid review events.")
|
),
|
||||||
}
|
"CheckMergeRequestReadiness" => Some(
|
||||||
"MergeRequestOpen" => {
|
"Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.",
|
||||||
Some("Open a Merge Request with immutable source and target selectors.")
|
),
|
||||||
}
|
"OpenMergeRequest" => Some(
|
||||||
"MergeRequestComplete" => {
|
"Open the Ticket's one Merge Request with immutable source and target selectors; reuse it and advance only selector_from with a normal non-force push for later fixes.",
|
||||||
Some("Complete using an approved review event and final target-ref evidence.")
|
),
|
||||||
}
|
"CompleteMergeRequest" => Some(
|
||||||
"MergeRequestReview" => {
|
"Record Orchestrator-owned integration using unchanged-source approval and refreshed final target-ref evidence.",
|
||||||
Some("Submit the injected Reviewer capability result for its captured subject ref.")
|
),
|
||||||
}
|
"ReviewMergeRequest" => Some(
|
||||||
|
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
||||||
|
),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,6 +471,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_facing_operations_use_only_verb_first_names() {
|
||||||
|
for name in [
|
||||||
|
"ShowMergeRequest",
|
||||||
|
"CheckMergeRequestReadiness",
|
||||||
|
"OpenMergeRequest",
|
||||||
|
"CompleteMergeRequest",
|
||||||
|
"ReviewMergeRequest",
|
||||||
|
] {
|
||||||
|
assert!(description(name).is_some(), "missing operation {name}");
|
||||||
|
}
|
||||||
|
for legacy in [
|
||||||
|
"MergeRequestShow",
|
||||||
|
"MergeRequestReadinessCheck",
|
||||||
|
"MergeRequestOpen",
|
||||||
|
"MergeRequestComplete",
|
||||||
|
"MergeRequestReview",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
description(legacy).is_none(),
|
||||||
|
"legacy alias {legacy} must not remain registered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn show_resolves_ticket_projection_then_reads_canonical_resource() {
|
async fn show_resolves_ticket_projection_then_reads_canonical_resource() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||||
@@ -546,7 +573,7 @@ mod tests {
|
|||||||
open: true,
|
open: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(tool_names(coder), ["MergeRequestShow", "MergeRequestOpen"]);
|
assert_eq!(tool_names(coder), ["ShowMergeRequest", "OpenMergeRequest"]);
|
||||||
|
|
||||||
let reviewer = MergeRequestFeatureConfig {
|
let reviewer = MergeRequestFeatureConfig {
|
||||||
show: true,
|
show: true,
|
||||||
@@ -555,7 +582,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool_names(reviewer),
|
tool_names(reviewer),
|
||||||
["MergeRequestShow", "MergeRequestReview"]
|
["ShowMergeRequest", "ReviewMergeRequest"]
|
||||||
);
|
);
|
||||||
|
|
||||||
let orchestrator = MergeRequestFeatureConfig {
|
let orchestrator = MergeRequestFeatureConfig {
|
||||||
@@ -567,9 +594,9 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool_names(orchestrator),
|
tool_names(orchestrator),
|
||||||
[
|
[
|
||||||
"MergeRequestShow",
|
"ShowMergeRequest",
|
||||||
"MergeRequestReadinessCheck",
|
"CheckMergeRequestReadiness",
|
||||||
"MergeRequestComplete"
|
"CompleteMergeRequest"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
||||||
@@ -581,8 +608,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn schemas_hide_revision_and_commit_authority() {
|
fn schemas_hide_revision_and_commit_authority() {
|
||||||
let schemas = [
|
let schemas = [
|
||||||
schemars::schema_for!(OpenInput),
|
schemars::schema_for!(OpenMergeRequestInput),
|
||||||
schemars::schema_for!(CompleteInput),
|
schemars::schema_for!(CompleteMergeRequestInput),
|
||||||
];
|
];
|
||||||
for s in schemas {
|
for s in schemas {
|
||||||
let j = serde_json::to_string(&s).unwrap();
|
let j = serde_json::to_string(&s).unwrap();
|
||||||
|
|||||||
@@ -89,18 +89,19 @@ impl Tool for SpawnTicketCoderTool {
|
|||||||
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
|
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
|
||||||
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
|
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
|
||||||
})?;
|
})?;
|
||||||
let ticket_id = authority_id(input.ticket_id, "ticket_id")?;
|
let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
|
||||||
let workflow_state = self
|
let ticket = self
|
||||||
.ticket_service
|
.ticket_service
|
||||||
.workflow_state(&ticket_id)
|
.ticket_handoff(&ticket_ref)
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
if !matches!(
|
if !matches!(
|
||||||
workflow_state,
|
ticket.workflow_state,
|
||||||
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
|
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
|
||||||
) {
|
) {
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
"Ticket {ticket_id} must be queued or inprogress before spawning its Coder; current state is {}",
|
"Ticket {} must be queued or inprogress before spawning its Coder; current state is {}",
|
||||||
workflow_state.as_str()
|
ticket.resource_key,
|
||||||
|
ticket.workflow_state.as_str()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
||||||
@@ -115,14 +116,14 @@ impl Tool for SpawnTicketCoderTool {
|
|||||||
)?,
|
)?,
|
||||||
relative_cwd,
|
relative_cwd,
|
||||||
profile: CODER_PROFILE.to_string(),
|
profile: CODER_PROFILE.to_string(),
|
||||||
ticket_id: Some(ticket_id.clone()),
|
ticket_id: Some(ticket.id.clone()),
|
||||||
operation_id: Some(format!("spawn-ticket-coder:{ticket_id}:{call_id}")),
|
operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
|
||||||
display_name: format!("Coder · {ticket_id}"),
|
display_name: format!("Coder · {}", ticket.resource_key),
|
||||||
initial_submit: vec![
|
initial_submit: vec![
|
||||||
Segment::Flow {
|
Segment::Flow {
|
||||||
selector: CODER_FLOW.to_string(),
|
selector: CODER_FLOW.to_string(),
|
||||||
},
|
},
|
||||||
Segment::text(format!("Implement Ticket {ticket_id}.")),
|
Segment::text(format!("Implement Ticket {}.", ticket.resource_key)),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -134,7 +135,7 @@ impl Tool for SpawnTicketCoderTool {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
summary: format!("Spawned Coder for Ticket {ticket_id}"),
|
summary: format!("Spawned Coder for Ticket {}", ticket.resource_key),
|
||||||
content: Some(response.body),
|
content: Some(response.body),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
})
|
})
|
||||||
@@ -201,21 +202,31 @@ mod tests {
|
|||||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::feature::builtin::ticket::TicketHandoff;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RecordingTicketService;
|
struct RecordingTicketService;
|
||||||
|
|
||||||
impl TicketService for RecordingTicketService {
|
impl TicketService for RecordingTicketService {
|
||||||
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
Ok(TicketWorkflowState::Queued)
|
assert_eq!(ticket_ref, "T-482");
|
||||||
|
Ok(TicketHandoff {
|
||||||
|
id: "00001KZXN51C7".to_string(),
|
||||||
|
resource_key: "T-482".to_string(),
|
||||||
|
workflow_state: TicketWorkflowState::Queued,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FixedTicketService(TicketWorkflowState);
|
struct FixedTicketService(TicketWorkflowState);
|
||||||
|
|
||||||
impl TicketService for FixedTicketService {
|
impl TicketService for FixedTicketService {
|
||||||
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
fn ticket_handoff(&self, _ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
Ok(self.0)
|
Ok(TicketHandoff {
|
||||||
|
id: "00001KZXN51C7".to_string(),
|
||||||
|
resource_key: "T-482".to_string(),
|
||||||
|
workflow_state: self.0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +258,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
tool.execute(
|
tool.execute(
|
||||||
&serde_json::json!({
|
&serde_json::json!({
|
||||||
"ticket_id": "00001KZXN51C7",
|
"ticket_id": "T-482",
|
||||||
"runtime_id": "runtime-1",
|
"runtime_id": "runtime-1",
|
||||||
"working_directory_id": "workdir-1"
|
"working_directory_id": "workdir-1"
|
||||||
})
|
})
|
||||||
@@ -265,16 +276,20 @@ mod tests {
|
|||||||
request.operation_id.as_deref(),
|
request.operation_id.as_deref(),
|
||||||
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
|
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
|
||||||
);
|
);
|
||||||
assert_eq!(request.display_name, "Coder · 00001KZXN51C7");
|
assert_eq!(request.display_name, "Coder · T-482");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.initial_submit,
|
request.initial_submit,
|
||||||
vec![
|
vec![
|
||||||
Segment::Flow {
|
Segment::Flow {
|
||||||
selector: CODER_FLOW.to_string()
|
selector: CODER_FLOW.to_string()
|
||||||
},
|
},
|
||||||
Segment::text("Implement Ticket 00001KZXN51C7.")
|
Segment::text("Implement Ticket T-482.")
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
assert!(!request.display_name.contains("00001KZXN51C7"));
|
||||||
|
assert!(request.initial_submit.iter().all(|segment| {
|
||||||
|
!Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7")
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ impl Tool for ShowOverviewTool {
|
|||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"entry_ref": entry.id,
|
"entry_ref": entry.id,
|
||||||
|
"origin": entry.origin,
|
||||||
"entry_range": entry.entry_range,
|
"entry_range": entry.entry_range,
|
||||||
"kind": entry.kind.as_str(),
|
"kind": entry.kind.as_str(),
|
||||||
"label": entry.label,
|
"label": entry.label,
|
||||||
@@ -234,15 +235,16 @@ impl Tool for SearchEntriesTool {
|
|||||||
.transpose()?;
|
.transpose()?;
|
||||||
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
|
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
|
||||||
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
|
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
|
||||||
|
let view = self.state.view();
|
||||||
if let (Some(from), Some(through)) = (&from, &through) {
|
if let (Some(from), Some(through)) = (&from, &through) {
|
||||||
if from.source_index() > through.source_index() {
|
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
|
||||||
return Err(ToolError::InvalidArgument(
|
return Err(ToolError::InvalidArgument(
|
||||||
"SearchEntries from must not be after through".to_string(),
|
"SearchEntries from must not be after through".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
|
let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
|
||||||
let hits = self.state.view().search(&SearchOptions {
|
let hits = view.search(&SearchOptions {
|
||||||
query: params.query,
|
query: params.query,
|
||||||
kind,
|
kind,
|
||||||
tool_part,
|
tool_part,
|
||||||
@@ -318,6 +320,7 @@ impl Tool for ReadEntryTool {
|
|||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"entry_ref": entry.id,
|
"entry_ref": entry.id,
|
||||||
|
"origin": entry.origin,
|
||||||
"entry_range": entry.entry_range,
|
"entry_range": entry.entry_range,
|
||||||
"kind": entry.kind.as_str(),
|
"kind": entry.kind.as_str(),
|
||||||
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
|
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
|
||||||
|
|||||||
@@ -267,7 +267,20 @@ pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
|||||||
const TICKET_SERVICE_VERSION: &str = "1";
|
const TICKET_SERVICE_VERSION: &str = "1";
|
||||||
|
|
||||||
pub trait TicketService: Send + Sync {
|
pub trait TicketService: Send + Sync {
|
||||||
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError>;
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TicketHandoff {
|
||||||
|
pub id: String,
|
||||||
|
pub resource_key: String,
|
||||||
|
pub workflow_state: TicketWorkflowState,
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BackendTicketService {
|
struct BackendTicketService {
|
||||||
@@ -275,10 +288,18 @@ struct BackendTicketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TicketService for BackendTicketService {
|
impl TicketService for BackendTicketService {
|
||||||
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
self.backend
|
let ticket = self.backend.show(ticket_ref.into())?;
|
||||||
.show(ticket_id.into())
|
let resource_key = ticket
|
||||||
.map(|ticket| ticket.meta.workflow_state)
|
.meta
|
||||||
|
.resource_key
|
||||||
|
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||||
|
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
|
||||||
|
Ok(TicketHandoff {
|
||||||
|
id: ticket.meta.id,
|
||||||
|
resource_key,
|
||||||
|
workflow_state: ticket.meta.workflow_state,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,12 +894,13 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
})?),
|
})?),
|
||||||
)
|
)
|
||||||
.map(TicketBackendOperationResult::Ticket),
|
.map(TicketBackendOperationResult::Ticket),
|
||||||
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
|
TicketBackendOperation::QueueReady { id, .. } => Self::request(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
||||||
None,
|
None,
|
||||||
),
|
)
|
||||||
|
.map(TicketBackendOperationResult::QueueOutcome),
|
||||||
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
@@ -1099,16 +1121,18 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
fn queue_ready(
|
||||||
match self.invoke(TicketBackendOperation::QueueReady {
|
&self,
|
||||||
|
id: TicketIdOrSlug,
|
||||||
|
queued_by: &str,
|
||||||
|
) -> TicketResult<ticket::TicketQueueOutcome> {
|
||||||
|
expect_ticket_result!(
|
||||||
|
self.invoke(TicketBackendOperation::QueueReady {
|
||||||
id,
|
id,
|
||||||
queued_by: queued_by.to_string(),
|
queued_by: queued_by.to_string(),
|
||||||
})? {
|
}),
|
||||||
TicketBackendOperationResult::Unit => Ok(()),
|
TicketBackendOperationResult::QueueOutcome
|
||||||
other => Err(TicketError::Conflict(format!(
|
)
|
||||||
"unexpected ticket backend response: {other:?}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
||||||
@@ -1767,6 +1791,14 @@ provider = "github"
|
|||||||
assert_eq!(removed.target, "01TARGET");
|
assert_eq!(removed.target, "01TARGET");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
|
||||||
|
assert!(is_canonical_ticket_resource_key("T-482"));
|
||||||
|
for invalid in ["", "00001KZVNXFNK", "T-", "T-key", "O-482"] {
|
||||||
|
assert!(!is_canonical_ticket_resource_key(invalid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_http_backend_executes_ticket_create_operation() {
|
fn workspace_http_backend_executes_ticket_create_operation() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
use agen::Item;
|
use agen::Item;
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use session_store::collect_state;
|
use session_store::{LogEntry, collect_state};
|
||||||
|
|
||||||
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
|
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
|
||||||
use crate::feature::{
|
use crate::feature::{
|
||||||
@@ -60,7 +61,27 @@ pub struct WorkerObservationSubject {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WorkerSessionCapture {
|
pub struct WorkerSessionCapture {
|
||||||
pub segment_id: String,
|
pub segment_id: String,
|
||||||
pub items: Vec<Item>,
|
pub entries: Vec<agen::HistoryEntry<crate::SessionHistoryMetadata>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerSessionCapture {
|
||||||
|
pub fn from_log_entries(
|
||||||
|
segment_id: impl Into<String>,
|
||||||
|
log_entries: &[LogEntry],
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let segment_id = segment_id.into();
|
||||||
|
let state = collect_state(log_entries);
|
||||||
|
let parsed_segment_id = segment_id.parse().unwrap_or_default();
|
||||||
|
let entries = crate::session_history::restore_history_entries(
|
||||||
|
state.session_id.unwrap_or_default(),
|
||||||
|
parsed_segment_id,
|
||||||
|
log_entries,
|
||||||
|
)?;
|
||||||
|
Ok(Self {
|
||||||
|
segment_id,
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -161,9 +182,17 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<session_store::LogEntry>, _>>()?;
|
.collect::<Result<Vec<session_store::LogEntry>, _>>()?;
|
||||||
let state = collect_state(&entries);
|
let state = collect_state(&entries);
|
||||||
|
let segment_id = response.segment_id;
|
||||||
|
let parsed_segment_id = segment_id.parse().unwrap_or_default();
|
||||||
|
let typed_entries = crate::session_history::restore_history_entries(
|
||||||
|
state.session_id.unwrap_or_default(),
|
||||||
|
parsed_segment_id,
|
||||||
|
&entries,
|
||||||
|
)
|
||||||
|
.map_err(WorkerObservationError::Unavailable)?;
|
||||||
Ok(WorkerSessionCapture {
|
Ok(WorkerSessionCapture {
|
||||||
segment_id: response.segment_id,
|
segment_id,
|
||||||
items: state.history,
|
entries: typed_entries,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,9 +421,15 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
|
|||||||
.ok_or(WorkerObservationError::NotFound)?;
|
.ok_or(WorkerObservationError::NotFound)?;
|
||||||
let entries = record.session.entries();
|
let entries = record.session.entries();
|
||||||
let state = collect_state(&entries);
|
let state = collect_state(&entries);
|
||||||
|
let typed_entries = crate::session_history::restore_history_entries(
|
||||||
|
state.session_id.unwrap_or_default(),
|
||||||
|
Default::default(),
|
||||||
|
&entries,
|
||||||
|
)
|
||||||
|
.map_err(WorkerObservationError::Unavailable)?;
|
||||||
Ok(WorkerSessionCapture {
|
Ok(WorkerSessionCapture {
|
||||||
segment_id: format!("subworker:{name}"),
|
segment_id: format!("subworker:{name}"),
|
||||||
items: state.history,
|
entries: typed_entries,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -508,6 +543,7 @@ impl Tool for ViewSessionOverviewTool {
|
|||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"entry_ref": entry.id,
|
"entry_ref": entry.id,
|
||||||
|
"origin": entry.origin,
|
||||||
"entry_range": entry.entry_range,
|
"entry_range": entry.entry_range,
|
||||||
"kind": entry.kind.as_str(),
|
"kind": entry.kind.as_str(),
|
||||||
"label": entry.label,
|
"label": entry.label,
|
||||||
@@ -547,7 +583,7 @@ impl Tool for SearchSessionEntriesTool {
|
|||||||
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
|
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
|
||||||
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
|
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
|
||||||
if let (Some(from), Some(through)) = (&from, &through) {
|
if let (Some(from), Some(through)) = (&from, &through) {
|
||||||
if from.source_index() > through.source_index() {
|
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
|
||||||
return Err(ToolError::InvalidArgument(
|
return Err(ToolError::InvalidArgument(
|
||||||
"SearchSessionEntries from must not be after through".to_string(),
|
"SearchSessionEntries from must not be after through".to_string(),
|
||||||
));
|
));
|
||||||
@@ -573,6 +609,7 @@ impl Tool for SearchSessionEntriesTool {
|
|||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"entry_ref": entry.id,
|
"entry_ref": entry.id,
|
||||||
|
"origin": entry.origin,
|
||||||
"entry_range": entry.entry_range,
|
"entry_range": entry.entry_range,
|
||||||
"kind": entry.kind.as_str(),
|
"kind": entry.kind.as_str(),
|
||||||
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
|
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
|
||||||
@@ -628,6 +665,7 @@ impl Tool for ReadSessionEntryTool {
|
|||||||
.map(|entry| {
|
.map(|entry| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"entry_ref": entry.id,
|
"entry_ref": entry.id,
|
||||||
|
"origin": entry.origin,
|
||||||
"entry_range": entry.entry_range,
|
"entry_range": entry.entry_range,
|
||||||
"kind": entry.kind.as_str(),
|
"kind": entry.kind.as_str(),
|
||||||
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
|
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
|
||||||
@@ -661,7 +699,10 @@ async fn latest_view(
|
|||||||
.capture_worker_session(subject)
|
.capture_worker_session(subject)
|
||||||
.await
|
.await
|
||||||
.map_err(tool_error)?;
|
.map_err(tool_error)?;
|
||||||
Ok(SessionCapture::new(capture.segment_id, capture.items))
|
Ok(SessionCapture::from_history_entries(
|
||||||
|
capture.segment_id,
|
||||||
|
capture.entries,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_input<T: serde::de::DeserializeOwned>(
|
fn parse_input<T: serde::de::DeserializeOwned>(
|
||||||
@@ -751,9 +792,23 @@ mod tests {
|
|||||||
if subject != &granted_subject() {
|
if subject != &granted_subject() {
|
||||||
return Err(WorkerObservationError::NotFound);
|
return Err(WorkerObservationError::NotFound);
|
||||||
}
|
}
|
||||||
|
let entries = self
|
||||||
|
.captures
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, item)| {
|
||||||
|
let mut metadata = crate::SessionHistoryMetadata::legacy_unknown();
|
||||||
|
metadata.entry_id =
|
||||||
|
session_store::LoggedSessionHistoryEntryId(format!("fake-{index:08}"));
|
||||||
|
agen::HistoryEntry::new(item, metadata)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
Ok(WorkerSessionCapture {
|
Ok(WorkerSessionCapture {
|
||||||
segment_id: "segment".to_string(),
|
segment_id: "segment".to_string(),
|
||||||
items: self.captures.lock().unwrap().clone(),
|
entries,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -796,7 +851,7 @@ mod tests {
|
|||||||
let read = read_definition(provider.clone())().1;
|
let read = read_definition(provider.clone())().1;
|
||||||
let hidden = read
|
let hidden = read
|
||||||
.execute(
|
.execute(
|
||||||
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"E00000000"}"#,
|
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"Efake-00000000"}"#,
|
||||||
agen::tool::ToolExecutionContext::direct(),
|
agen::tool::ToolExecutionContext::direct(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -810,7 +865,7 @@ mod tests {
|
|||||||
.push(message("a1", Role::Assistant, "second"));
|
.push(message("a1", Role::Assistant, "second"));
|
||||||
let output = read
|
let output = read
|
||||||
.execute(
|
.execute(
|
||||||
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000000"}"#,
|
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000000"}"#,
|
||||||
agen::tool::ToolExecutionContext::direct(),
|
agen::tool::ToolExecutionContext::direct(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -819,7 +874,7 @@ mod tests {
|
|||||||
|
|
||||||
let output = read
|
let output = read
|
||||||
.execute(
|
.execute(
|
||||||
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000001"}"#,
|
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000001"}"#,
|
||||||
agen::tool::ToolExecutionContext::direct(),
|
agen::tool::ToolExecutionContext::direct(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ pub(crate) struct InternalWorkerAuthority {
|
|||||||
pub workspace: WorkerWorkspaceContext,
|
pub workspace: WorkerWorkspaceContext,
|
||||||
pub filesystem: WorkerFilesystemAuthority,
|
pub filesystem: WorkerFilesystemAuthority,
|
||||||
pub scope: Scope,
|
pub scope: Scope,
|
||||||
|
/// Provider-bound session inherited in an attenuated form from the owner.
|
||||||
|
pub workdir_session: Option<workdir::WorkdirSessionHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct InternalWorkerSpec {
|
pub(crate) struct InternalWorkerSpec {
|
||||||
@@ -53,6 +55,17 @@ pub(crate) struct InternalWorkerSpec {
|
|||||||
pub input: String,
|
pub input: String,
|
||||||
pub cache_key: Option<String>,
|
pub cache_key: Option<String>,
|
||||||
pub max_turns: Option<u32>,
|
pub max_turns: Option<u32>,
|
||||||
|
pub engine_configurator: Option<
|
||||||
|
Box<
|
||||||
|
dyn FnOnce(
|
||||||
|
&mut Engine<
|
||||||
|
Box<dyn LlmClient>,
|
||||||
|
agen::state::Mutable,
|
||||||
|
crate::SessionHistoryMetadata,
|
||||||
|
>,
|
||||||
|
) + Send,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
pub features: FeatureRegistryBuilder,
|
pub features: FeatureRegistryBuilder,
|
||||||
pub required_tools: &'static [&'static str],
|
pub required_tools: &'static [&'static str],
|
||||||
pub authority: InternalWorkerAuthority,
|
pub authority: InternalWorkerAuthority,
|
||||||
@@ -102,6 +115,7 @@ where
|
|||||||
input,
|
input,
|
||||||
cache_key,
|
cache_key,
|
||||||
max_turns,
|
max_turns,
|
||||||
|
engine_configurator,
|
||||||
features,
|
features,
|
||||||
required_tools,
|
required_tools,
|
||||||
authority,
|
authority,
|
||||||
@@ -120,7 +134,9 @@ where
|
|||||||
|
|
||||||
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
||||||
let usage_slot = last_usage.clone();
|
let usage_slot = last_usage.clone();
|
||||||
let mut engine = Engine::new(client).system_prompt(system_prompt);
|
let mut engine =
|
||||||
|
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
|
||||||
|
.system_prompt(system_prompt);
|
||||||
engine.on_usage(move |usage| {
|
engine.on_usage(move |usage| {
|
||||||
if let Ok(mut slot) = usage_slot.lock() {
|
if let Ok(mut slot) = usage_slot.lock() {
|
||||||
*slot = Some(usage.clone());
|
*slot = Some(usage.clone());
|
||||||
@@ -128,7 +144,11 @@ where
|
|||||||
});
|
});
|
||||||
engine.set_cache_key(cache_key);
|
engine.set_cache_key(cache_key);
|
||||||
engine.set_max_turns(max_turns);
|
engine.set_max_turns(max_turns);
|
||||||
|
if let Some(configure) = engine_configurator {
|
||||||
|
configure(&mut engine);
|
||||||
|
}
|
||||||
let store = EphemeralSessionStore::default();
|
let store = EphemeralSessionStore::default();
|
||||||
|
let inherited_workdir_session = authority.workdir_session.clone();
|
||||||
let mut worker = Worker::new(
|
let mut worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
engine,
|
engine,
|
||||||
@@ -144,6 +164,9 @@ where
|
|||||||
identity: identity.clone(),
|
identity: identity.clone(),
|
||||||
history_entries: 0,
|
history_entries: 0,
|
||||||
})?;
|
})?;
|
||||||
|
if let Some(session) = inherited_workdir_session {
|
||||||
|
worker.bind_workdir_session(Some(session));
|
||||||
|
}
|
||||||
|
|
||||||
let install_report = worker.install_features(features);
|
let install_report = worker.install_features(features);
|
||||||
let installed_tools = install_report.installed_tool_names();
|
let installed_tools = install_report.installed_tool_names();
|
||||||
@@ -250,7 +273,6 @@ impl InternalWorkerSessionStatus {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub(crate) enum InternalWorkerSessionError {
|
pub(crate) enum InternalWorkerSessionError {
|
||||||
#[cfg(test)]
|
|
||||||
#[error("failed to build internal Worker session: {message}")]
|
#[error("failed to build internal Worker session: {message}")]
|
||||||
Build { message: String },
|
Build { message: String },
|
||||||
#[error("internal Worker session is busy")]
|
#[error("internal Worker session is busy")]
|
||||||
@@ -410,7 +432,6 @@ impl InternalWorkerSessionHandle {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus {
|
pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus {
|
||||||
loop {
|
loop {
|
||||||
let notified = self.state_changed.notified();
|
let notified = self.state_changed.notified();
|
||||||
@@ -475,6 +496,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
input,
|
input,
|
||||||
cache_key,
|
cache_key,
|
||||||
max_turns,
|
max_turns,
|
||||||
|
engine_configurator,
|
||||||
features,
|
features,
|
||||||
required_tools,
|
required_tools,
|
||||||
authority,
|
authority,
|
||||||
@@ -484,7 +506,9 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
|
|
||||||
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
||||||
let usage_slot = last_usage.clone();
|
let usage_slot = last_usage.clone();
|
||||||
let mut engine = Engine::new(client).system_prompt(system_prompt);
|
let mut engine =
|
||||||
|
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
|
||||||
|
.system_prompt(system_prompt);
|
||||||
engine.on_usage(move |usage| {
|
engine.on_usage(move |usage| {
|
||||||
if let Ok(mut slot) = usage_slot.lock() {
|
if let Ok(mut slot) = usage_slot.lock() {
|
||||||
*slot = Some(usage.clone());
|
*slot = Some(usage.clone());
|
||||||
@@ -492,7 +516,11 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
});
|
});
|
||||||
engine.set_cache_key(cache_key);
|
engine.set_cache_key(cache_key);
|
||||||
engine.set_max_turns(max_turns);
|
engine.set_max_turns(max_turns);
|
||||||
|
if let Some(configure) = engine_configurator {
|
||||||
|
configure(&mut engine);
|
||||||
|
}
|
||||||
let store = EphemeralSessionStore::default();
|
let store = EphemeralSessionStore::default();
|
||||||
|
let inherited_workdir_session = authority.workdir_session.clone();
|
||||||
let mut worker = Worker::new(
|
let mut worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
engine,
|
engine,
|
||||||
@@ -505,6 +533,9 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
.map_err(|source| InternalWorkerSessionError::Build {
|
.map_err(|source| InternalWorkerSessionError::Build {
|
||||||
message: source.to_string(),
|
message: source.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
if let Some(session) = inherited_workdir_session {
|
||||||
|
worker.bind_workdir_session(Some(session));
|
||||||
|
}
|
||||||
let install_report = worker.install_features(features);
|
let install_report = worker.install_features(features);
|
||||||
let installed_tools = install_report.installed_tool_names();
|
let installed_tools = install_report.installed_tool_names();
|
||||||
let install_failed = install_report
|
let install_failed = install_report
|
||||||
@@ -539,6 +570,104 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
spawn_prepared_internal_worker_session(worker, store, input, None).await
|
spawn_prepared_internal_worker_session(worker, store, input, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prepare an observable Internal Worker from the same bounded spec as one-shot
|
||||||
|
/// helpers, but do not start its first turn. The owner must register the returned
|
||||||
|
/// handle before calling `send`, preserving the snapshot/live boundary.
|
||||||
|
pub(crate) fn prepare_internal_worker_from_spec(
|
||||||
|
spec: InternalWorkerSpec,
|
||||||
|
visibility: InternalWorkerVisibility,
|
||||||
|
) -> std::pin::Pin<
|
||||||
|
Box<
|
||||||
|
dyn std::future::Future<
|
||||||
|
Output = Result<InternalWorkerSessionHandle, InternalWorkerSessionError>,
|
||||||
|
> + Send,
|
||||||
|
>,
|
||||||
|
> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let InternalWorkerSpec {
|
||||||
|
identity,
|
||||||
|
mut manifest,
|
||||||
|
client,
|
||||||
|
system_prompt,
|
||||||
|
input: _,
|
||||||
|
cache_key,
|
||||||
|
max_turns,
|
||||||
|
engine_configurator,
|
||||||
|
features,
|
||||||
|
required_tools,
|
||||||
|
authority,
|
||||||
|
} = spec;
|
||||||
|
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
||||||
|
manifest.feature = Default::default();
|
||||||
|
manifest.plugins = Default::default();
|
||||||
|
manifest.mcp = Default::default();
|
||||||
|
manifest.skills = None;
|
||||||
|
manifest.compaction = None;
|
||||||
|
manifest.memory = None;
|
||||||
|
|
||||||
|
let mut engine =
|
||||||
|
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
|
||||||
|
.system_prompt(system_prompt);
|
||||||
|
engine.set_cache_key(cache_key);
|
||||||
|
engine.set_max_turns(max_turns);
|
||||||
|
if let Some(configure) = engine_configurator {
|
||||||
|
configure(&mut engine);
|
||||||
|
}
|
||||||
|
let store = EphemeralSessionStore::default();
|
||||||
|
let inherited_workdir_session = authority.workdir_session.clone();
|
||||||
|
let mut worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
engine,
|
||||||
|
store.clone(),
|
||||||
|
authority.workspace,
|
||||||
|
authority.filesystem,
|
||||||
|
authority.scope,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|source| InternalWorkerSessionError::Build {
|
||||||
|
message: source.to_string(),
|
||||||
|
})?;
|
||||||
|
if let Some(session) = inherited_workdir_session {
|
||||||
|
worker.bind_workdir_session(Some(session));
|
||||||
|
}
|
||||||
|
let install_report = worker.install_features(features);
|
||||||
|
let installed_tools = install_report.installed_tool_names();
|
||||||
|
let install_failed = install_report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.any(|report| !report.installed);
|
||||||
|
let missing = required_tools
|
||||||
|
.iter()
|
||||||
|
.filter(|required| {
|
||||||
|
!installed_tools
|
||||||
|
.iter()
|
||||||
|
.any(|installed| installed == **required)
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if install_failed || !missing.is_empty() {
|
||||||
|
let diagnostics = install_report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.flat_map(|report| report.diagnostics.iter())
|
||||||
|
.map(|diagnostic| diagnostic.message.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
return Err(InternalWorkerSessionError::Build {
|
||||||
|
message: format!(
|
||||||
|
"internal Worker feature installation failed: {diagnostics}; missing tools: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Box::pin(prepare_internal_worker_session(
|
||||||
|
worker, store, visibility, None, None,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Sender<Event>) {
|
fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Sender<Event>) {
|
||||||
let (_, mut log_rx) = sink.subscribe_with_snapshot();
|
let (_, mut log_rx) = sink.subscribe_with_snapshot();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -1076,12 +1205,14 @@ permission = "write"
|
|||||||
input: "input".to_string(),
|
input: "input".to_string(),
|
||||||
cache_key: Some("internal-test".to_string()),
|
cache_key: Some("internal-test".to_string()),
|
||||||
max_turns: Some(1),
|
max_turns: Some(1),
|
||||||
|
engine_configurator: None,
|
||||||
features: FeatureRegistryBuilder::new(),
|
features: FeatureRegistryBuilder::new(),
|
||||||
required_tools,
|
required_tools,
|
||||||
authority: InternalWorkerAuthority {
|
authority: InternalWorkerAuthority {
|
||||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||||
filesystem: WorkerFilesystemAuthority::None,
|
filesystem: WorkerFilesystemAuthority::None,
|
||||||
scope: Scope::empty(),
|
scope: Scope::empty(),
|
||||||
|
workdir_session: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! decisions (continue / skip / abort / pause).
|
//! decisions (continue / skip / abort / pause).
|
||||||
|
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -33,7 +34,9 @@ use crate::hook::{
|
|||||||
};
|
};
|
||||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
||||||
use crate::prompt::catalog::PromptCatalog;
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
|
use crate::session_history::SessionHistoryMetadata;
|
||||||
use crate::worker::SystemItemCommitter;
|
use crate::worker::SystemItemCommitter;
|
||||||
|
use agen::HistoryEntry;
|
||||||
use agen::token_counter::total_tokens;
|
use agen::token_counter::total_tokens;
|
||||||
|
|
||||||
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
||||||
@@ -73,6 +76,7 @@ pub(crate) struct WorkerInterceptor {
|
|||||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||||
/// attached.
|
/// attached.
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||||
|
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||||
/// Next turn index assigned by `on_prompt_submit`.
|
/// Next turn index assigned by `on_prompt_submit`.
|
||||||
next_turn_index: AtomicUsize,
|
next_turn_index: AtomicUsize,
|
||||||
/// Tool calls observed in the current turn (reset on each new prompt).
|
/// Tool calls observed in the current turn (reset on each new prompt).
|
||||||
@@ -80,6 +84,7 @@ pub(crate) struct WorkerInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerInterceptor {
|
impl WorkerInterceptor {
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
registry: Arc<HookRegistry>,
|
registry: Arc<HookRegistry>,
|
||||||
compact_state: Option<Arc<CompactState>>,
|
compact_state: Option<Arc<CompactState>>,
|
||||||
@@ -88,6 +93,28 @@ impl WorkerInterceptor {
|
|||||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new_with_history_queue(
|
||||||
|
registry,
|
||||||
|
compact_state,
|
||||||
|
usage_history,
|
||||||
|
pending_notifies,
|
||||||
|
pending_attachments,
|
||||||
|
prompts,
|
||||||
|
log_writer,
|
||||||
|
Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_with_history_queue(
|
||||||
|
registry: Arc<HookRegistry>,
|
||||||
|
compact_state: Option<Arc<CompactState>>,
|
||||||
|
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
||||||
|
pending_notifies: NotifyBuffer,
|
||||||
|
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
|
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||||
|
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
registry,
|
registry,
|
||||||
@@ -99,6 +126,7 @@ impl WorkerInterceptor {
|
|||||||
prompts,
|
prompts,
|
||||||
prompt_workspace_id: None,
|
prompt_workspace_id: None,
|
||||||
log_writer,
|
log_writer,
|
||||||
|
pending_committed_history,
|
||||||
next_turn_index: AtomicUsize::new(0),
|
next_turn_index: AtomicUsize::new(0),
|
||||||
tool_calls_this_turn: AtomicUsize::new(0),
|
tool_calls_this_turn: AtomicUsize::new(0),
|
||||||
}
|
}
|
||||||
@@ -125,7 +153,11 @@ impl WorkerInterceptor {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
for item in items {
|
for item in items {
|
||||||
writer.commit_system_item(item.clone())?;
|
let entry = writer.commit_system_item(item.clone())?;
|
||||||
|
self.pending_committed_history
|
||||||
|
.lock()
|
||||||
|
.expect("pending committed history poisoned")
|
||||||
|
.push_back(entry);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -507,7 +539,12 @@ mod tests {
|
|||||||
&self,
|
&self,
|
||||||
entry: session_store::LogEntry,
|
entry: session_store::LogEntry,
|
||||||
) -> Result<(), session_store::StoreError> {
|
) -> Result<(), session_store::StoreError> {
|
||||||
if let session_store::LogEntry::SystemItem { item, .. } = entry {
|
let item = match entry {
|
||||||
|
session_store::LogEntry::SystemItem { item, .. } => Some(item),
|
||||||
|
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(item) = item {
|
||||||
self.committed
|
self.committed
|
||||||
.lock()
|
.lock()
|
||||||
.expect("committed system-item list poisoned")
|
.expect("committed system-item list poisoned")
|
||||||
|
|||||||
@@ -29,15 +29,21 @@ pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtoco
|
|||||||
|
|
||||||
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
||||||
match entry {
|
match entry {
|
||||||
LogEntry::SegmentStart { .. } => {
|
entry @ (LogEntry::SegmentStart { .. } | LogEntry::AnnotatedSegmentStart { .. }) => {
|
||||||
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
|
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
|
||||||
Some(Event::SegmentRotated { entry: value })
|
Some(Event::SegmentRotated { entry: value })
|
||||||
}
|
}
|
||||||
LogEntry::UserInput { segments, .. } => Some(Event::UserMessage { segments }),
|
LogEntry::UserInput { segments, .. } | LogEntry::AnnotatedUserInput { segments, .. } => {
|
||||||
|
Some(Event::UserMessage { segments })
|
||||||
|
}
|
||||||
LogEntry::SystemItem { item, .. } => {
|
LogEntry::SystemItem { item, .. } => {
|
||||||
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
|
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
|
||||||
Some(Event::SystemItem { item: value })
|
Some(Event::SystemItem { item: value })
|
||||||
}
|
}
|
||||||
|
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||||
|
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
||||||
|
Some(Event::SystemItem { item: value })
|
||||||
|
}
|
||||||
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
|
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
|
||||||
other => {
|
other => {
|
||||||
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant
|
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod prompt;
|
|||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod segment_log_sink;
|
pub mod segment_log_sink;
|
||||||
mod session_capture;
|
mod session_capture;
|
||||||
|
mod session_history;
|
||||||
pub mod shared_state;
|
pub mod shared_state;
|
||||||
mod shutdown_after_idle;
|
mod shutdown_after_idle;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
@@ -41,6 +42,10 @@ pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTem
|
|||||||
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
|
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
|
||||||
pub use runtime::dir::RuntimeDir;
|
pub use runtime::dir::RuntimeDir;
|
||||||
pub use segment_log_sink::SegmentLogSink;
|
pub use segment_log_sink::SegmentLogSink;
|
||||||
|
pub use session_history::{
|
||||||
|
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
|
||||||
|
WorkerHistoryProvenance, WorkerSubjectSnapshot,
|
||||||
|
};
|
||||||
pub use shared_state::WorkerSharedState;
|
pub use shared_state::WorkerSharedState;
|
||||||
pub use worker::{
|
pub use worker::{
|
||||||
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
|
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ impl PermissionHook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: LlmClient, St: Store> Worker<C, St> {
|
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||||
pub(crate) fn apply_permissions_from_manifest(&mut self) {
|
pub(crate) fn apply_permissions_from_manifest(&mut self) {
|
||||||
let Some(permissions) = self.manifest().permissions.clone() else {
|
let Some(permissions) = self.manifest().permissions.clone() else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ pub enum WorkerPrompt {
|
|||||||
AgentsMdSection,
|
AgentsMdSection,
|
||||||
ResidentMemorySummarySection,
|
ResidentMemorySummarySection,
|
||||||
WorkerOrchestrationGuidanceSection,
|
WorkerOrchestrationGuidanceSection,
|
||||||
TicketEventCompanionNotice,
|
|
||||||
SubWorkerSpawnToolDescription,
|
SubWorkerSpawnToolDescription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +121,6 @@ impl WorkerPrompt {
|
|||||||
Self::WorkerOrchestrationGuidanceSection => {
|
Self::WorkerOrchestrationGuidanceSection => {
|
||||||
"internal.worker_orchestration_guidance_section"
|
"internal.worker_orchestration_guidance_section"
|
||||||
}
|
}
|
||||||
Self::TicketEventCompanionNotice => "worker.ticket_event_companion_notice",
|
|
||||||
Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description",
|
Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +137,6 @@ impl WorkerPrompt {
|
|||||||
WorkerPrompt::AgentsMdSection,
|
WorkerPrompt::AgentsMdSection,
|
||||||
WorkerPrompt::ResidentMemorySummarySection,
|
WorkerPrompt::ResidentMemorySummarySection,
|
||||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||||
WorkerPrompt::TicketEventCompanionNotice,
|
|
||||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
WorkerPrompt::SubWorkerSpawnToolDescription,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -593,6 +590,12 @@ mod tests {
|
|||||||
fn builtin_dcdl_catalog_loads() {
|
fn builtin_dcdl_catalog_loads() {
|
||||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||||
assert!(!catalog.projection.templates.is_empty());
|
assert!(!catalog.projection.templates.is_empty());
|
||||||
|
assert!(
|
||||||
|
!catalog
|
||||||
|
.projection
|
||||||
|
.templates
|
||||||
|
.contains_key("worker.ticket_event_companion_notice")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -609,6 +612,47 @@ mod tests {
|
|||||||
assert!(!prompt.contains("use the Ticket repository `origin` transport"));
|
assert!(!prompt.contains("use the Ticket repository `origin` transport"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_request_prompts_use_selector_refs_and_verb_first_operations() {
|
||||||
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||||
|
let templates = &catalog.projection.templates;
|
||||||
|
let common = &templates["common.merge_request"];
|
||||||
|
let coder = &templates["role.coder"];
|
||||||
|
let orchestrator = &templates["role.orchestrator"];
|
||||||
|
let reviewer = &templates["role.reviewer"];
|
||||||
|
let combined = format!("{common}\n{coder}\n{orchestrator}\n{reviewer}");
|
||||||
|
|
||||||
|
for operation in [
|
||||||
|
"OpenMergeRequest",
|
||||||
|
"ShowMergeRequest",
|
||||||
|
"ReviewMergeRequest",
|
||||||
|
"CheckMergeRequestReadiness",
|
||||||
|
"CompleteMergeRequest",
|
||||||
|
] {
|
||||||
|
assert!(combined.contains(operation), "missing {operation}");
|
||||||
|
}
|
||||||
|
for stale_operation in [
|
||||||
|
"MergeRequestOpen",
|
||||||
|
"MergeRequestShow",
|
||||||
|
"MergeRequestReview",
|
||||||
|
"MergeRequestReadinessCheck",
|
||||||
|
"MergeRequestComplete",
|
||||||
|
"MergeRequestAddRevision",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!combined.contains(stale_operation),
|
||||||
|
"stale operation {stale_operation} remains in prompt authority"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(common.contains("selector_from"));
|
||||||
|
assert!(common.contains("normal non-force push"));
|
||||||
|
assert!(common.contains("Moving only the target ref does not invalidate approval"));
|
||||||
|
assert!(common.contains("take precedence over stale Memory"));
|
||||||
|
assert!(coder.contains("Never invent an add-revision operation"));
|
||||||
|
assert!(orchestrator.contains("Target-only movement preserves source approval"));
|
||||||
|
assert!(reviewer.contains("target-only movement does not invalidate approval"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn graph_rejects_dynamic_legacy_missing_and_cycles() {
|
fn graph_rejects_dynamic_legacy_missing_and_cycles() {
|
||||||
let invalid = BTreeMap::from([
|
let invalid = BTreeMap::from([
|
||||||
|
|||||||
@@ -121,8 +121,11 @@ impl SegmentLogSink {
|
|||||||
matches!(
|
matches!(
|
||||||
entry,
|
entry,
|
||||||
LogEntry::SegmentStart { .. }
|
LogEntry::SegmentStart { .. }
|
||||||
|
| LogEntry::AnnotatedSegmentStart { .. }
|
||||||
| LogEntry::UserInput { .. }
|
| LogEntry::UserInput { .. }
|
||||||
|
| LogEntry::AnnotatedUserInput { .. }
|
||||||
| LogEntry::SystemItem { .. }
|
| LogEntry::SystemItem { .. }
|
||||||
|
| LogEntry::AnnotatedSystemItem { .. }
|
||||||
| LogEntry::Invoke { .. }
|
| LogEntry::Invoke { .. }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use agen::{Item, Role};
|
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
|
||||||
|
use agen::{HistoryEntry, Item, Role};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const DEFAULT_SEARCH_LIMIT: usize = 20;
|
const DEFAULT_SEARCH_LIMIT: usize = 20;
|
||||||
@@ -21,14 +22,21 @@ const OVERVIEW_ANCHOR_STRIDE: usize = 8;
|
|||||||
pub(crate) struct SessionEntryRef(String);
|
pub(crate) struct SessionEntryRef(String);
|
||||||
|
|
||||||
impl SessionEntryRef {
|
impl SessionEntryRef {
|
||||||
pub(crate) fn new(source_index: usize) -> Self {
|
pub(crate) fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
|
||||||
Self(format!("E{source_index:08}"))
|
Self(format!("E{}", entry_id.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse(value: &str) -> Option<Self> {
|
pub(crate) fn parse(value: &str) -> Option<Self> {
|
||||||
let reference = Self(value.to_string());
|
let suffix = value.strip_prefix('E')?;
|
||||||
reference.source_index()?;
|
if suffix.is_empty()
|
||||||
Some(reference)
|
|| suffix.len() > 64
|
||||||
|
|| !suffix
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self(value.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn as_str(&self) -> &str {
|
pub(crate) fn as_str(&self) -> &str {
|
||||||
@@ -97,6 +105,7 @@ impl ToolPart {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct OverviewItem {
|
pub(crate) struct OverviewItem {
|
||||||
pub id: SessionEntryRef,
|
pub id: SessionEntryRef,
|
||||||
|
pub origin: WorkerHistoryProvenance,
|
||||||
pub entry_range: [u64; 2],
|
pub entry_range: [u64; 2],
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -107,6 +116,7 @@ pub(crate) struct OverviewItem {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct ReferenceEntry {
|
pub(crate) struct ReferenceEntry {
|
||||||
pub id: SessionEntryRef,
|
pub id: SessionEntryRef,
|
||||||
|
pub origin: WorkerHistoryProvenance,
|
||||||
pub entry_range: [u64; 2],
|
pub entry_range: [u64; 2],
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub tool_part: Option<ToolPart>,
|
pub tool_part: Option<ToolPart>,
|
||||||
@@ -132,6 +142,7 @@ pub(crate) struct SearchOptions {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct SearchHit {
|
pub(crate) struct SearchHit {
|
||||||
pub id: SessionEntryRef,
|
pub id: SessionEntryRef,
|
||||||
|
pub origin: WorkerHistoryProvenance,
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub tool_part: Option<ToolPart>,
|
pub tool_part: Option<ToolPart>,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
@@ -143,6 +154,7 @@ pub(crate) struct SearchHit {
|
|||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub(crate) enum ReadSelector<'a> {
|
pub(crate) enum ReadSelector<'a> {
|
||||||
Id(&'a str),
|
Id(&'a str),
|
||||||
|
#[cfg(test)]
|
||||||
EntryRange([u64; 2]),
|
EntryRange([u64; 2]),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +188,7 @@ impl Default for ReadOptions {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct ReadEntry {
|
pub(crate) struct ReadEntry {
|
||||||
pub id: SessionEntryRef,
|
pub id: SessionEntryRef,
|
||||||
|
pub origin: WorkerHistoryProvenance,
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub tool_part: Option<ToolPart>,
|
pub tool_part: Option<ToolPart>,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
@@ -194,6 +207,7 @@ pub(crate) struct ReadResult {
|
|||||||
pub(crate) struct SessionEntryEvidence {
|
pub(crate) struct SessionEntryEvidence {
|
||||||
pub segment_id: String,
|
pub segment_id: String,
|
||||||
pub entry_ref: SessionEntryRef,
|
pub entry_ref: SessionEntryRef,
|
||||||
|
pub origin: WorkerHistoryProvenance,
|
||||||
pub entry_range: [u64; 2],
|
pub entry_range: [u64; 2],
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub tool_part: Option<ToolPart>,
|
pub tool_part: Option<ToolPart>,
|
||||||
@@ -205,26 +219,42 @@ pub(crate) struct SessionEntryEvidence {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct SessionCapture {
|
pub(crate) struct SessionCapture {
|
||||||
segment_id: String,
|
segment_id: String,
|
||||||
items: Arc<Vec<Item>>,
|
entries: Arc<Vec<HistoryEntry<SessionHistoryMetadata>>>,
|
||||||
overview: Vec<OverviewItem>,
|
overview: Vec<OverviewItem>,
|
||||||
index: Vec<ReferenceEntry>,
|
index: Vec<ReferenceEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionCapture {
|
impl SessionCapture {
|
||||||
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
|
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
|
||||||
|
let entries = items
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, item)| {
|
||||||
|
let mut metadata = SessionHistoryMetadata::legacy_unknown();
|
||||||
|
metadata.entry_id =
|
||||||
|
session_store::LoggedSessionHistoryEntryId(format!("{index:08}"));
|
||||||
|
HistoryEntry::new(item, metadata)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self::from_history_entries(segment_id, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_history_entries(
|
||||||
|
segment_id: impl Into<String>,
|
||||||
|
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
||||||
|
) -> Self {
|
||||||
let segment_id = segment_id.into();
|
let segment_id = segment_id.into();
|
||||||
let items = Arc::new(items);
|
let entries = Arc::new(entries);
|
||||||
let mut overview = Vec::new();
|
let mut overview = Vec::new();
|
||||||
let mut index = Vec::new();
|
let mut index = Vec::new();
|
||||||
|
|
||||||
for (idx, item) in items.iter().enumerate() {
|
for (idx, entry) in entries.iter().enumerate() {
|
||||||
|
let item = &entry.item;
|
||||||
let entry_range = [idx as u64, idx as u64];
|
let entry_range = [idx as u64, idx as u64];
|
||||||
match item {
|
match item {
|
||||||
Item::Message { role, content, .. } => {
|
Item::Message { role, content, .. } => {
|
||||||
let kind = match role {
|
let Some(kind) = message_reference_kind(&entry.annotation.origin, role) else {
|
||||||
Role::User => ReferenceKind::User,
|
continue;
|
||||||
Role::Assistant => ReferenceKind::Assistant,
|
|
||||||
Role::System => continue,
|
|
||||||
};
|
};
|
||||||
let text = content
|
let text = content
|
||||||
.iter()
|
.iter()
|
||||||
@@ -233,9 +263,10 @@ impl SessionCapture {
|
|||||||
.join("");
|
.join("");
|
||||||
let label = format!("{} message", kind.as_str());
|
let label = format!("{} message", kind.as_str());
|
||||||
let summary = truncate_chars(&text, 240);
|
let summary = truncate_chars(&text, 240);
|
||||||
let id = SessionEntryRef::new(idx);
|
let id = SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id);
|
||||||
index.push(ReferenceEntry {
|
index.push(ReferenceEntry {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
origin: entry.annotation.origin.clone(),
|
||||||
entry_range,
|
entry_range,
|
||||||
kind,
|
kind,
|
||||||
tool_part: None,
|
tool_part: None,
|
||||||
@@ -247,6 +278,7 @@ impl SessionCapture {
|
|||||||
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
|
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
|
||||||
overview.push(OverviewItem {
|
overview.push(OverviewItem {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
origin: entry.annotation.origin.clone(),
|
||||||
entry_range,
|
entry_range,
|
||||||
kind,
|
kind,
|
||||||
label,
|
label,
|
||||||
@@ -260,7 +292,8 @@ impl SessionCapture {
|
|||||||
} => {
|
} => {
|
||||||
let text = format!("{name}\n{arguments}");
|
let text = format!("{name}\n{arguments}");
|
||||||
index.push(ReferenceEntry {
|
index.push(ReferenceEntry {
|
||||||
id: SessionEntryRef::new(idx),
|
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
|
||||||
|
origin: entry.annotation.origin.clone(),
|
||||||
entry_range,
|
entry_range,
|
||||||
kind: ReferenceKind::Tool,
|
kind: ReferenceKind::Tool,
|
||||||
tool_part: Some(ToolPart::Input),
|
tool_part: Some(ToolPart::Input),
|
||||||
@@ -286,7 +319,8 @@ impl SessionCapture {
|
|||||||
content.as_deref().unwrap_or_default(),
|
content.as_deref().unwrap_or_default(),
|
||||||
);
|
);
|
||||||
index.push(ReferenceEntry {
|
index.push(ReferenceEntry {
|
||||||
id: SessionEntryRef::new(idx),
|
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
|
||||||
|
origin: entry.annotation.origin.clone(),
|
||||||
entry_range,
|
entry_range,
|
||||||
kind: ReferenceKind::Tool,
|
kind: ReferenceKind::Tool,
|
||||||
tool_part: Some(ToolPart::Output),
|
tool_part: Some(ToolPart::Output),
|
||||||
@@ -326,7 +360,7 @@ impl SessionCapture {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
segment_id,
|
segment_id,
|
||||||
items,
|
entries,
|
||||||
overview,
|
overview,
|
||||||
index,
|
index,
|
||||||
}
|
}
|
||||||
@@ -336,6 +370,14 @@ impl SessionCapture {
|
|||||||
&self.overview
|
&self.overview
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn source_index_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
|
||||||
|
self.index
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.id == *reference)
|
||||||
|
.map(|entry| entry.entry_range[0])
|
||||||
|
.or_else(|| reference.source_index())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
|
pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
|
||||||
let query = options.query.trim().to_lowercase();
|
let query = options.query.trim().to_lowercase();
|
||||||
let limit = options
|
let limit = options
|
||||||
@@ -346,12 +388,12 @@ impl SessionCapture {
|
|||||||
let min_entry_index = options
|
let min_entry_index = options
|
||||||
.from
|
.from
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(SessionEntryRef::source_index)
|
.and_then(|reference| self.source_index_for_ref(reference))
|
||||||
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
|
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
|
||||||
let max_entry_index = options
|
let max_entry_index = options
|
||||||
.through
|
.through
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(SessionEntryRef::source_index)
|
.and_then(|reference| self.source_index_for_ref(reference))
|
||||||
.unwrap_or(u64::MAX);
|
.unwrap_or(u64::MAX);
|
||||||
let mut skipped = 0usize;
|
let mut skipped = 0usize;
|
||||||
let mut hits = Vec::new();
|
let mut hits = Vec::new();
|
||||||
@@ -390,6 +432,7 @@ impl SessionCapture {
|
|||||||
}
|
}
|
||||||
hits.push(SearchHit {
|
hits.push(SearchHit {
|
||||||
id: entry.id.clone(),
|
id: entry.id.clone(),
|
||||||
|
origin: entry.origin.clone(),
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
tool_part: entry.tool_part,
|
tool_part: entry.tool_part,
|
||||||
tool_name: entry.tool_name.clone(),
|
tool_name: entry.tool_name.clone(),
|
||||||
@@ -418,6 +461,7 @@ impl SessionCapture {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|entry| entry.id.as_str() == id)
|
.filter(|entry| entry.id.as_str() == id)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
#[cfg(test)]
|
||||||
ReadSelector::EntryRange([start, end]) => self
|
ReadSelector::EntryRange([start, end]) => self
|
||||||
.index
|
.index
|
||||||
.iter()
|
.iter()
|
||||||
@@ -440,13 +484,18 @@ impl SessionCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let Some(item) = self.items.get(entry.entry_range[0] as usize) else {
|
let Some(item) = self
|
||||||
|
.entries
|
||||||
|
.get(entry.entry_range[0] as usize)
|
||||||
|
.map(|entry| &entry.item)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes));
|
let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes));
|
||||||
bytes = bytes.saturating_add(text.len());
|
bytes = bytes.saturating_add(text.len());
|
||||||
entries.push(ReadEntry {
|
entries.push(ReadEntry {
|
||||||
id: entry.id.clone(),
|
id: entry.id.clone(),
|
||||||
|
origin: entry.origin.clone(),
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
tool_part: entry.tool_part,
|
tool_part: entry.tool_part,
|
||||||
tool_name: entry.tool_name.clone(),
|
tool_name: entry.tool_name.clone(),
|
||||||
@@ -483,6 +532,7 @@ impl SessionCapture {
|
|||||||
Some(SessionEntryEvidence {
|
Some(SessionEntryEvidence {
|
||||||
segment_id: self.segment_id.clone(),
|
segment_id: self.segment_id.clone(),
|
||||||
entry_ref: entry.id.clone(),
|
entry_ref: entry.id.clone(),
|
||||||
|
origin: entry.origin.clone(),
|
||||||
entry_range: entry.entry_range,
|
entry_range: entry.entry_range,
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
tool_part: entry.tool_part,
|
tool_part: entry.tool_part,
|
||||||
@@ -493,6 +543,28 @@ impl SessionCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn message_reference_kind(
|
||||||
|
origin: &WorkerHistoryProvenance,
|
||||||
|
provider_role: &Role,
|
||||||
|
) -> Option<ReferenceKind> {
|
||||||
|
match origin {
|
||||||
|
WorkerHistoryProvenance::HumanInput { .. }
|
||||||
|
| WorkerHistoryProvenance::WorkerInput { .. } => Some(ReferenceKind::User),
|
||||||
|
WorkerHistoryProvenance::ModelOutput { .. } => Some(ReferenceKind::Assistant),
|
||||||
|
WorkerHistoryProvenance::ToolOutput { .. } => Some(ReferenceKind::Tool),
|
||||||
|
WorkerHistoryProvenance::LegacyUnknown => match provider_role {
|
||||||
|
Role::User => Some(ReferenceKind::User),
|
||||||
|
Role::Assistant => Some(ReferenceKind::Assistant),
|
||||||
|
Role::System => None,
|
||||||
|
},
|
||||||
|
// Flow/backend/system content remains out of the observation surface
|
||||||
|
// even when represented with a provider user/system role.
|
||||||
|
WorkerHistoryProvenance::FlowInstruction { .. }
|
||||||
|
| WorkerHistoryProvenance::BackendInstruction { .. }
|
||||||
|
| WorkerHistoryProvenance::DerivedSummary => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render_item(
|
fn render_item(
|
||||||
item: &Item,
|
item: &Item,
|
||||||
entry: &ReferenceEntry,
|
entry: &ReferenceEntry,
|
||||||
@@ -561,6 +633,60 @@ fn truncate_chars(text: &str, max_chars: usize) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_user_role_is_excluded_while_explicit_human_origin_remains_evidence() {
|
||||||
|
let entries = vec![
|
||||||
|
crate::session_history::history_entry(
|
||||||
|
Item::user_message("trusted flow instruction"),
|
||||||
|
WorkerHistoryProvenance::FlowInstruction {
|
||||||
|
selector: "builtin:coder-review".into(),
|
||||||
|
definition_id: "coder-review".into(),
|
||||||
|
definition_revision: 3,
|
||||||
|
instance_id: "instance".into(),
|
||||||
|
state_id: "implement".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
crate::session_history::history_entry(
|
||||||
|
Item::user_message("remember my preference"),
|
||||||
|
WorkerHistoryProvenance::HumanInput {
|
||||||
|
account_id: "account-1".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let capture = SessionCapture::from_history_entries("segment", entries);
|
||||||
|
let overview = capture.overview();
|
||||||
|
assert_eq!(overview.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
overview[0].origin,
|
||||||
|
WorkerHistoryProvenance::HumanInput { .. }
|
||||||
|
));
|
||||||
|
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
|
||||||
|
assert!(evidence.excerpt.ends_with("remember my preference"));
|
||||||
|
assert!(matches!(
|
||||||
|
evidence.origin,
|
||||||
|
WorkerHistoryProvenance::HumanInput { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_logical_ref_survives_retention_and_restore_projection() {
|
||||||
|
let retained = crate::session_history::history_entry(
|
||||||
|
Item::assistant_message("retained"),
|
||||||
|
WorkerHistoryProvenance::ModelOutput {
|
||||||
|
worker: crate::session_history::worker_subject(Default::default()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let expected_ref = SessionEntryRef::from_history_entry_id(&retained.annotation.entry_id);
|
||||||
|
let before = SessionCapture::from_history_entries("old", vec![retained.clone()]);
|
||||||
|
let after = SessionCapture::from_history_entries("new", vec![retained]);
|
||||||
|
assert_eq!(before.overview()[0].id, expected_ref);
|
||||||
|
assert_eq!(after.overview()[0].id, expected_ref);
|
||||||
|
assert_eq!(
|
||||||
|
after.evidence_for(expected_ref.as_str()).unwrap().entry_ref,
|
||||||
|
expected_ref
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overview_contains_user_and_assistant_only() {
|
fn overview_contains_user_and_assistant_only() {
|
||||||
let view = SessionCapture::new(
|
let view = SessionCapture::new(
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! Restore-authoritative metadata for model-visible Worker history.
|
||||||
|
//!
|
||||||
|
//! Agen transports this annotation without interpreting it. Session Log v2
|
||||||
|
//! stores each item and metadata in one typed record; legacy records are
|
||||||
|
//! retained only as explicit `LegacyUnknown` entries.
|
||||||
|
|
||||||
|
use agen::{HistoryEntry, Item};
|
||||||
|
use protocol::Segment;
|
||||||
|
use session_store::{
|
||||||
|
LogEntry, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
||||||
|
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedWorkerSubject, SegmentId,
|
||||||
|
SessionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type SessionHistoryEntryId = LoggedSessionHistoryEntryId;
|
||||||
|
pub type SessionHistoryMetadata = LoggedSessionHistoryMetadata;
|
||||||
|
pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
|
||||||
|
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
|
||||||
|
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
|
||||||
|
|
||||||
|
pub(crate) fn worker_subject(session_id: SessionId) -> WorkerSubjectSnapshot {
|
||||||
|
WorkerSubjectSnapshot {
|
||||||
|
workspace_id: None,
|
||||||
|
runtime_id: None,
|
||||||
|
worker_id: session_id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn metadata(
|
||||||
|
origin: WorkerHistoryProvenance,
|
||||||
|
derivation: Option<SessionHistoryDerivation>,
|
||||||
|
) -> SessionHistoryMetadata {
|
||||||
|
SessionHistoryMetadata {
|
||||||
|
entry_id: SessionHistoryEntryId::new(),
|
||||||
|
origin,
|
||||||
|
derivation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn history_entry(
|
||||||
|
item: Item,
|
||||||
|
origin: WorkerHistoryProvenance,
|
||||||
|
) -> HistoryEntry<SessionHistoryMetadata> {
|
||||||
|
HistoryEntry::new(item, metadata(origin, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_logged_history_entry(
|
||||||
|
entry: &HistoryEntry<SessionHistoryMetadata>,
|
||||||
|
) -> LoggedHistoryEntry {
|
||||||
|
LoggedHistoryEntry {
|
||||||
|
item: entry.item.clone().into(),
|
||||||
|
metadata: entry.annotation.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_entry(item: Item) -> HistoryEntry<SessionHistoryMetadata> {
|
||||||
|
HistoryEntry::new(item, SessionHistoryMetadata::legacy_unknown())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadata> {
|
||||||
|
HistoryEntry::new(Item::from(entry.item.clone()), entry.metadata.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild typed Worker history directly from the append-only Session Log.
|
||||||
|
/// Missing legacy metadata is never inferred from role or plaintext.
|
||||||
|
pub(crate) fn restore_history_entries(
|
||||||
|
_session_id: SessionId,
|
||||||
|
_segment_id: SegmentId,
|
||||||
|
entries: &[LogEntry],
|
||||||
|
) -> Result<Vec<HistoryEntry<SessionHistoryMetadata>>, String> {
|
||||||
|
let mut history = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
match entry {
|
||||||
|
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
|
||||||
|
history = seed.iter().map(from_logged).collect();
|
||||||
|
}
|
||||||
|
LogEntry::SegmentStart { history: seed, .. } => {
|
||||||
|
history = seed
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(Item::from)
|
||||||
|
.map(legacy_entry)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
LogEntry::AnnotatedUserInput { history: input, .. } => {
|
||||||
|
history.extend(input.iter().map(from_logged))
|
||||||
|
}
|
||||||
|
LogEntry::UserInput { segments, .. } => history.push(legacy_entry(Item::user_message(
|
||||||
|
Segment::flatten_to_text(segments),
|
||||||
|
))),
|
||||||
|
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||||
|
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
|
||||||
|
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
|
||||||
|
history.push(legacy_entry(Item::from(item.clone())));
|
||||||
|
}
|
||||||
|
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
|
||||||
|
entry.item.to_history_item(),
|
||||||
|
entry.metadata.clone(),
|
||||||
|
)),
|
||||||
|
LogEntry::SystemItem { item, .. } => {
|
||||||
|
history.push(legacy_entry(item.to_history_item()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(history)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use agen::llm_client::RequestConfig;
|
||||||
|
use session_store::LogEntry;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_user_role_is_not_inferred_as_human_authority() {
|
||||||
|
let entries = vec![LogEntry::UserInput {
|
||||||
|
ts: 1,
|
||||||
|
segments: vec![Segment::text("legacy")],
|
||||||
|
extensions: Vec::new(),
|
||||||
|
}];
|
||||||
|
let restored =
|
||||||
|
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &entries).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
restored[0].annotation.origin,
|
||||||
|
WorkerHistoryProvenance::LegacyUnknown
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
|
||||||
|
let session_id = SessionId::now_v7();
|
||||||
|
let projected = vec![
|
||||||
|
history_entry(
|
||||||
|
Item::user_message("flow instructions"),
|
||||||
|
WorkerHistoryProvenance::FlowInstruction {
|
||||||
|
selector: "builtin:coder-review".to_string(),
|
||||||
|
definition_id: "coder-review".to_string(),
|
||||||
|
definition_revision: 7,
|
||||||
|
instance_id: "flow-instance".to_string(),
|
||||||
|
state_id: "implement".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
history_entry(
|
||||||
|
Item::user_message("implement"),
|
||||||
|
WorkerHistoryProvenance::LegacyUnknown,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let entries = vec![
|
||||||
|
LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 0,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: RequestConfig::default(),
|
||||||
|
history: Vec::new(),
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
},
|
||||||
|
LogEntry::AnnotatedUserInput {
|
||||||
|
ts: 1,
|
||||||
|
segments: vec![
|
||||||
|
Segment::Flow {
|
||||||
|
selector: "builtin:coder-review".to_string(),
|
||||||
|
},
|
||||||
|
Segment::text("implement"),
|
||||||
|
],
|
||||||
|
extensions: Vec::new(),
|
||||||
|
history: projected.iter().map(to_logged_history_entry).collect(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let restored = restore_history_entries(session_id, SegmentId::now_v7(), &entries).unwrap();
|
||||||
|
assert_eq!(restored, projected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotated_restore_preserves_logical_ids_across_reboot() {
|
||||||
|
let session_id = SessionId::now_v7();
|
||||||
|
let entry = history_entry(
|
||||||
|
Item::assistant_message("persisted"),
|
||||||
|
WorkerHistoryProvenance::ModelOutput {
|
||||||
|
worker: worker_subject(session_id),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let log = vec![LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 0,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: RequestConfig::default(),
|
||||||
|
history: vec![to_logged_history_entry(&entry)],
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
}];
|
||||||
|
let first = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
|
||||||
|
let second = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
|
||||||
|
assert_eq!(first[0].annotation.entry_id, entry.annotation.entry_id);
|
||||||
|
assert_eq!(second[0].annotation.entry_id, entry.annotation.entry_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compacted_derivation_uses_stable_logical_entry_ids() {
|
||||||
|
let source = history_entry(
|
||||||
|
Item::user_message("source"),
|
||||||
|
WorkerHistoryProvenance::LegacyUnknown,
|
||||||
|
);
|
||||||
|
let summary = HistoryEntry::new(
|
||||||
|
Item::system_message("summary"),
|
||||||
|
metadata(
|
||||||
|
WorkerHistoryProvenance::DerivedSummary,
|
||||||
|
Some(SessionHistoryDerivation {
|
||||||
|
sources: vec![source.annotation.entry_id.clone()],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
summary.annotation.derivation.unwrap().sources,
|
||||||
|
vec![source.annotation.entry_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Parent-owned registry of direct Internal SubWorker sessions.
|
//! Parent-owned registry of direct Internal Worker sessions.
|
||||||
//!
|
//!
|
||||||
//! `SubWorkerSpawn` inserts typed `InternalWorkerSessionHandle`s; List/Send/Stop and
|
//! `SubWorkerSpawn` inserts controllable SubWorker handles, while host services such as
|
||||||
//! worker-observation use the same in-memory authority. Internal children are not persisted, restored, discovered as
|
//! compaction insert parent-visible service handles without joining the model-facing
|
||||||
|
//! List/Send/Stop surface. Internal children are not persisted, restored, discovered as
|
||||||
//! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process
|
//! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process
|
||||||
//! child records only to reclaim their delegated scope and clear obsolete metadata.
|
//! child records only to reclaim their delegated scope and clear obsolete metadata.
|
||||||
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny
|
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny
|
||||||
@@ -177,6 +178,52 @@ impl InternalSpawnedWorkerRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parent-visible service Internal Worker. Unlike a SubWorker this record has no
|
||||||
|
/// delegated scope, model-facing control name, or stop-summary authority.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct InternalServiceWorkerRecord {
|
||||||
|
pub service_kind: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub session: InternalWorkerSessionHandle,
|
||||||
|
protocol_revision: Arc<AtomicU64>,
|
||||||
|
protocol_emit_lock: Arc<Mutex<()>>,
|
||||||
|
protocol_terminal: Arc<AtomicBool>,
|
||||||
|
forwarding_started: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InternalServiceWorkerRecord {
|
||||||
|
pub(crate) fn new(
|
||||||
|
service_kind: impl Into<String>,
|
||||||
|
display_name: impl Into<String>,
|
||||||
|
session: InternalWorkerSessionHandle,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
service_kind: service_kind.into(),
|
||||||
|
display_name: display_name.into(),
|
||||||
|
session,
|
||||||
|
protocol_revision: Arc::new(AtomicU64::new(0)),
|
||||||
|
protocol_emit_lock: Arc::new(Mutex::new(())),
|
||||||
|
protocol_terminal: Arc::new(AtomicBool::new(false)),
|
||||||
|
forwarding_started: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_ref(&self, parent_session_id: Option<String>) -> InternalWorkerRef {
|
||||||
|
InternalWorkerRef {
|
||||||
|
session_id: self.session.session_id_string(),
|
||||||
|
name: self.display_name.clone(),
|
||||||
|
parent_session_id,
|
||||||
|
kind: InternalWorkerKind::Service {
|
||||||
|
kind: self.service_kind.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_revision(&self) -> u64 {
|
||||||
|
self.protocol_revision.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct InternalSpawnReservation {
|
pub(crate) struct InternalSpawnReservation {
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
worker_name: String,
|
worker_name: String,
|
||||||
@@ -214,6 +261,7 @@ impl Drop for InternalSpawnReservation {
|
|||||||
|
|
||||||
pub struct SpawnedWorkerRegistry {
|
pub struct SpawnedWorkerRegistry {
|
||||||
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
|
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
|
||||||
|
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
|
||||||
internal_names: std::sync::Mutex<HashSet<String>>,
|
internal_names: std::sync::Mutex<HashSet<String>>,
|
||||||
parent_scope: Option<SharedScope>,
|
parent_scope: Option<SharedScope>,
|
||||||
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
||||||
@@ -226,10 +274,21 @@ pub struct SpawnedWorkerRegistryLoad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnedWorkerRegistry {
|
impl SpawnedWorkerRegistry {
|
||||||
|
pub(crate) fn new_for_internal_services() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
|
parent_scope: None,
|
||||||
|
parent_protocol: Mutex::new(None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Empty registry used by tests and non-spawning projections.
|
/// Empty registry used by tests and non-spawning projections.
|
||||||
pub fn new(_runtime_dir: Arc<RuntimeDir>) -> Arc<Self> {
|
pub fn new(_runtime_dir: Arc<RuntimeDir>) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
parent_scope: None,
|
parent_scope: None,
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
@@ -239,6 +298,7 @@ impl SpawnedWorkerRegistry {
|
|||||||
pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> Arc<Self> {
|
pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
parent_scope: Some(parent_scope),
|
parent_scope: Some(parent_scope),
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
@@ -317,6 +377,7 @@ impl SpawnedWorkerRegistry {
|
|||||||
Ok(SpawnedWorkerRegistryLoad {
|
Ok(SpawnedWorkerRegistryLoad {
|
||||||
registry: Arc::new(Self {
|
registry: Arc::new(Self {
|
||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
parent_scope,
|
parent_scope,
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
@@ -356,6 +417,144 @@ impl SpawnedWorkerRegistry {
|
|||||||
for record in self.internal_records.lock().unwrap().clone() {
|
for record in self.internal_records.lock().unwrap().clone() {
|
||||||
self.start_protocol_forwarding(record);
|
self.start_protocol_forwarding(record);
|
||||||
}
|
}
|
||||||
|
for record in self.service_records.lock().unwrap().clone() {
|
||||||
|
self.start_service_protocol_forwarding(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a parent-visible service Internal Worker before its first turn.
|
||||||
|
pub(crate) fn attach_service(
|
||||||
|
&self,
|
||||||
|
record: InternalServiceWorkerRecord,
|
||||||
|
) -> io::Result<InternalWorkerRef> {
|
||||||
|
let parent_session_id = self
|
||||||
|
.parent_protocol
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, id)| id.clone());
|
||||||
|
let worker_ref = record.protocol_ref(parent_session_id);
|
||||||
|
let session_id = record.session.session_id_string();
|
||||||
|
let mut records = self
|
||||||
|
.service_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?;
|
||||||
|
if records
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.session.session_id_string() == session_id)
|
||||||
|
{
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"internal service Worker is already registered",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
records.push(record.clone());
|
||||||
|
drop(records);
|
||||||
|
self.start_service_protocol_forwarding(record);
|
||||||
|
Ok(worker_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop and remove one parent-owned service Worker. This is host-only and is
|
||||||
|
/// intentionally separate from the SubWorker control surface.
|
||||||
|
pub(crate) async fn stop_service(&self, session_id: &str) -> io::Result<bool> {
|
||||||
|
let record = self
|
||||||
|
.service_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.session.session_id_string() == session_id)
|
||||||
|
.cloned();
|
||||||
|
let Some(record) = record else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
record
|
||||||
|
.session
|
||||||
|
.stop()
|
||||||
|
.await
|
||||||
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
|
self.remove_service(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove one service Worker and emit the terminal projection fence.
|
||||||
|
pub(crate) fn remove_service(&self, session_id: &str) -> io::Result<bool> {
|
||||||
|
let removed = {
|
||||||
|
let mut records = self
|
||||||
|
.service_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?;
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.position(|record| record.session.session_id_string() == session_id)
|
||||||
|
.map(|index| records.remove(index))
|
||||||
|
};
|
||||||
|
if let Some(record) = removed {
|
||||||
|
self.publish_service_removal(&record);
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_service_protocol_forwarding(&self, record: InternalServiceWorkerRecord) {
|
||||||
|
if record.session.visibility() != InternalWorkerVisibility::ParentClient
|
||||||
|
|| record.forwarding_started.swap(true, Ordering::AcqRel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
|
||||||
|
else {
|
||||||
|
record.forwarding_started.store(false, Ordering::Release);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let worker = record.protocol_ref(Some(parent_session_id));
|
||||||
|
let protocol_revision = record.protocol_revision.clone();
|
||||||
|
let protocol_emit_lock = record.protocol_emit_lock.clone();
|
||||||
|
let protocol_terminal = record.protocol_terminal.clone();
|
||||||
|
let mut child_rx = record.session.subscribe_events();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match child_rx.recv().await {
|
||||||
|
Ok(event) => {
|
||||||
|
let shutdown = matches!(event, Event::Shutdown);
|
||||||
|
let _emit_guard = protocol_emit_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
if protocol_terminal.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
|
let _ = parent_tx.send(Event::InternalWorker {
|
||||||
|
worker: worker.clone(),
|
||||||
|
revision,
|
||||||
|
event: Box::new(event),
|
||||||
|
});
|
||||||
|
if shutdown {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
let _emit_guard = protocol_emit_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
if protocol_terminal.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
|
let _ = parent_tx.send(Event::InternalWorker {
|
||||||
|
worker: worker.clone(),
|
||||||
|
revision,
|
||||||
|
event: Box::new(Event::Error {
|
||||||
|
code: protocol::ErrorCode::Internal,
|
||||||
|
message: format!(
|
||||||
|
"internal Worker output lagged by {skipped} events; reconnect to resynchronize"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_protocol_forwarding(&self, record: InternalSpawnedWorkerRecord) {
|
fn start_protocol_forwarding(&self, record: InternalSpawnedWorkerRecord) {
|
||||||
@@ -427,28 +626,37 @@ impl SpawnedWorkerRegistry {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|(_, id)| id.clone());
|
.map(|(_, id)| id.clone());
|
||||||
self.internal_records
|
let mut snapshots = self
|
||||||
|
.internal_records
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient)
|
.filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient)
|
||||||
.map(|record| {
|
.map(|record| {
|
||||||
let snapshot = record.session.protocol_snapshot();
|
internal_worker_snapshot(
|
||||||
InternalWorkerSnapshot {
|
record.protocol_ref(parent_session_id.clone()),
|
||||||
worker: record.protocol_ref(parent_session_id.clone()),
|
record.protocol_revision(),
|
||||||
revision: record.protocol_revision(),
|
&record.session,
|
||||||
entries: snapshot
|
)
|
||||||
.entries
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|entry| serde_json::to_value(entry).ok())
|
|
||||||
.collect(),
|
|
||||||
status: snapshot.status,
|
|
||||||
error: snapshot.error,
|
|
||||||
in_flight: snapshot.in_flight,
|
|
||||||
internal_workers: snapshot.internal_workers,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect::<Vec<_>>();
|
||||||
|
snapshots.extend(
|
||||||
|
self.service_records
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|record| {
|
||||||
|
record.session.visibility() == InternalWorkerVisibility::ParentClient
|
||||||
|
})
|
||||||
|
.map(|record| {
|
||||||
|
internal_worker_snapshot(
|
||||||
|
record.protocol_ref(parent_session_id.clone()),
|
||||||
|
record.protocol_revision(),
|
||||||
|
&record.session,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_internal(&self, worker_name: &str) -> Option<InternalSpawnedWorkerRecord> {
|
pub(crate) fn get_internal(&self, worker_name: &str) -> Option<InternalSpawnedWorkerRecord> {
|
||||||
@@ -567,6 +775,47 @@ impl SpawnedWorkerRegistry {
|
|||||||
revision,
|
revision,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn publish_service_removal(&self, record: &InternalServiceWorkerRecord) {
|
||||||
|
if record.session.visibility() != InternalWorkerVisibility::ParentClient {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _emit_guard = record
|
||||||
|
.protocol_emit_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
record.protocol_terminal.store(true, Ordering::Release);
|
||||||
|
let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
|
||||||
|
let _ = parent_tx.send(Event::InternalWorkerRemoved {
|
||||||
|
worker: record.protocol_ref(Some(parent_session_id)),
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_worker_snapshot(
|
||||||
|
worker: InternalWorkerRef,
|
||||||
|
revision: u64,
|
||||||
|
session: &InternalWorkerSessionHandle,
|
||||||
|
) -> InternalWorkerSnapshot {
|
||||||
|
let snapshot = session.protocol_snapshot();
|
||||||
|
InternalWorkerSnapshot {
|
||||||
|
worker,
|
||||||
|
revision,
|
||||||
|
entries: snapshot
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|entry| serde_json::to_value(entry).ok())
|
||||||
|
.collect(),
|
||||||
|
status: snapshot.status,
|
||||||
|
error: snapshot.error,
|
||||||
|
in_flight: snapshot.in_flight,
|
||||||
|
internal_workers: snapshot.internal_workers,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for SpawnedWorkerRegistry {
|
impl Drop for SpawnedWorkerRegistry {
|
||||||
@@ -813,6 +1062,63 @@ mod tests {
|
|||||||
assert_eq!(snapshots[0].in_flight.blocks.len(), 1);
|
assert_eq!(snapshots[0].in_flight.blocks.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn service_worker_is_parent_visible_but_not_subworker_controllable() {
|
||||||
|
let registry = registry();
|
||||||
|
let (parent_tx, mut parent_rx) = broadcast::channel(16);
|
||||||
|
registry.attach_parent_protocol(parent_tx, "parent-session".into());
|
||||||
|
let (session, child_tx) =
|
||||||
|
test_internal_worker_session(InternalWorkerVisibility::ParentClient);
|
||||||
|
let session_id = session.session_id_string();
|
||||||
|
|
||||||
|
let worker_ref = registry
|
||||||
|
.attach_service(InternalServiceWorkerRecord::new(
|
||||||
|
"compaction",
|
||||||
|
"Compaction",
|
||||||
|
session,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
worker_ref.kind,
|
||||||
|
InternalWorkerKind::Service { ref kind } if kind == "compaction"
|
||||||
|
));
|
||||||
|
assert!(registry.list_internal().is_empty());
|
||||||
|
|
||||||
|
child_tx
|
||||||
|
.send(Event::TextDone {
|
||||||
|
text: "summary candidate".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let event = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
event,
|
||||||
|
Event::InternalWorker { worker, revision: 1, event }
|
||||||
|
if worker.session_id == session_id
|
||||||
|
&& matches!(worker.kind, InternalWorkerKind::Service { ref kind } if kind == "compaction")
|
||||||
|
&& matches!(*event, Event::TextDone { ref text } if text == "summary candidate")
|
||||||
|
));
|
||||||
|
let snapshots = registry.internal_worker_snapshots();
|
||||||
|
assert_eq!(snapshots.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
snapshots[0].worker.kind,
|
||||||
|
InternalWorkerKind::Service { ref kind } if kind == "compaction"
|
||||||
|
));
|
||||||
|
assert!(registry.remove_service(&session_id).unwrap());
|
||||||
|
let removed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
removed,
|
||||||
|
Event::InternalWorkerRemoved { worker, revision: 2 }
|
||||||
|
if worker.session_id == session_id
|
||||||
|
));
|
||||||
|
assert!(registry.internal_worker_snapshots().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn service_private_internal_output_is_never_disclosed() {
|
async fn service_private_internal_output_is_never_disclosed() {
|
||||||
let registry = registry();
|
let registry = registry();
|
||||||
|
|||||||
@@ -329,13 +329,13 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
|
|||||||
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
|
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if input
|
if !input
|
||||||
.scope
|
.scope
|
||||||
.iter()
|
.iter()
|
||||||
.any(|rule| matches!(rule.permission, PermissionInput::Write))
|
.any(|rule| matches!(rule.permission, PermissionInput::Write))
|
||||||
{
|
{
|
||||||
return Err(ToolError::InvalidArgument(
|
return Err(ToolError::InvalidArgument(
|
||||||
"Merge Request Reviewer SubWorkers must have read-only delegated scope".to_string(),
|
"Merge Request Reviewer SubWorkers must include writable delegated scope".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1008,28 +1008,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() {
|
fn reviewer_handoff_requires_explicit_builtin_profile_and_writable_scope() {
|
||||||
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||||
"scope":[{"target":"work","permission":"read"}],
|
"scope":[{"target":"work","permission":"write"}],
|
||||||
"review":{"ticket_id":"T1"}
|
"review":{"ticket_id":"T1"}
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(validate_reviewer_handoff(&valid).is_ok());
|
assert!(validate_reviewer_handoff(&valid).is_ok());
|
||||||
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
"name":"reviewer","task":"review","profile":"builtin:coder",
|
"name":"reviewer","task":"review","profile":"builtin:coder",
|
||||||
"scope":[{"target":"work","permission":"read"}],
|
|
||||||
"review":{"ticket_id":"T1"}
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
|
||||||
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
|
||||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
|
||||||
"scope":[{"target":"work","permission":"write"}],
|
"scope":[{"target":"work","permission":"write"}],
|
||||||
"review":{"ticket_id":"T1"}
|
"review":{"ticket_id":"T1"}
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(validate_reviewer_handoff(&writable).is_err());
|
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
||||||
|
let read_only: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||||
|
"scope":[{"target":"work","permission":"read"}],
|
||||||
|
"review":{"ticket_id":"T1"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(validate_reviewer_handoff(&read_only).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
|
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
|
||||||
@@ -1079,7 +1079,7 @@ extract_threshold = 4000
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reviewer_profile_spawns_and_notifies_parent_controller() {
|
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
|
||||||
let runtime = TempDir::new().unwrap();
|
let runtime = TempDir::new().unwrap();
|
||||||
let workspace_root = runtime.path().join("project");
|
let workspace_root = runtime.path().join("project");
|
||||||
let available_profiles = write_project_profile_registry(
|
let available_profiles = write_project_profile_registry(
|
||||||
@@ -1140,7 +1140,7 @@ extract_threshold = 4000
|
|||||||
"task": "review immutable commit",
|
"task": "review immutable commit",
|
||||||
"scope": [{
|
"scope": [{
|
||||||
"target": ".",
|
"target": ".",
|
||||||
"permission": "read",
|
"permission": "write",
|
||||||
"recursive": true
|
"recursive": true
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -1171,11 +1171,10 @@ extract_threshold = 4000
|
|||||||
let record = registry
|
let record = registry
|
||||||
.get_internal("reviewer-child")
|
.get_internal("reviewer-child")
|
||||||
.expect("Internal reviewer registry record");
|
.expect("Internal reviewer registry record");
|
||||||
assert!(record.installed_tools.iter().any(|name| name == "Read"));
|
for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
|
||||||
for denied in ["Write", "Edit", "Bash"] {
|
|
||||||
assert!(
|
assert!(
|
||||||
!record.installed_tools.iter().any(|name| name == denied),
|
record.installed_tools.iter().any(|name| name == required),
|
||||||
"read-only child unexpectedly received {denied}: {:?}",
|
"write-scoped child is missing {required}: {:?}",
|
||||||
record.installed_tools
|
record.installed_tools
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1250,7 +1249,7 @@ extract_threshold = 4000
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(first_capture.items.iter().any(|item| {
|
assert!(first_capture.entries.iter().map(|entry| &entry.item).any(|item| {
|
||||||
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
|
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1272,7 +1271,7 @@ extract_threshold = 4000
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(latest_capture.items.len() > first_capture.items.len());
|
assert!(latest_capture.entries.len() > first_capture.entries.len());
|
||||||
|
|
||||||
fail_requests.store(true, Ordering::SeqCst);
|
fail_requests.store(true, Ordering::SeqCst);
|
||||||
send.execute(
|
send.execute(
|
||||||
|
|||||||
+1020
-323
File diff suppressed because it is too large
Load Diff
@@ -163,7 +163,8 @@ async fn make_worker_with_manifest(
|
|||||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||||
std::mem::forget(pwd_tmp);
|
std::mem::forget(pwd_tmp);
|
||||||
|
|
||||||
let worker = Engine::new(client);
|
let worker =
|
||||||
|
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||||
let mut worker = Worker::new(
|
let mut worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
worker,
|
worker,
|
||||||
@@ -204,7 +205,14 @@ fn system_texts_in_sink_session_start(
|
|||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let (entries, _rx) = worker.sink().subscribe_with_snapshot();
|
let (entries, _rx) = worker.sink().subscribe_with_snapshot();
|
||||||
for entry in entries.into_iter().rev() {
|
for entry in entries.into_iter().rev() {
|
||||||
if let session_store::LogEntry::SegmentStart { history, .. } = entry {
|
let history = match entry {
|
||||||
|
session_store::LogEntry::AnnotatedSegmentStart { history, .. } => history
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| entry.item)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
session_store::LogEntry::SegmentStart { history, .. } => history,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
return history
|
return history
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|logged| {
|
.filter_map(|logged| {
|
||||||
@@ -226,7 +234,6 @@ fn system_texts_in_sink_session_start(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +344,12 @@ permission = "write"
|
|||||||
// New segment records forked_from pointing at the source.
|
// New segment records forked_from pointing at the source.
|
||||||
let new_entries = store.read_all(session_id, new_segment_id).unwrap();
|
let new_entries = store.read_all(session_id, new_segment_id).unwrap();
|
||||||
match &new_entries[0] {
|
match &new_entries[0] {
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
|
session_id: seg_session,
|
||||||
|
forked_from: Some(origin),
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| LogEntry::SegmentStart {
|
||||||
session_id: seg_session,
|
session_id: seg_session,
|
||||||
forked_from: Some(origin),
|
forked_from: Some(origin),
|
||||||
..
|
..
|
||||||
@@ -431,7 +443,7 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
|||||||
let kinds: Vec<&str> = events
|
let kinds: Vec<&str> = events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| match e {
|
.map(|e| match e {
|
||||||
Event::CompactStart => "start",
|
Event::CompactStart { .. } => "start",
|
||||||
Event::CompactDone { .. } => "done",
|
Event::CompactDone { .. } => "done",
|
||||||
Event::CompactFailed { .. } => "failed",
|
Event::CompactFailed { .. } => "failed",
|
||||||
_ => "other",
|
_ => "other",
|
||||||
@@ -445,10 +457,61 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
|||||||
!kinds.contains(&"failed"),
|
!kinds.contains(&"failed"),
|
||||||
"unexpected CompactFailed in {kinds:?}"
|
"unexpected CompactFailed in {kinds:?}"
|
||||||
);
|
);
|
||||||
|
let starts = events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|event| match event {
|
||||||
|
Event::CompactStart { lifecycle } => Some(lifecycle),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
starts.len(),
|
||||||
|
2,
|
||||||
|
"start and Internal Worker binding revisions"
|
||||||
|
);
|
||||||
|
assert_eq!(starts[0].compaction_id, starts[1].compaction_id);
|
||||||
|
assert_eq!(starts[0].revision, 1);
|
||||||
|
assert!(starts[0].internal_worker.is_none());
|
||||||
|
assert_eq!(starts[1].revision, 2);
|
||||||
|
assert!(matches!(
|
||||||
|
starts[1].internal_worker.as_ref().map(|worker| &worker.kind),
|
||||||
|
Some(protocol::InternalWorkerKind::Service { kind }) if kind == "compaction"
|
||||||
|
));
|
||||||
|
assert!(events.iter().any(|event| matches!(
|
||||||
|
event,
|
||||||
|
Event::InternalWorker { worker, .. }
|
||||||
|
if matches!(&worker.kind, protocol::InternalWorkerKind::Service { kind } if kind == "compaction")
|
||||||
|
)), "compactor activity must be projected through the parent stream");
|
||||||
|
let completed = events
|
||||||
|
.iter()
|
||||||
|
.find_map(|event| match event {
|
||||||
|
Event::CompactDone { lifecycle } => Some(lifecycle),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("completed lifecycle");
|
||||||
|
assert_eq!(completed.compaction_id, starts[0].compaction_id);
|
||||||
|
assert_eq!(completed.revision, 3);
|
||||||
|
assert_eq!(completed.summary.as_deref(), Some("summary"));
|
||||||
|
assert_eq!(completed.state, protocol::CompactionLifecycleState::Done);
|
||||||
|
let done_index = events
|
||||||
|
.iter()
|
||||||
|
.position(|event| matches!(event, Event::CompactDone { .. }))
|
||||||
|
.expect("done event");
|
||||||
|
let removed_index = events
|
||||||
|
.iter()
|
||||||
|
.position(|event| matches!(event, Event::InternalWorkerRemoved { .. }))
|
||||||
|
.expect("terminal compactor session must be released");
|
||||||
|
assert!(
|
||||||
|
done_index < removed_index,
|
||||||
|
"terminal lifecycle precedes release fence"
|
||||||
|
);
|
||||||
|
|
||||||
// CompactDone carries the new Segment ID; the Session ID is unchanged.
|
// CompactDone carries the new Segment ID; the Session ID is unchanged.
|
||||||
let new_id_in_event = events.iter().find_map(|e| match e {
|
let new_id_in_event = events.iter().find_map(|e| match e {
|
||||||
Event::CompactDone { new_segment_id } => Some(*new_segment_id),
|
Event::CompactDone { lifecycle } => lifecycle
|
||||||
|
.new_segment_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
assert!(new_id_in_event.is_some(), "CompactDone missing");
|
assert!(new_id_in_event.is_some(), "CompactDone missing");
|
||||||
@@ -488,7 +551,7 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
|||||||
let kinds: Vec<&str> = events
|
let kinds: Vec<&str> = events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| match e {
|
.map(|e| match e {
|
||||||
Event::CompactStart => "start",
|
Event::CompactStart { .. } => "start",
|
||||||
Event::CompactDone { .. } => "done",
|
Event::CompactDone { .. } => "done",
|
||||||
Event::CompactFailed { .. } => "failed",
|
Event::CompactFailed { .. } => "failed",
|
||||||
_ => "other",
|
_ => "other",
|
||||||
@@ -504,7 +567,10 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let new_id_in_event = events.iter().find_map(|e| match e {
|
let new_id_in_event = events.iter().find_map(|e| match e {
|
||||||
Event::CompactDone { new_segment_id } => Some(*new_segment_id),
|
Event::CompactDone { lifecycle } => lifecycle
|
||||||
|
.new_segment_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
assert_eq!(new_id_in_event, Some(worker.segment_id()));
|
assert_eq!(new_id_in_event, Some(worker.segment_id()));
|
||||||
@@ -533,6 +599,9 @@ model_id = "test-model"
|
|||||||
max_tokens = 100
|
max_tokens = 100
|
||||||
|
|
||||||
[memory]
|
[memory]
|
||||||
|
workspace_id = "test-workspace"
|
||||||
|
settings_revision = 1
|
||||||
|
language = "English"
|
||||||
extract_threshold = 1
|
extract_threshold = 1
|
||||||
|
|
||||||
[compaction]
|
[compaction]
|
||||||
@@ -659,7 +728,7 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
|||||||
let kinds: Vec<&str> = events
|
let kinds: Vec<&str> = events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| match e {
|
.map(|e| match e {
|
||||||
Event::CompactStart => "start",
|
Event::CompactStart { .. } => "start",
|
||||||
Event::CompactDone { .. } => "done",
|
Event::CompactDone { .. } => "done",
|
||||||
Event::CompactFailed { .. } => "failed",
|
Event::CompactFailed { .. } => "failed",
|
||||||
_ => "other",
|
_ => "other",
|
||||||
@@ -695,6 +764,9 @@ model_id = "test-model"
|
|||||||
max_tokens = 100
|
max_tokens = 100
|
||||||
|
|
||||||
[memory]
|
[memory]
|
||||||
|
workspace_id = "test-workspace"
|
||||||
|
settings_revision = 1
|
||||||
|
language = "English"
|
||||||
extract_threshold = 1
|
extract_threshold = 1
|
||||||
|
|
||||||
[[scope.allow]]
|
[[scope.allow]]
|
||||||
@@ -817,11 +889,14 @@ async fn controller_compact_method_emits_start_and_done() {
|
|||||||
.expect("timeout waiting for compact events")
|
.expect("timeout waiting for compact events")
|
||||||
.expect("event")
|
.expect("event")
|
||||||
{
|
{
|
||||||
Event::CompactStart => saw_start = true,
|
Event::CompactStart { .. } => saw_start = true,
|
||||||
Event::CompactDone { .. } => {
|
Event::CompactDone { .. } => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Event::CompactFailed { error } => panic!("manual compact failed: {error}"),
|
Event::CompactFailed { lifecycle } => panic!(
|
||||||
|
"manual compact failed: {}",
|
||||||
|
lifecycle.error.as_deref().unwrap_or("unknown error")
|
||||||
|
),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,16 +32,29 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
|
|||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
match entry {
|
match entry {
|
||||||
|
LogEntry::AnnotatedSegmentStart { history, .. } => {
|
||||||
|
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
|
||||||
|
}
|
||||||
LogEntry::SegmentStart { history, .. } => {
|
LogEntry::SegmentStart { history, .. } => {
|
||||||
items.extend(history.into_iter().map(Item::from));
|
items.extend(history.into_iter().map(Item::from));
|
||||||
}
|
}
|
||||||
|
LogEntry::AnnotatedUserInput { history, .. } => {
|
||||||
|
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
|
||||||
|
}
|
||||||
LogEntry::UserInput { segments, .. } => {
|
LogEntry::UserInput { segments, .. } => {
|
||||||
let text = protocol::Segment::flatten_to_text(&segments);
|
let text = protocol::Segment::flatten_to_text(&segments);
|
||||||
items.push(Item::user_message(text));
|
items.push(Item::user_message(text));
|
||||||
}
|
}
|
||||||
|
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||||
|
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
||||||
|
items.push(Item::from(entry.item));
|
||||||
|
}
|
||||||
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
|
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
|
||||||
items.push(Item::from(item));
|
items.push(Item::from(item));
|
||||||
}
|
}
|
||||||
|
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||||
|
items.push(entry.item.to_history_item());
|
||||||
|
}
|
||||||
LogEntry::SystemItem { item, .. } => {
|
LogEntry::SystemItem { item, .. } => {
|
||||||
items.push(item.to_history_item());
|
items.push(item.to_history_item());
|
||||||
}
|
}
|
||||||
@@ -51,6 +64,14 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn system_item(entry: &LogEntry) -> Option<&session_store::SystemItem> {
|
||||||
|
match entry {
|
||||||
|
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
|
||||||
|
LogEntry::SystemItem { item, .. } => Some(item),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mock LLM Client
|
// Mock LLM Client
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -192,7 +213,8 @@ async fn make_worker_with_pwd_and_manifest(
|
|||||||
let scope = manifest::Scope::writable(&pwd).unwrap();
|
let scope = manifest::Scope::writable(&pwd).unwrap();
|
||||||
std::mem::forget(pwd_tmp);
|
std::mem::forget(pwd_tmp);
|
||||||
|
|
||||||
let worker = Engine::new(client);
|
let worker =
|
||||||
|
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||||
let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
|
let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
|
||||||
let worker = Worker::new(
|
let worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
@@ -804,10 +826,12 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
|
|||||||
// Walk the entries, find a `LogEntry::UserInput` and
|
// Walk the entries, find a `LogEntry::UserInput` and
|
||||||
// confirm its segments flatten to our submitted text.
|
// confirm its segments flatten to our submitted text.
|
||||||
let mut found = false;
|
let mut found = false;
|
||||||
for value in entries {
|
for value in &entries {
|
||||||
let entry: session_store::LogEntry =
|
let entry: session_store::LogEntry =
|
||||||
serde_json::from_value(value).expect("LogEntry deserialise");
|
serde_json::from_value(value.clone()).expect("LogEntry deserialise");
|
||||||
if let session_store::LogEntry::UserInput { segments, .. } = entry {
|
if let session_store::LogEntry::UserInput { segments, .. }
|
||||||
|
| session_store::LogEntry::AnnotatedUserInput { segments, .. } = entry
|
||||||
|
{
|
||||||
let text = protocol::Segment::flatten_to_text(&segments);
|
let text = protocol::Segment::flatten_to_text(&segments);
|
||||||
if text == "hello in-flight" {
|
if text == "hello in-flight" {
|
||||||
found = true;
|
found = true;
|
||||||
@@ -815,7 +839,10 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(found, "snapshot must carry the in-flight UserInput entry");
|
assert!(
|
||||||
|
found,
|
||||||
|
"snapshot must carry the in-flight UserInput entry: {entries:?}"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Event::Alert(_) => continue,
|
Event::Alert(_) => continue,
|
||||||
@@ -1086,7 +1113,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
entry = entry_rx.recv() => match entry {
|
entry = entry_rx.recv() => match entry {
|
||||||
Ok(session_store::LogEntry::UserInput { segments, .. }) => {
|
Ok(session_store::LogEntry::UserInput { segments, .. } | session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
|
||||||
user_input_segments = Some(segments);
|
user_input_segments = Some(segments);
|
||||||
if saw_turn_end {
|
if saw_turn_end {
|
||||||
break;
|
break;
|
||||||
@@ -1317,11 +1344,8 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
|
|||||||
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
||||||
let saw_notify_in_mirror = entries.iter().any(|e| {
|
let saw_notify_in_mirror = entries.iter().any(|e| {
|
||||||
matches!(
|
matches!(
|
||||||
e,
|
system_item(e),
|
||||||
session_store::LogEntry::SystemItem {
|
Some(session_store::SystemItem::Notification { message, .. }) if message == "turn finished"
|
||||||
item: session_store::SystemItem::Notification { message, .. },
|
|
||||||
..
|
|
||||||
} if message == "turn finished"
|
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1463,14 +1487,11 @@ async fn worker_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_
|
|||||||
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
||||||
let saw_worker_event_in_mirror = entries.iter().any(|e| {
|
let saw_worker_event_in_mirror = entries.iter().any(|e| {
|
||||||
matches!(
|
matches!(
|
||||||
e,
|
system_item(e),
|
||||||
session_store::LogEntry::SystemItem {
|
Some(session_store::SystemItem::WorkerEvent {
|
||||||
item: session_store::SystemItem::WorkerEvent {
|
|
||||||
event: protocol::WorkerEvent::TurnEnded { worker_name },
|
event: protocol::WorkerEvent::TurnEnded { worker_name },
|
||||||
..
|
..
|
||||||
},
|
}) if worker_name == "child"
|
||||||
..
|
|
||||||
} if worker_name == "child"
|
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1552,14 +1573,11 @@ async fn worker_event_scope_sub_delegated_while_idle_stays_control_plane_only()
|
|||||||
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
||||||
let saw_scope_event_in_mirror = entries.iter().any(|entry| {
|
let saw_scope_event_in_mirror = entries.iter().any(|entry| {
|
||||||
matches!(
|
matches!(
|
||||||
entry,
|
system_item(entry),
|
||||||
session_store::LogEntry::SystemItem {
|
Some(session_store::SystemItem::WorkerEvent {
|
||||||
item: session_store::SystemItem::WorkerEvent {
|
|
||||||
event: protocol::WorkerEvent::ScopeSubDelegated { .. },
|
event: protocol::WorkerEvent::ScopeSubDelegated { .. },
|
||||||
..
|
..
|
||||||
},
|
})
|
||||||
..
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2373,7 +2391,8 @@ async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bo
|
|||||||
let entry: session_store::LogEntry =
|
let entry: session_store::LogEntry =
|
||||||
serde_json::from_value(value).expect("LogEntry deserialise");
|
serde_json::from_value(value).expect("LogEntry deserialise");
|
||||||
match entry {
|
match entry {
|
||||||
session_store::LogEntry::UserInput { segments, .. } => {
|
session_store::LogEntry::UserInput { segments, .. }
|
||||||
|
| session_store::LogEntry::AnnotatedUserInput { segments, .. } => {
|
||||||
protocol::Segment::flatten_to_text(&segments).contains(needle)
|
protocol::Segment::flatten_to_text(&segments).contains(needle)
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ impl Tool for BigContentTool {
|
|||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
summary: self.summary.into(),
|
summary: self.summary.into(),
|
||||||
content: Some(self.content.clone()),
|
content: Some(self.content.clone()),
|
||||||
|
attachments: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,8 @@ async fn make_worker(
|
|||||||
let pwd = pwd_tmp.path().to_path_buf();
|
let pwd = pwd_tmp.path().to_path_buf();
|
||||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||||
|
|
||||||
let mut worker = Engine::new(client);
|
let mut worker =
|
||||||
|
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||||
worker.register_tool(big_content_tool_definition(tool_name));
|
worker.register_tool(big_content_tool_definition(tool_name));
|
||||||
|
|
||||||
let worker = Worker::new(
|
let worker = Worker::new(
|
||||||
@@ -459,7 +461,8 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() {
|
|||||||
// protected token budget covers the only user message). That is enough to drive
|
// protected token budget covers the only user message). That is enough to drive
|
||||||
// the failure path: at least one metric attempts to write.
|
// the failure path: at least one metric attempts to write.
|
||||||
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
|
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
|
||||||
let worker = Engine::new(client);
|
let worker =
|
||||||
|
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||||
let mut worker = Worker::new(
|
let mut worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
worker,
|
worker,
|
||||||
@@ -535,7 +538,8 @@ permission = "write"
|
|||||||
let pwd_tmp = tempfile::tempdir().unwrap();
|
let pwd_tmp = tempfile::tempdir().unwrap();
|
||||||
let pwd = pwd_tmp.path().to_path_buf();
|
let pwd = pwd_tmp.path().to_path_buf();
|
||||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||||
let worker = Engine::new(client);
|
let worker =
|
||||||
|
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||||
let mut worker = Worker::new(
|
let mut worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
worker,
|
worker,
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ async fn make_worker_with_body(
|
|||||||
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
|
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
|
||||||
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
|
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
|
||||||
|
|
||||||
let worker = Engine::new(client);
|
let worker =
|
||||||
|
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||||
let mut worker = Worker::new(
|
let mut worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
worker,
|
worker,
|
||||||
|
|||||||
+195
-19
@@ -7,6 +7,88 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use workdir::workspace::WorkingDirectorySummary;
|
use workdir::workspace::WorkingDirectorySummary;
|
||||||
|
|
||||||
|
/// Provider-neutral classification of an authoritative Repository source.
|
||||||
|
///
|
||||||
|
/// Local paths remain distinct from network Git transports so callers cannot
|
||||||
|
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RepositorySourceKind {
|
||||||
|
LocalPath,
|
||||||
|
File,
|
||||||
|
Ssh,
|
||||||
|
Http,
|
||||||
|
Https,
|
||||||
|
/// A legacy value that could not be classified during migration. It remains
|
||||||
|
/// inspectable but every provider operation must fail closed.
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepositorySourceKind {
|
||||||
|
pub const fn is_remote(self) -> bool {
|
||||||
|
matches!(self, Self::Ssh | Self::Http | Self::Https)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::LocalPath => "local_path",
|
||||||
|
Self::File => "file",
|
||||||
|
Self::Ssh => "ssh",
|
||||||
|
Self::Http => "http",
|
||||||
|
Self::Https => "https",
|
||||||
|
Self::Invalid => "invalid",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
Some(match value {
|
||||||
|
"local_path" => Self::LocalPath,
|
||||||
|
"file" => Self::File,
|
||||||
|
"ssh" => Self::Ssh,
|
||||||
|
"http" => Self::Http,
|
||||||
|
"https" => Self::Https,
|
||||||
|
"invalid" => Self::Invalid,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable Repository source identity stored by Workspace authority.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RepositorySource {
|
||||||
|
pub kind: RepositorySourceKind,
|
||||||
|
/// Canonical source representation. This is an absolute local path for
|
||||||
|
/// `local_path`, and a normalized URI/remote specification otherwise.
|
||||||
|
pub uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RepositoryObservedStatus {
|
||||||
|
Unverified,
|
||||||
|
Ready,
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepositoryObservedStatus {
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Unverified => "unverified",
|
||||||
|
Self::Ready => "ready",
|
||||||
|
Self::Invalid => "invalid",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
Some(match value {
|
||||||
|
"unverified" => Self::Unverified,
|
||||||
|
"ready" => Self::Ready,
|
||||||
|
"invalid" => Self::Invalid,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
|
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
|
||||||
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
||||||
|
|
||||||
@@ -165,24 +247,6 @@ pub struct RuntimeSourceSummary {
|
|||||||
pub note: String,
|
pub note: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct RuntimeCapabilitySummary {
|
|
||||||
pub can_list_hosts: bool,
|
|
||||||
pub can_list_workers: bool,
|
|
||||||
pub can_get_worker: bool,
|
|
||||||
pub can_spawn_worker: bool,
|
|
||||||
pub can_stop_worker: bool,
|
|
||||||
pub has_workspace_fs: bool,
|
|
||||||
pub has_shell: bool,
|
|
||||||
pub has_git: bool,
|
|
||||||
pub supports_worktrees: bool,
|
|
||||||
pub supports_backend_internal_tools: bool,
|
|
||||||
pub workspace_scope: String,
|
|
||||||
pub max_workers: usize,
|
|
||||||
pub os: String,
|
|
||||||
pub arch: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct RuntimeSummary {
|
pub struct RuntimeSummary {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
@@ -192,7 +256,9 @@ pub struct RuntimeSummary {
|
|||||||
pub source: RuntimeSourceSummary,
|
pub source: RuntimeSourceSummary,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub host_ids: Vec<String>,
|
pub host_ids: Vec<String>,
|
||||||
pub capabilities: RuntimeCapabilitySummary,
|
pub worker_creation_available: bool,
|
||||||
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub diagnostics: Vec<Diagnostic>,
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
}
|
}
|
||||||
@@ -291,6 +357,116 @@ pub struct UpdateWorkspaceMemorySettingsRequest {
|
|||||||
pub language: String,
|
pub language: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public metadata for one Workspace-scoped Repository SSH credential.
|
||||||
|
///
|
||||||
|
/// Secret references and secret material are deliberately not part of this DTO.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshCredential {
|
||||||
|
pub credential_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub public_key_algorithm: String,
|
||||||
|
pub public_key_fingerprint: String,
|
||||||
|
pub current_revision: u64,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub rotated_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub referenced_repositories: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CreateRepositorySshCredentialRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub credential_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub private_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub passphrase: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RotateRepositorySshCredentialRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub expected_revision: u64,
|
||||||
|
pub private_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub passphrase: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DeleteRepositorySshCredentialRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub expected_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public metadata for an explicitly pinned SSH host key.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshHostTrust {
|
||||||
|
pub host_trust_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub key_algorithm: String,
|
||||||
|
pub host_key: String,
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub current_revision: u64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub referenced_repositories: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PutRepositorySshHostTrustRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub host_trust_id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub host_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expected_revision: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DeleteRepositorySshHostTrustRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub expected_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RepositoryAccessMode {
|
||||||
|
ReadOnly,
|
||||||
|
ReadWrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshAccessBinding {
|
||||||
|
pub repository_id: String,
|
||||||
|
pub credential_id: String,
|
||||||
|
pub host_trust_id: String,
|
||||||
|
pub access: RepositoryAccessMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secret-free active Repository access projection consumed by later Runtime work.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositoryAccessProjection {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub config_revision: u64,
|
||||||
|
pub projection_digest: String,
|
||||||
|
pub bindings: Vec<RepositorySshAccessBinding>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ manifest.workspace = true
|
|||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
project-record.workspace = true
|
project-record.workspace = true
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||||
|
ring.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde_yaml.workspace = true
|
serde_yaml.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
ssh-key.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
ticket.workspace = true
|
ticket.workspace = true
|
||||||
memory.workspace = true
|
memory.workspace = true
|
||||||
@@ -46,6 +48,7 @@ tracing.workspace = true
|
|||||||
ts-rs = { version = "12.0.1", optional = true }
|
ts-rs = { version = "12.0.1", optional = true }
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
|
zeroize.workspace = true
|
||||||
webauthn-rs = { workspace = true }
|
webauthn-rs = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -176,8 +176,28 @@ fn actor_for_user<S: ControlPlaneStore + ?Sized>(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String {
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax")
|
pub struct SessionCookiePolicy<'a> {
|
||||||
|
pub cookie_name: &'a str,
|
||||||
|
pub path: &'a str,
|
||||||
|
pub domain: Option<&'a str>,
|
||||||
|
pub secure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_set_cookie(
|
||||||
|
policy: SessionCookiePolicy<'_>,
|
||||||
|
token: &str,
|
||||||
|
max_age_seconds: i64,
|
||||||
|
) -> String {
|
||||||
|
let domain = policy
|
||||||
|
.domain
|
||||||
|
.map(|domain| format!("; Domain={domain}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let secure = if policy.secure { "; Secure" } else { "" };
|
||||||
|
format!(
|
||||||
|
"{}={token}; Max-Age={max_age_seconds}; Path={}; HttpOnly; SameSite=Lax{domain}{secure}",
|
||||||
|
policy.cookie_name, policy.path
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn auth_error(code: &str, message: &str) -> Error {
|
pub fn auth_error(code: &str, message: &str) -> Error {
|
||||||
|
|||||||
@@ -704,6 +704,15 @@ impl SqliteWorkspaceAuthority {
|
|||||||
&self,
|
&self,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
request: TicketShowRequest,
|
request: TicketShowRequest,
|
||||||
|
) -> Result<TicketDetail> {
|
||||||
|
self.read_ticket_detail_with_backend(reference, request, &self.ticket_backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_ticket_detail_with_backend(
|
||||||
|
&self,
|
||||||
|
reference: &str,
|
||||||
|
request: TicketShowRequest,
|
||||||
|
backend: &SqliteTicketBackend,
|
||||||
) -> Result<TicketDetail> {
|
) -> Result<TicketDetail> {
|
||||||
let id = self
|
let id = self
|
||||||
.store
|
.store
|
||||||
@@ -713,16 +722,19 @@ impl SqliteWorkspaceAuthority {
|
|||||||
reference,
|
reference,
|
||||||
)?
|
)?
|
||||||
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?;
|
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?;
|
||||||
let ticket = self.ticket_backend.show(TicketIdOrSlug::Id(id))?;
|
let ticket = backend.show(TicketIdOrSlug::Id(id))?;
|
||||||
self.ticket_detail_from_ticket(ticket, request)
|
self.ticket_detail_from_ticket(ticket, request, backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_detail_from_ticket(
|
fn ticket_detail_from_ticket(
|
||||||
&self,
|
&self,
|
||||||
ticket: ticket::Ticket,
|
ticket: ticket::Ticket,
|
||||||
request: TicketShowRequest,
|
request: TicketShowRequest,
|
||||||
|
dependency_backend: &SqliteTicketBackend,
|
||||||
) -> Result<TicketDetail> {
|
) -> Result<TicketDetail> {
|
||||||
let id = ticket.meta.id.as_str();
|
let id = ticket.meta.id.as_str();
|
||||||
|
let dependency_check =
|
||||||
|
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
|
||||||
let (body, body_truncated) =
|
let (body, body_truncated) =
|
||||||
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
||||||
let event_limit = request
|
let event_limit = request
|
||||||
@@ -822,6 +834,27 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
||||||
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
|
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
|
||||||
let has_blockers = !ticket.relations.blockers.is_empty();
|
let has_blockers = !ticket.relations.blockers.is_empty();
|
||||||
|
let mut queue_assignment_blockers = Vec::new();
|
||||||
|
for ticket_id in &dependency_check.queue_tickets {
|
||||||
|
let assignments = self
|
||||||
|
.store
|
||||||
|
.list_current_ticket_role_assignments(&self.workspace_id, ticket_id)?;
|
||||||
|
if !assignments
|
||||||
|
.iter()
|
||||||
|
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator)
|
||||||
|
{
|
||||||
|
queue_assignment_blockers.push(format!(
|
||||||
|
"Ticket {ticket_id} requires an active Orchestrator assignment"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if assignments
|
||||||
|
.iter()
|
||||||
|
.any(|assignment| assignment.role == TicketAssignmentRole::Coder)
|
||||||
|
{
|
||||||
|
queue_assignment_blockers
|
||||||
|
.push(format!("Ticket {ticket_id} has an active Coder assignment"));
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut assignment_diagnostics = Vec::new();
|
let mut assignment_diagnostics = Vec::new();
|
||||||
if let Some(legacy_assignee) = ticket
|
if let Some(legacy_assignee) = ticket
|
||||||
.meta
|
.meta
|
||||||
@@ -833,6 +866,19 @@ impl SqliteWorkspaceAuthority {
|
|||||||
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
|
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let mut action_blockers = Vec::new();
|
||||||
|
if !has_target {
|
||||||
|
action_blockers.push("Ticket target is required".to_string());
|
||||||
|
}
|
||||||
|
if !dependency_check.queue_guard.can_queue_for_orchestrator {
|
||||||
|
if let Some(reason) = dependency_check.queue_guard.blocked_reason.clone() {
|
||||||
|
action_blockers.push(reason);
|
||||||
|
} else if let Some(reason) = dependency_check.queue_guard.reason.clone() {
|
||||||
|
action_blockers.push(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let queue_assignments_valid = queue_assignment_blockers.is_empty();
|
||||||
|
action_blockers.extend(queue_assignment_blockers);
|
||||||
let action_eligibility = TicketActionEligibility {
|
let action_eligibility = TicketActionEligibility {
|
||||||
can_assign_orchestrator: matches!(
|
can_assign_orchestrator: matches!(
|
||||||
ticket.meta.workflow_state,
|
ticket.meta.workflow_state,
|
||||||
@@ -848,19 +894,15 @@ impl SqliteWorkspaceAuthority {
|
|||||||
&& has_orchestrator
|
&& has_orchestrator
|
||||||
&& !has_coder
|
&& !has_coder
|
||||||
&& has_target
|
&& has_target
|
||||||
&& !has_blockers,
|
&& dependency_check.queue_guard.can_queue_for_orchestrator
|
||||||
|
&& queue_assignments_valid,
|
||||||
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
||||||
&& !has_orchestrator
|
&& !has_orchestrator
|
||||||
&& !has_coder
|
&& !has_coder
|
||||||
&& has_target
|
&& has_target
|
||||||
&& !has_blockers,
|
&& !has_blockers,
|
||||||
blockers: [
|
queue_tickets: dependency_check.queue_tickets.clone(),
|
||||||
(!has_target).then_some("Ticket target is required".to_string()),
|
blockers: action_blockers,
|
||||||
has_blockers.then_some("unresolved blocking relations remain".to_string()),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
@@ -1084,6 +1126,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
|||||||
event_limit: Some(TICKET_EVENT_LIMIT),
|
event_limit: Some(TICKET_EVENT_LIMIT),
|
||||||
event_cursor: None,
|
event_cursor: None,
|
||||||
},
|
},
|
||||||
|
&self.ticket_backend,
|
||||||
)?;
|
)?;
|
||||||
if ticket_matches_query(
|
if ticket_matches_query(
|
||||||
&summary,
|
&summary,
|
||||||
@@ -2927,7 +2970,7 @@ mod tests {
|
|||||||
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
||||||
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
|
write_ticket(dir.path(), "00000000001J5", "Second ticket", "queued");
|
||||||
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
||||||
let db_path = dir.path().join("workspace.db");
|
let db_path = dir.path().join("workspace.db");
|
||||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||||
@@ -3038,10 +3081,22 @@ VALUES ('workspace-test', 'ticket', 4);
|
|||||||
.ticket_backend
|
.ticket_backend
|
||||||
.add_ticket_relation(
|
.add_ticket_relation(
|
||||||
TicketIdOrSlug::Id("00000000001J2".to_string()),
|
TicketIdOrSlug::Id("00000000001J2".to_string()),
|
||||||
|
ticket::NewTicketRelation {
|
||||||
|
kind: ticket::TicketRelationKind::DependsOn,
|
||||||
|
target: "00000000001J5".to_string(),
|
||||||
|
note: Some("queued dependency with a transitive blocker".to_string()),
|
||||||
|
author: Some("tester".to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
authority
|
||||||
|
.ticket_backend
|
||||||
|
.add_ticket_relation(
|
||||||
|
TicketIdOrSlug::Id("00000000001J5".to_string()),
|
||||||
ticket::NewTicketRelation {
|
ticket::NewTicketRelation {
|
||||||
kind: ticket::TicketRelationKind::DependsOn,
|
kind: ticket::TicketRelationKind::DependsOn,
|
||||||
target: "00000000001J6".to_string(),
|
target: "00000000001J6".to_string(),
|
||||||
note: Some("separate dependency relation".to_string()),
|
note: Some("transitive planning dependency".to_string()),
|
||||||
author: Some("tester".to_string()),
|
author: Some("tester".to_string()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -3056,6 +3111,14 @@ VALUES ('workspace-test', 'ticket', 4);
|
|||||||
assert_eq!(ticket_by_key.id, tickets.items[0].id);
|
assert_eq!(ticket_by_key.id, tickets.items[0].id);
|
||||||
|
|
||||||
let ticket = authority.ticket("00000000001J2").unwrap();
|
let ticket = authority.ticket("00000000001J2").unwrap();
|
||||||
|
assert!(!ticket.action_eligibility.can_queue);
|
||||||
|
assert!(
|
||||||
|
ticket
|
||||||
|
.action_eligibility
|
||||||
|
.blockers
|
||||||
|
.iter()
|
||||||
|
.any(|reason| reason.contains("00000000001J6"))
|
||||||
|
);
|
||||||
assert!(ticket.body.contains("Ticket body"));
|
assert!(ticket.body.contains("Ticket body"));
|
||||||
assert!(ticket.body_truncated);
|
assert!(ticket.body_truncated);
|
||||||
assert!(!ticket.body.contains("Deep Ticket marker"));
|
assert!(!ticket.body.contains("Deep Ticket marker"));
|
||||||
@@ -3139,8 +3202,8 @@ VALUES ('workspace-test', 'ticket', 4);
|
|||||||
assert!(note_only_kind.items.is_empty());
|
assert!(note_only_kind.items.is_empty());
|
||||||
let crossed_relation_filters = authority
|
let crossed_relation_filters = authority
|
||||||
.query_tickets(TicketQueryRequest {
|
.query_tickets(TicketQueryRequest {
|
||||||
related_ticket_id: Some("00000000001J5".to_string()),
|
related_ticket_id: Some("00000000001J6".to_string()),
|
||||||
relation_kind: Some("depends_on".to_string()),
|
relation_kind: Some("related".to_string()),
|
||||||
..TicketQueryRequest::default()
|
..TicketQueryRequest::default()
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -3,53 +3,52 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::{fs, io};
|
use std::{fs, io};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use crate::hosts::RemoteRuntimeConfig;
|
use crate::hosts::RemoteRuntimeConfig;
|
||||||
use crate::identity::WorkspaceIdentity;
|
use crate::identity::WorkspaceIdentity;
|
||||||
use crate::repositories::ConfiguredRepository;
|
|
||||||
use crate::server::{AuthConfig, ServerConfig};
|
use crate::server::{AuthConfig, ServerConfig};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
|
|
||||||
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
|
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
|
||||||
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
|
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
|
||||||
include_str!("../../../resources/workspace-backend.default.toml");
|
|
||||||
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
||||||
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
|
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
|
||||||
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787";
|
|
||||||
const DEFAULT_AUTH_RP_ID: &str = "localhost";
|
|
||||||
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
|
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
|
||||||
const DEFAULT_MAX_RECORDS: usize = 200;
|
const DEFAULT_MAX_RECORDS: usize = 200;
|
||||||
|
|
||||||
fn default_auth_rp_id() -> String {
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
DEFAULT_AUTH_RP_ID.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auth_origin() -> String {
|
|
||||||
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auth_public_base_url() -> String {
|
|
||||||
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auth_cookie_name() -> String {
|
|
||||||
DEFAULT_AUTH_COOKIE_NAME.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceBackendConfigFile {
|
pub struct ServerHostConfigFile {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub server: WorkspaceBackendServerConfig,
|
pub browser: ServerBrowserConfig,
|
||||||
#[serde(default)]
|
}
|
||||||
pub data: WorkspaceBackendDataConfig,
|
|
||||||
#[serde(default)]
|
impl Default for ServerHostConfigFile {
|
||||||
pub limits: WorkspaceBackendLimitsConfig,
|
fn default() -> Self {
|
||||||
#[serde(default)]
|
Self {
|
||||||
pub auth: WorkspaceBackendAuthConfig,
|
browser: ServerBrowserConfig::default(),
|
||||||
#[serde(default)]
|
}
|
||||||
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ServerBrowserConfig {
|
||||||
|
#[serde(default = "default_browser_public_url")]
|
||||||
|
pub public_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerBrowserConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
public_url: default_browser_public_url(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_browser_public_url() -> String {
|
||||||
|
DEFAULT_BROWSER_PUBLIC_URL.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -59,71 +58,6 @@ pub struct BackendRuntimesConfigFile {
|
|||||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
pub runtimes: WorkspaceBackendRuntimesConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendServerConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub listen: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub frontend_url: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub static_assets_dir: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendDataConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub root: Option<PathBuf>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub workspace_database_path: Option<PathBuf>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub embedded_runtime_store_root: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendLimitsConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub max_records: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendAuthConfig {
|
|
||||||
#[serde(default = "default_auth_rp_id")]
|
|
||||||
pub rp_id: String,
|
|
||||||
#[serde(default = "default_auth_origin")]
|
|
||||||
pub origin: String,
|
|
||||||
#[serde(default = "default_auth_public_base_url")]
|
|
||||||
pub public_base_url: String,
|
|
||||||
#[serde(default = "default_auth_cookie_name")]
|
|
||||||
pub cookie_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WorkspaceBackendAuthConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
rp_id: default_auth_rp_id(),
|
|
||||||
origin: default_auth_origin(),
|
|
||||||
public_base_url: default_auth_public_base_url(),
|
|
||||||
cookie_name: default_auth_cookie_name(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceRepositoryConfigFile {
|
|
||||||
pub id: String,
|
|
||||||
pub provider: String,
|
|
||||||
pub uri: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub default_selector: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceBackendRuntimesConfig {
|
pub struct WorkspaceBackendRuntimesConfig {
|
||||||
@@ -142,61 +76,6 @@ pub struct RemoteRuntimeConfigFile {
|
|||||||
pub token_ref: Option<String>,
|
pub token_ref: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ConfigDiff {
|
|
||||||
pub differs: bool,
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConfigDiff {
|
|
||||||
fn new(default: &str, local: &str) -> Self {
|
|
||||||
if default == local {
|
|
||||||
return Self {
|
|
||||||
differs: false,
|
|
||||||
text: "workspace backend local config matches the packaged default\n".to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut text = String::from("--- packaged default\n+++ workspace local\n");
|
|
||||||
let default_lines = default.lines().collect::<Vec<_>>();
|
|
||||||
let local_lines = local.lines().collect::<Vec<_>>();
|
|
||||||
let max = default_lines.len().max(local_lines.len());
|
|
||||||
for index in 0..max {
|
|
||||||
match (default_lines.get(index), local_lines.get(index)) {
|
|
||||||
(Some(left), Some(right)) if left == right => {
|
|
||||||
text.push(' ');
|
|
||||||
text.push_str(left);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(Some(left), Some(right)) => {
|
|
||||||
text.push('-');
|
|
||||||
text.push_str(left);
|
|
||||||
text.push('\n');
|
|
||||||
text.push('+');
|
|
||||||
text.push_str(right);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(Some(left), None) => {
|
|
||||||
text.push('-');
|
|
||||||
text.push_str(left);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(None, Some(right)) => {
|
|
||||||
text.push('+');
|
|
||||||
text.push_str(right);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(None, None) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
differs: true,
|
|
||||||
text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ResolvedWorkspaceBackendConfig {
|
pub struct ResolvedWorkspaceBackendConfig {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
@@ -204,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig {
|
|||||||
pub database_path: PathBuf,
|
pub database_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ServerHostConfigFile {
|
||||||
|
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
||||||
|
config_dir.as_ref().join(SERVER_HOST_CONFIG_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_path() -> Option<PathBuf> {
|
||||||
|
manifest::paths::config_dir().map(Self::path_for_config_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_default() -> Result<Self> {
|
||||||
|
let Some(path) = Self::default_path() else {
|
||||||
|
return Ok(Self::default());
|
||||||
|
};
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => Self::parse_str(&raw, &path),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
|
||||||
|
Err(error) => Err(Error::Io(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let raw = fs::read_to_string(path).map_err(|error| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"failed to read Server host config `{}`: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Self::parse_str(&raw, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
toml::from_str(raw).map_err(|error| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"failed to parse Server host config `{}`: {error}",
|
||||||
|
path.as_ref().display()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl BackendRuntimesConfigFile {
|
impl BackendRuntimesConfigFile {
|
||||||
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
||||||
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
|
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
|
||||||
@@ -272,151 +192,22 @@ impl BackendRuntimesConfigFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceBackendConfigFile {
|
impl ResolvedWorkspaceBackendConfig {
|
||||||
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
|
pub fn local_dev(
|
||||||
workspace_root
|
|
||||||
.as_ref()
|
|
||||||
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
match fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&path)
|
|
||||||
{
|
|
||||||
Ok(mut file) => {
|
|
||||||
use std::io::Write;
|
|
||||||
file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?;
|
|
||||||
file.sync_all()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn local_config_diff_for_workspace(workspace_root: impl AsRef<Path>) -> Result<ConfigDiff> {
|
|
||||||
let workspace_root = workspace_root.as_ref();
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
|
|
||||||
"workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first",
|
|
||||||
path.display(),
|
|
||||||
workspace_root.display()
|
|
||||||
))),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(raw) => Self::parse_str(&raw, &path),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_for_workspace(&self, workspace_root: impl AsRef<Path>) -> Result<()> {
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let raw = toml::to_string_pretty(self).map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to serialize workspace backend config: {error}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
fs::write(path, raw)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
toml::from_str(raw).map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to parse workspace backend config `{}`: {error}",
|
|
||||||
path.as_ref().display()
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve(
|
|
||||||
&self,
|
|
||||||
workspace_root: impl AsRef<Path>,
|
|
||||||
identity: WorkspaceIdentity,
|
|
||||||
) -> Result<ResolvedWorkspaceBackendConfig> {
|
|
||||||
self.resolve_with_runtime_config(
|
|
||||||
workspace_root,
|
|
||||||
identity,
|
|
||||||
&BackendRuntimesConfigFile::default(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_with_runtime_config(
|
|
||||||
&self,
|
|
||||||
workspace_root: impl AsRef<Path>,
|
workspace_root: impl AsRef<Path>,
|
||||||
identity: WorkspaceIdentity,
|
identity: WorkspaceIdentity,
|
||||||
|
host_config: &ServerHostConfigFile,
|
||||||
runtime_config: &BackendRuntimesConfigFile,
|
runtime_config: &BackendRuntimesConfigFile,
|
||||||
) -> Result<ResolvedWorkspaceBackendConfig> {
|
) -> Result<Self> {
|
||||||
let workspace_root = workspace_root.as_ref();
|
let workspace_root = workspace_root.as_ref();
|
||||||
let data_root = self
|
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
|
||||||
.data
|
let database_path = ServerConfig::default_server_database_path();
|
||||||
.root
|
let (browser_public_url, browser_rp_id) =
|
||||||
.as_ref()
|
resolve_browser_public_url(&host_config.browser.public_url)?;
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
ServerConfig::default_workspace_backend_data_root(&identity.workspace_id)
|
|
||||||
});
|
|
||||||
let database_path = self
|
|
||||||
.data
|
|
||||||
.workspace_database_path
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path))
|
|
||||||
.unwrap_or_else(ServerConfig::default_server_database_path);
|
|
||||||
let embedded_runtime_store_root = self
|
|
||||||
.data
|
|
||||||
.embedded_runtime_store_root
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path))
|
|
||||||
.unwrap_or_else(|| data_root.join("embedded-runtime"));
|
|
||||||
let listen = self
|
|
||||||
.server
|
|
||||||
.listen
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(DEFAULT_LISTEN)
|
|
||||||
.parse::<SocketAddr>()
|
|
||||||
.map_err(|_| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"invalid workspace backend server.listen `{}`",
|
|
||||||
self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN)
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
|
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
|
||||||
server.database_path = database_path.clone();
|
server.database_path = database_path.clone();
|
||||||
server.frontend_url = self
|
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
|
||||||
.server
|
server.max_records = DEFAULT_MAX_RECORDS;
|
||||||
.frontend_url
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string());
|
|
||||||
server.static_assets_dir = self
|
|
||||||
.server
|
|
||||||
.static_assets_dir
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path));
|
|
||||||
server.embedded_runtime_store_root = embedded_runtime_store_root;
|
|
||||||
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
|
|
||||||
server.repositories = self
|
|
||||||
.repositories
|
|
||||||
.iter()
|
|
||||||
.map(|repository| resolve_repository(workspace_root, repository))
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
server.remote_runtime_sources = runtime_config
|
server.remote_runtime_sources = runtime_config
|
||||||
.runtimes
|
.runtimes
|
||||||
.remote
|
.remote
|
||||||
@@ -424,13 +215,16 @@ impl WorkspaceBackendConfigFile {
|
|||||||
.map(resolve_remote_runtime)
|
.map(resolve_remote_runtime)
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
server.auth = AuthConfig::Passkey {
|
server.auth = AuthConfig::Passkey {
|
||||||
rp_id: self.auth.rp_id.trim().to_string(),
|
rp_id: browser_rp_id,
|
||||||
origin: self.auth.origin.trim().to_string(),
|
origin: browser_public_url.clone(),
|
||||||
public_base_url: self.auth.public_base_url.trim().to_string(),
|
public_base_url: browser_public_url,
|
||||||
cookie_name: self.auth.cookie_name.trim().to_string(),
|
cookie_name: DEFAULT_AUTH_COOKIE_NAME.to_string(),
|
||||||
};
|
};
|
||||||
|
let listen = DEFAULT_LISTEN.parse::<SocketAddr>().map_err(|error| {
|
||||||
|
Error::Config(format!("invalid built-in Server listen address: {error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(ResolvedWorkspaceBackendConfig {
|
Ok(Self {
|
||||||
server,
|
server,
|
||||||
listen,
|
listen,
|
||||||
database_path,
|
database_path,
|
||||||
@@ -439,18 +233,6 @@ impl WorkspaceBackendConfigFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedWorkspaceBackendConfig {
|
impl ResolvedWorkspaceBackendConfig {
|
||||||
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
|
|
||||||
let path = path.into();
|
|
||||||
self.database_path = path.clone();
|
|
||||||
self.server.database_path = path;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_static_assets_dir(mut self, path: Option<PathBuf>) -> Self {
|
|
||||||
self.server.static_assets_dir = path;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
|
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
|
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
|
||||||
self
|
self
|
||||||
@@ -462,29 +244,6 @@ impl ResolvedWorkspaceBackendConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_repository(
|
|
||||||
workspace_root: &Path,
|
|
||||||
config: &WorkspaceRepositoryConfigFile,
|
|
||||||
) -> Result<ConfiguredRepository> {
|
|
||||||
let id = normalize_required_string("repository id", &config.id)?;
|
|
||||||
validate_repository_id(&id)?;
|
|
||||||
let provider =
|
|
||||||
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
|
|
||||||
let uri = normalize_required_string("repository uri", &config.uri)?;
|
|
||||||
let path = resolve_repository_uri(workspace_root, &id, &uri)?;
|
|
||||||
let display_name = normalize_optional_string(config.display_name.as_deref());
|
|
||||||
let default_selector = normalize_optional_string(config.default_selector.as_deref());
|
|
||||||
|
|
||||||
Ok(ConfiguredRepository {
|
|
||||||
id,
|
|
||||||
provider,
|
|
||||||
uri,
|
|
||||||
path,
|
|
||||||
display_name,
|
|
||||||
default_selector,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -493,45 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
|||||||
Ok(trimmed.to_string())
|
Ok(trimmed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
|
||||||
value.and_then(|value| {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_repository_id(id: &str) -> Result<()> {
|
|
||||||
if id
|
|
||||||
.chars()
|
|
||||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
|
||||||
{
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(Error::Config(format!(
|
|
||||||
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_repository_uri(workspace_root: &Path, id: &str, uri: &str) -> Result<PathBuf> {
|
|
||||||
if uri.contains("://") {
|
|
||||||
return Err(Error::Config(format!(
|
|
||||||
"repository `{id}` uses a remote URI, but remote repository materialization is not implemented"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(resolve_workspace_path(workspace_root, Path::new(uri)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_remote_runtime(
|
pub(crate) fn resolve_remote_runtime(
|
||||||
config: &RemoteRuntimeConfigFile,
|
config: &RemoteRuntimeConfigFile,
|
||||||
) -> Result<RemoteRuntimeConfig> {
|
) -> Result<RemoteRuntimeConfig> {
|
||||||
if let Some(token_ref) = config.token_ref.as_deref() {
|
if let Some(token_ref) = config.token_ref.as_deref() {
|
||||||
return Err(Error::Config(format!(
|
return Err(Error::Config(format!(
|
||||||
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet",
|
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet",
|
||||||
config.id
|
config.id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -546,12 +272,34 @@ pub(crate) fn resolve_remote_runtime(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf {
|
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
|
||||||
if path.is_absolute() {
|
let value = normalize_required_string("browser.public_url", value)?;
|
||||||
path.to_path_buf()
|
let url = Url::parse(&value).map_err(|error| {
|
||||||
} else {
|
Error::Config(format!(
|
||||||
workspace_root.join(path)
|
"browser.public_url must be an absolute http(s) URL: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"browser.public_url must use the http or https scheme".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"browser.public_url must not contain user information".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"browser.public_url must contain only an origin without a path, query, or fragment"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rp_id = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| Error::Config("browser.public_url must contain a host".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
Ok((url.origin().ascii_serialization(), rp_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -566,14 +314,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn resolved_with_runtimes(
|
||||||
fn missing_config_path_uses_defaults() {
|
runtimes: &BackendRuntimesConfigFile,
|
||||||
|
) -> ResolvedWorkspaceBackendConfig {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
|
ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
dir.path(),
|
||||||
|
identity(),
|
||||||
|
&ServerHostConfigFile::default(),
|
||||||
|
runtimes,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_settings_resolve_without_a_repository_file() {
|
||||||
|
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default());
|
||||||
|
|
||||||
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
|
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
|
||||||
assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL);
|
let AuthConfig::Passkey {
|
||||||
|
rp_id,
|
||||||
|
origin,
|
||||||
|
public_base_url,
|
||||||
|
..
|
||||||
|
} = &resolved.server.auth;
|
||||||
|
assert_eq!(rp_id, "localhost");
|
||||||
|
assert_eq!(origin, DEFAULT_BROWSER_PUBLIC_URL);
|
||||||
|
assert_eq!(public_base_url, DEFAULT_BROWSER_PUBLIC_URL);
|
||||||
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
|
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
|
||||||
assert!(resolved.database_path.ends_with("server.db"));
|
assert!(resolved.database_path.ends_with("server.db"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -586,12 +353,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_base_url_is_explicit_and_normalized() {
|
fn backend_base_url_is_explicit_and_normalized() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let listen = "127.0.0.1:48787".parse().unwrap();
|
let listen = "127.0.0.1:48787".parse().unwrap();
|
||||||
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path())
|
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
|
||||||
.unwrap()
|
|
||||||
.resolve(dir.path(), identity())
|
|
||||||
.unwrap()
|
|
||||||
.with_listen(listen)
|
.with_listen(listen)
|
||||||
.with_backend_base_url("http://127.0.0.1:48787/");
|
.with_backend_base_url("http://127.0.0.1:48787/");
|
||||||
|
|
||||||
@@ -603,172 +366,82 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_fields() {
|
fn browser_public_url_from_host_config_drives_all_browser_auth_settings() {
|
||||||
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
|
let host_config = ServerHostConfigFile::parse_str(
|
||||||
.unwrap_err();
|
"[browser]\npublic_url = \"https://Yoi.Example:443/\"\n",
|
||||||
assert!(
|
"server.toml",
|
||||||
error.to_string().contains("unknown field"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolves_relative_paths_against_workspace_root() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[server]
|
|
||||||
static_assets_dir = "web/build"
|
|
||||||
|
|
||||||
[data]
|
|
||||||
root = ".yoi/backend-data"
|
|
||||||
workspace_database_path = ".yoi/custom.db"
|
|
||||||
embedded_runtime_store_root = ".yoi/runtime-store"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
|
tempfile::tempdir().unwrap().path(),
|
||||||
assert_eq!(
|
identity(),
|
||||||
resolved.server.static_assets_dir,
|
&host_config,
|
||||||
Some(dir.path().join("web/build"))
|
&BackendRuntimesConfigFile::default(),
|
||||||
);
|
|
||||||
assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db"));
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.embedded_runtime_store_root,
|
|
||||||
dir.path().join(".yoi/runtime-store")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn absolute_paths_are_preserved() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[data]
|
|
||||||
workspace_database_path = "/tmp/yoi-workspace.db"
|
|
||||||
embedded_runtime_store_root = "/tmp/yoi-runtime"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
let AuthConfig::Passkey {
|
||||||
resolved.database_path,
|
rp_id,
|
||||||
PathBuf::from("/tmp/yoi-workspace.db")
|
origin,
|
||||||
|
public_base_url,
|
||||||
|
..
|
||||||
|
} = &resolved.server.auth;
|
||||||
|
assert_eq!(rp_id, "yoi.example");
|
||||||
|
assert_eq!(origin, "https://yoi.example");
|
||||||
|
assert_eq!(public_base_url, "https://yoi.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_public_url_rejects_non_origin_urls() {
|
||||||
|
for value in [
|
||||||
|
"https://example.test/path",
|
||||||
|
"https://example.test?query=true",
|
||||||
|
"file:///tmp/web",
|
||||||
|
] {
|
||||||
|
let host_config = ServerHostConfigFile {
|
||||||
|
browser: ServerBrowserConfig {
|
||||||
|
public_url: value.to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let result = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
|
tempfile::tempdir().unwrap().path(),
|
||||||
|
identity(),
|
||||||
|
&host_config,
|
||||||
|
&BackendRuntimesConfigFile::default(),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
let error = match result {
|
||||||
resolved.server.embedded_runtime_store_root,
|
Ok(_) => panic!("expected {value} to be rejected"),
|
||||||
PathBuf::from("/tmp/yoi-runtime")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn data_root_derives_runtime_store_path_only() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[data]
|
|
||||||
root = ".local-data"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
|
|
||||||
assert!(resolved.database_path.ends_with("server.db"));
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.embedded_runtime_store_root,
|
|
||||||
dir.path().join(".local-data/embedded-runtime")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn copies_local_config_without_overwriting() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
|
||||||
let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path());
|
|
||||||
let raw = fs::read_to_string(&path).unwrap();
|
|
||||||
assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE);
|
|
||||||
WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap();
|
|
||||||
|
|
||||||
fs::write(&path, "# custom local config\n").unwrap();
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(&path).unwrap(),
|
|
||||||
"# custom local config\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_config_diff_reports_match_and_difference() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
|
||||||
let matched =
|
|
||||||
WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
|
|
||||||
assert!(!matched.differs);
|
|
||||||
|
|
||||||
fs::write(
|
|
||||||
WorkspaceBackendConfigFile::path_for_workspace(dir.path()),
|
|
||||||
"[server]\nlisten = \"127.0.0.1:9999\"\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
|
|
||||||
assert!(diff.differs);
|
|
||||||
assert!(diff.text.contains("+++ workspace local"));
|
|
||||||
assert!(diff.text.contains("127.0.0.1:9999"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolves_repository_uri_relative_to_workspace_root() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[repositories]]
|
|
||||||
id = "main"
|
|
||||||
provider = "git"
|
|
||||||
uri = "."
|
|
||||||
display_name = "Main"
|
|
||||||
default_selector = "HEAD"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
let repository = resolved.server.repositories.first().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(repository.id, "main");
|
|
||||||
assert_eq!(repository.provider, "git");
|
|
||||||
assert_eq!(repository.path, dir.path());
|
|
||||||
assert_eq!(repository.display_name.as_deref(), Some("Main"));
|
|
||||||
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_repository_uri_fails_closed() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[repositories]]
|
|
||||||
id = "main"
|
|
||||||
provider = "git"
|
|
||||||
uri = "https://example.com/org/repo.git"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let error = match config.resolve(dir.path(), identity()) {
|
|
||||||
Ok(_) => panic!("remote repository URI should fail closed"),
|
|
||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
};
|
};
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("browser.public_url"),
|
||||||
|
"unexpected error for {value}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_host_config_loads_only_from_the_explicit_host_path() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = ServerHostConfigFile::path_for_config_dir(dir.path());
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
"[browser]\npublic_url = \"https://deploy.example.test\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loaded = ServerHostConfigFile::load_from_path(&path).unwrap();
|
||||||
|
assert_eq!(loaded.browser.public_url, "https://deploy.example.test");
|
||||||
|
assert_eq!(path, dir.path().join("server.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_missing_server_host_config_fails_closed() {
|
||||||
|
let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
error
|
error
|
||||||
.to_string()
|
.to_string()
|
||||||
.contains("remote repository materialization is not implemented"),
|
.contains("failed to read Server host config")
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,28 +467,8 @@ uri = "https://example.com/org/repo.git"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_backend_config_rejects_runtime_entries() {
|
|
||||||
let error = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[runtimes.remote]]
|
|
||||||
id = "arc"
|
|
||||||
endpoint = "http://legacy.example.test"
|
|
||||||
display_name = "legacy arc"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("unknown field `runtimes`"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_runtimes_config_is_the_only_runtime_source() {
|
fn backend_runtimes_config_is_the_only_runtime_source() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
|
|
||||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
||||||
r#"
|
r#"
|
||||||
[[runtimes.remote]]
|
[[runtimes.remote]]
|
||||||
@@ -826,9 +479,7 @@ display_name = "xdg arc"
|
|||||||
"runtimes.toml",
|
"runtimes.toml",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resolved = workspace_config
|
let resolved = resolved_with_runtimes(&runtime_config);
|
||||||
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
|
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
|
||||||
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
|
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -857,8 +508,6 @@ token = "secret"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn token_ref_fails_closed_until_secret_resolution_exists() {
|
fn token_ref_fails_closed_until_secret_resolution_exists() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
|
|
||||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
||||||
r#"
|
r#"
|
||||||
[[runtimes.remote]]
|
[[runtimes.remote]]
|
||||||
@@ -869,9 +518,10 @@ token_ref = "local:remote-token"
|
|||||||
"runtimes.toml",
|
"runtimes.toml",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let error = match workspace_config.resolve_with_runtime_config(
|
let error = match ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
dir.path(),
|
tempfile::tempdir().unwrap().path(),
|
||||||
identity(),
|
identity(),
|
||||||
|
&ServerHostConfigFile::default(),
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
) {
|
) {
|
||||||
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
|
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
|||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
||||||
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
|
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
|
||||||
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest,
|
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
|
||||||
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
|
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
|
WorkingDirectorySummary, WorkspaceApiRef,
|
||||||
};
|
};
|
||||||
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -39,11 +40,11 @@ use worker_runtime::execution::WorkerExecutionRunState;
|
|||||||
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||||
use worker_runtime::http_server::{
|
use worker_runtime::http_server::{
|
||||||
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
||||||
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest,
|
RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse,
|
||||||
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
|
RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse,
|
||||||
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
|
RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse,
|
||||||
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
|
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
|
||||||
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
||||||
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
||||||
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
|
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
|
||||||
};
|
};
|
||||||
@@ -177,26 +178,6 @@ impl RuntimeSourceSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct RuntimeCapabilitySummary {
|
|
||||||
pub can_list_hosts: bool,
|
|
||||||
pub can_list_workers: bool,
|
|
||||||
pub can_get_worker: bool,
|
|
||||||
pub can_spawn_worker: bool,
|
|
||||||
pub can_stop_worker: bool,
|
|
||||||
pub has_workspace_fs: bool,
|
|
||||||
pub has_shell: bool,
|
|
||||||
pub has_git: bool,
|
|
||||||
pub supports_worktrees: bool,
|
|
||||||
pub supports_backend_internal_tools: bool,
|
|
||||||
pub workspace_scope: String,
|
|
||||||
pub max_workers: usize,
|
|
||||||
pub os: String,
|
|
||||||
pub arch: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type HostCapabilitySummary = RuntimeCapabilitySummary;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct RuntimeSummary {
|
pub struct RuntimeSummary {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
@@ -205,7 +186,9 @@ pub struct RuntimeSummary {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub source: RuntimeSourceSummary,
|
pub source: RuntimeSourceSummary,
|
||||||
pub host_ids: Vec<String>,
|
pub host_ids: Vec<String>,
|
||||||
pub capabilities: RuntimeCapabilitySummary,
|
pub worker_creation_available: bool,
|
||||||
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +201,8 @@ pub struct HostSummary {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub observed_at: String,
|
pub observed_at: String,
|
||||||
pub last_seen_at: Option<String>,
|
pub last_seen_at: Option<String>,
|
||||||
pub capabilities: HostCapabilitySummary,
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,27 +294,6 @@ impl From<RuntimeSourceSummary> for workspace_api::RuntimeSourceSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<RuntimeCapabilitySummary> for workspace_api::RuntimeCapabilitySummary {
|
|
||||||
fn from(capabilities: RuntimeCapabilitySummary) -> Self {
|
|
||||||
Self {
|
|
||||||
can_list_hosts: capabilities.can_list_hosts,
|
|
||||||
can_list_workers: capabilities.can_list_workers,
|
|
||||||
can_get_worker: capabilities.can_get_worker,
|
|
||||||
can_spawn_worker: capabilities.can_spawn_worker,
|
|
||||||
can_stop_worker: capabilities.can_stop_worker,
|
|
||||||
has_workspace_fs: capabilities.has_workspace_fs,
|
|
||||||
has_shell: capabilities.has_shell,
|
|
||||||
has_git: capabilities.has_git,
|
|
||||||
supports_worktrees: capabilities.supports_worktrees,
|
|
||||||
supports_backend_internal_tools: capabilities.supports_backend_internal_tools,
|
|
||||||
workspace_scope: capabilities.workspace_scope,
|
|
||||||
max_workers: capabilities.max_workers,
|
|
||||||
os: capabilities.os,
|
|
||||||
arch: capabilities.arch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
|
impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
|
||||||
fn from(runtime: RuntimeSummary) -> Self {
|
fn from(runtime: RuntimeSummary) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -340,7 +303,9 @@ impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
|
|||||||
status: runtime.status,
|
status: runtime.status,
|
||||||
source: runtime.source.into(),
|
source: runtime.source.into(),
|
||||||
host_ids: runtime.host_ids,
|
host_ids: runtime.host_ids,
|
||||||
capabilities: runtime.capabilities.into(),
|
worker_creation_available: runtime.worker_creation_available,
|
||||||
|
os: runtime.os,
|
||||||
|
arch: runtime.arch,
|
||||||
diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(),
|
diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -854,6 +819,16 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
_request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> std::result::Result<(), Error> {
|
||||||
|
Err(Error::InvalidInput(
|
||||||
|
"Runtime does not support working directory Repository access authorization"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
RuntimeList::new(Vec::new(), Vec::new())
|
RuntimeList::new(Vec::new(), Vec::new())
|
||||||
}
|
}
|
||||||
@@ -1427,6 +1402,23 @@ impl RuntimeRegistry {
|
|||||||
Ok(runtime.create_working_directory(request))
|
Ok(runtime.create_working_directory(request))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> Result<(), RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||||
|
validate_backend_identifier("working_directory_id", &request.working_directory_id)?;
|
||||||
|
let runtime = self.runtime(runtime_id)?;
|
||||||
|
runtime
|
||||||
|
.authorize_working_directory_repository_access(request)
|
||||||
|
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code: "working_directory_repository_access_failed".to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_working_directories(
|
pub fn list_working_directories(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -1890,7 +1882,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
status: "unavailable".to_string(),
|
status: "unavailable".to_string(),
|
||||||
source: RuntimeSourceSummary::embedded_worker_runtime(),
|
source: RuntimeSourceSummary::embedded_worker_runtime(),
|
||||||
host_ids: Vec::new(),
|
host_ids: Vec::new(),
|
||||||
capabilities: embedded_runtime_capabilities(limit, false, false),
|
worker_creation_available: false,
|
||||||
|
os: std::env::consts::OS.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1910,7 +1904,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
} else {
|
} else {
|
||||||
vec![self.host_id.clone()]
|
vec![self.host_id.clone()]
|
||||||
},
|
},
|
||||||
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled),
|
worker_creation_available: true,
|
||||||
|
os: std::env::consts::OS.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1928,7 +1924,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
status: "available".to_string(),
|
status: "available".to_string(),
|
||||||
observed_at: Utc::now().to_rfc3339(),
|
observed_at: Utc::now().to_rfc3339(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled),
|
os: std::env::consts::OS.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
diagnostics: vec![diagnostic(
|
diagnostics: vec![diagnostic(
|
||||||
"embedded_runtime_host_boundary",
|
"embedded_runtime_host_boundary",
|
||||||
DiagnosticSeverity::Info,
|
DiagnosticSeverity::Info,
|
||||||
@@ -2576,7 +2573,9 @@ pub struct RemoteRuntimeConfig {
|
|||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub bearer_token: Option<String>,
|
pub bearer_token: Option<String>,
|
||||||
pub auth: Option<RemoteRuntimeAuthConfig>,
|
pub auth: Option<RemoteRuntimeAuthConfig>,
|
||||||
pub cached_capabilities: RuntimeCapabilitySummary,
|
pub cached_worker_creation_available: bool,
|
||||||
|
pub cached_os: String,
|
||||||
|
pub cached_arch: String,
|
||||||
pub cached_status: String,
|
pub cached_status: String,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
}
|
}
|
||||||
@@ -2598,7 +2597,12 @@ impl std::fmt::Debug for RemoteRuntimeConfig {
|
|||||||
&self.bearer_token.as_ref().map(|_| "<redacted>"),
|
&self.bearer_token.as_ref().map(|_| "<redacted>"),
|
||||||
)
|
)
|
||||||
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
|
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
|
||||||
.field("cached_capabilities", &self.cached_capabilities)
|
.field(
|
||||||
|
"cached_worker_creation_available",
|
||||||
|
&self.cached_worker_creation_available,
|
||||||
|
)
|
||||||
|
.field("cached_os", &self.cached_os)
|
||||||
|
.field("cached_arch", &self.cached_arch)
|
||||||
.field("cached_status", &self.cached_status)
|
.field("cached_status", &self.cached_status)
|
||||||
.field("timeout", &self.timeout)
|
.field("timeout", &self.timeout)
|
||||||
.finish()
|
.finish()
|
||||||
@@ -2619,9 +2623,9 @@ impl RemoteRuntimeConfig {
|
|||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
bearer_token,
|
bearer_token,
|
||||||
auth: None,
|
auth: None,
|
||||||
cached_capabilities: remote_runtime_capabilities(
|
cached_worker_creation_available: false,
|
||||||
200, false, false, "unknown", "unknown",
|
cached_os: "unknown".to_string(),
|
||||||
),
|
cached_arch: "unknown".to_string(),
|
||||||
cached_status: "configured".to_string(),
|
cached_status: "configured".to_string(),
|
||||||
timeout: Duration::from_secs(10),
|
timeout: Duration::from_secs(10),
|
||||||
}
|
}
|
||||||
@@ -2632,11 +2636,6 @@ impl RemoteRuntimeConfig {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self {
|
|
||||||
self.cached_capabilities = capabilities;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
|
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
|
||||||
self.auth = Some(auth);
|
self.auth = Some(auth);
|
||||||
self
|
self
|
||||||
@@ -2708,7 +2707,9 @@ pub struct RemoteWorkerRuntime {
|
|||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
bearer_token: Option<String>,
|
bearer_token: Option<String>,
|
||||||
auth: Option<RemoteRuntimeAuthConfig>,
|
auth: Option<RemoteRuntimeAuthConfig>,
|
||||||
cached_capabilities: RuntimeCapabilitySummary,
|
cached_worker_creation_available: bool,
|
||||||
|
cached_os: String,
|
||||||
|
cached_arch: String,
|
||||||
cached_status: String,
|
cached_status: String,
|
||||||
host_id: String,
|
host_id: String,
|
||||||
resource_broker: BackendResourceBroker,
|
resource_broker: BackendResourceBroker,
|
||||||
@@ -2768,7 +2769,9 @@ impl RemoteWorkerRuntime {
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
bearer_token: config.bearer_token,
|
bearer_token: config.bearer_token,
|
||||||
auth: config.auth,
|
auth: config.auth,
|
||||||
cached_capabilities: config.cached_capabilities,
|
cached_worker_creation_available: config.cached_worker_creation_available,
|
||||||
|
cached_os: config.cached_os,
|
||||||
|
cached_arch: config.cached_arch,
|
||||||
cached_status: config.cached_status,
|
cached_status: config.cached_status,
|
||||||
resource_broker: BackendResourceBroker::default(),
|
resource_broker: BackendResourceBroker::default(),
|
||||||
http,
|
http,
|
||||||
@@ -3045,13 +3048,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
} else {
|
} else {
|
||||||
vec![self.host_id.clone()]
|
vec![self.host_id.clone()]
|
||||||
},
|
},
|
||||||
capabilities: remote_runtime_capabilities(
|
worker_creation_available: response.runtime.worker_creation_available,
|
||||||
limit,
|
os: response.runtime.os,
|
||||||
true,
|
arch: response.runtime.arch,
|
||||||
response.runtime.worker_creation_available,
|
|
||||||
response.runtime.os,
|
|
||||||
response.runtime.arch,
|
|
||||||
),
|
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
},
|
},
|
||||||
Err(diagnostic) => RuntimeSummary {
|
Err(diagnostic) => RuntimeSummary {
|
||||||
@@ -3065,7 +3064,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
} else {
|
} else {
|
||||||
vec![self.host_id.clone()]
|
vec![self.host_id.clone()]
|
||||||
},
|
},
|
||||||
capabilities: self.cached_capabilities.clone(),
|
worker_creation_available: self.cached_worker_creation_available,
|
||||||
|
os: self.cached_os.clone(),
|
||||||
|
arch: self.cached_arch.clone(),
|
||||||
diagnostics: vec![diagnostic],
|
diagnostics: vec![diagnostic],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -3084,7 +3085,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
status: "configured".to_string(),
|
status: "configured".to_string(),
|
||||||
observed_at: Utc::now().to_rfc3339(),
|
observed_at: Utc::now().to_rfc3339(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
capabilities: remote_runtime_capabilities(limit, true, false, "unknown", "unknown"),
|
os: self.cached_os.clone(),
|
||||||
|
arch: self.cached_arch.clone(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}],
|
}],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -3205,6 +3207,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize_working_directory_repository_access(
|
||||||
|
&self,
|
||||||
|
request: WorkingDirectoryRepositoryAccessRequest,
|
||||||
|
) -> std::result::Result<(), Error> {
|
||||||
|
self.post_json::<_, RuntimeHttpRepositoryAccessResponse>(
|
||||||
|
"/v1/working-directories/repository-access",
|
||||||
|
&request,
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
|
||||||
|
}
|
||||||
|
|
||||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||||
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
|
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
|
||||||
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
|
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
|
||||||
@@ -3553,29 +3567,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn embedded_runtime_capabilities(
|
|
||||||
limit: usize,
|
|
||||||
available: bool,
|
|
||||||
execution_enabled: bool,
|
|
||||||
) -> RuntimeCapabilitySummary {
|
|
||||||
RuntimeCapabilitySummary {
|
|
||||||
can_list_hosts: true,
|
|
||||||
can_list_workers: available,
|
|
||||||
can_get_worker: available,
|
|
||||||
can_spawn_worker: available,
|
|
||||||
can_stop_worker: available && execution_enabled,
|
|
||||||
has_workspace_fs: false,
|
|
||||||
has_shell: false,
|
|
||||||
has_git: false,
|
|
||||||
supports_worktrees: false,
|
|
||||||
supports_backend_internal_tools: true,
|
|
||||||
workspace_scope: "backend_internal".to_string(),
|
|
||||||
max_workers: limit,
|
|
||||||
os: std::env::consts::OS.to_string(),
|
|
||||||
arch: std::env::consts::ARCH.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str {
|
fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str {
|
||||||
match status {
|
match status {
|
||||||
RuntimeStatus::Running => "running",
|
RuntimeStatus::Running => "running",
|
||||||
@@ -4122,31 +4113,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
|
|||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remote_runtime_capabilities(
|
|
||||||
limit: usize,
|
|
||||||
available: bool,
|
|
||||||
worker_creation_available: bool,
|
|
||||||
os: impl Into<String>,
|
|
||||||
arch: impl Into<String>,
|
|
||||||
) -> RuntimeCapabilitySummary {
|
|
||||||
RuntimeCapabilitySummary {
|
|
||||||
can_list_hosts: true,
|
|
||||||
can_list_workers: available,
|
|
||||||
can_get_worker: available,
|
|
||||||
can_spawn_worker: available && worker_creation_available,
|
|
||||||
can_stop_worker: available,
|
|
||||||
has_workspace_fs: false,
|
|
||||||
has_shell: false,
|
|
||||||
has_git: false,
|
|
||||||
supports_worktrees: false,
|
|
||||||
supports_backend_internal_tools: false,
|
|
||||||
workspace_scope: "remote_runtime_backend_private".to_string(),
|
|
||||||
max_workers: limit,
|
|
||||||
os: os.into(),
|
|
||||||
arch: arch.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic {
|
fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic {
|
||||||
if err.is_timeout() {
|
if err.is_timeout() {
|
||||||
diagnostic(
|
diagnostic(
|
||||||
@@ -4460,7 +4426,7 @@ mod tests {
|
|||||||
let handle = bundle.profile_source_archive_handle.as_ref().unwrap();
|
let handle = bundle.profile_source_archive_handle.as_ref().unwrap();
|
||||||
assert!(bundle.profile_source_archive.is_none());
|
assert!(bundle.profile_source_archive.is_none());
|
||||||
let response = broker
|
let response = broker
|
||||||
.fetch_profile_source_archive(worker_runtime::resource::BackendResourceFetchRequest {
|
.fetch_resource(worker_runtime::resource::BackendResourceFetchRequest {
|
||||||
handle: handle.clone(),
|
handle: handle.clone(),
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
worker_id: None,
|
worker_id: None,
|
||||||
@@ -4800,22 +4766,9 @@ mod tests {
|
|||||||
status: "available".to_string(),
|
status: "available".to_string(),
|
||||||
source: RuntimeSourceSummary::embedded_worker_runtime_reserved(),
|
source: RuntimeSourceSummary::embedded_worker_runtime_reserved(),
|
||||||
host_ids: vec![self.host_id.clone()],
|
host_ids: vec![self.host_id.clone()],
|
||||||
capabilities: RuntimeCapabilitySummary {
|
worker_creation_available: false,
|
||||||
can_list_hosts: true,
|
|
||||||
can_list_workers: true,
|
|
||||||
can_get_worker: true,
|
|
||||||
can_spawn_worker: false,
|
|
||||||
can_stop_worker: false,
|
|
||||||
has_workspace_fs: false,
|
|
||||||
has_shell: false,
|
|
||||||
has_git: false,
|
|
||||||
supports_worktrees: false,
|
|
||||||
supports_backend_internal_tools: false,
|
|
||||||
workspace_scope: "none".to_string(),
|
|
||||||
max_workers: self.workers.len(),
|
|
||||||
os: "test".to_string(),
|
os: "test".to_string(),
|
||||||
arch: "test".to_string(),
|
arch: "test".to_string(),
|
||||||
},
|
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4830,7 +4783,8 @@ mod tests {
|
|||||||
status: "available".to_string(),
|
status: "available".to_string(),
|
||||||
observed_at: "unknown".to_string(),
|
observed_at: "unknown".to_string(),
|
||||||
last_seen_at: None,
|
last_seen_at: None,
|
||||||
capabilities: self.runtime_summary(1).capabilities,
|
os: "test".to_string(),
|
||||||
|
arch: "test".to_string(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}],
|
}],
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -5234,7 +5188,7 @@ mod tests {
|
|||||||
RuntimeSourceKind::EmbeddedWorkerRuntime
|
RuntimeSourceKind::EmbeddedWorkerRuntime
|
||||||
);
|
);
|
||||||
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
|
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
|
||||||
assert!(embedded_summary.capabilities.can_spawn_worker);
|
assert!(embedded_summary.worker_creation_available);
|
||||||
|
|
||||||
let spawned = registry
|
let spawned = registry
|
||||||
.spawn_worker(
|
.spawn_worker(
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ impl WorkspaceIdentity {
|
|||||||
Ok(raw) => Self::parse_str(&raw, &path),
|
Ok(raw) => Self::parse_str(&raw, &path),
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||||
Err(Error::WorkspaceIdentity(format!(
|
Err(Error::WorkspaceIdentity(format!(
|
||||||
"workspace is not initialized at {}; run `yoi-server init --workspace {}` first",
|
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
|
||||||
workspace_root.as_ref().display(),
|
|
||||||
workspace_root.as_ref().display()
|
workspace_root.as_ref().display()
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -219,7 +218,7 @@ mod tests {
|
|||||||
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
|
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
error.to_string().contains("workspace is not initialized"),
|
error.to_string().contains("workspace identity is missing"),
|
||||||
"unexpected error: {error}"
|
"unexpected error: {error}"
|
||||||
);
|
);
|
||||||
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
|
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ pub mod records;
|
|||||||
#[cfg(feature = "typescript")]
|
#[cfg(feature = "typescript")]
|
||||||
pub use records::ticket_api_typescript;
|
pub use records::ticket_api_typescript;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
|
pub mod repository_access;
|
||||||
|
pub mod repository_source;
|
||||||
pub mod resource_broker;
|
pub mod resource_broker;
|
||||||
pub mod retention;
|
pub mod retention;
|
||||||
|
pub mod runtime_settings;
|
||||||
pub mod runtime_subscription;
|
pub mod runtime_subscription;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
pub mod workdir_create_operations;
|
||||||
pub mod worker_source;
|
pub mod worker_source;
|
||||||
pub mod workspace_catalog;
|
pub mod workspace_catalog;
|
||||||
mod workspace_subscription;
|
mod workspace_subscription;
|
||||||
@@ -35,11 +39,7 @@ pub use authority::{
|
|||||||
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
||||||
WorkspaceAuthority,
|
WorkspaceAuthority,
|
||||||
};
|
};
|
||||||
pub use config::{
|
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
||||||
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
|
|
||||||
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
|
||||||
WorkspaceBackendConfigFile,
|
|
||||||
};
|
|
||||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||||
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
||||||
pub use repositories::{
|
pub use repositories::{
|
||||||
@@ -113,6 +113,8 @@ pub enum Error {
|
|||||||
TicketAssignmentConflict(String),
|
TicketAssignmentConflict(String),
|
||||||
#[error("Workdir attachment conflict: {0}")]
|
#[error("Workdir attachment conflict: {0}")]
|
||||||
WorkdirAttachmentConflict(String),
|
WorkdirAttachmentConflict(String),
|
||||||
|
#[error("Workspace permission denied: {0}")]
|
||||||
|
WorkspacePermissionDenied(String),
|
||||||
#[error("Workspace config update conflict: {0}")]
|
#[error("Workspace config update conflict: {0}")]
|
||||||
WorkspaceConfigConflict(String),
|
WorkspaceConfigConflict(String),
|
||||||
#[error("Registry inconsistency: {0}")]
|
#[error("Registry inconsistency: {0}")]
|
||||||
|
|||||||
@@ -11,17 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
|||||||
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
||||||
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
|
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
|
||||||
use yoi_workspace_server::{
|
use yoi_workspace_server::{
|
||||||
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig,
|
BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
|
||||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService,
|
ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
||||||
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
Serve(ServeOptions),
|
Serve(ServeOptions),
|
||||||
Init(InitOptions),
|
|
||||||
ConfigDefault,
|
|
||||||
ConfigDiff(WorkspacePathOptions),
|
|
||||||
Identity(Vec<String>),
|
Identity(Vec<String>),
|
||||||
TrustRuntime(Vec<String>),
|
TrustRuntime(Vec<String>),
|
||||||
MigrateDryRun { database: Option<PathBuf> },
|
MigrateDryRun { database: Option<PathBuf> },
|
||||||
@@ -32,16 +28,7 @@ enum Command {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ServeOptions {
|
struct ServeOptions {
|
||||||
listen: Option<SocketAddr>,
|
listen: Option<SocketAddr>,
|
||||||
}
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct InitOptions {
|
|
||||||
workspace: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct WorkspacePathOptions {
|
|
||||||
workspace: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -82,9 +69,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
match parse_command(&args)? {
|
match parse_command(&args)? {
|
||||||
Command::Serve(options) => run_serve(options).await,
|
Command::Serve(options) => run_serve(options).await,
|
||||||
Command::Init(options) => run_init(options).await,
|
|
||||||
Command::ConfigDefault => run_config_default(),
|
|
||||||
Command::ConfigDiff(options) => run_config_diff(options),
|
|
||||||
Command::Identity(args) => run_identity_command(args),
|
Command::Identity(args) => run_identity_command(args),
|
||||||
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
||||||
Command::MigrateDryRun { database } => {
|
Command::MigrateDryRun { database } => {
|
||||||
@@ -110,14 +94,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match command.as_str() {
|
match command.as_str() {
|
||||||
"init" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
print_init_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
}
|
|
||||||
Ok(Command::Init(parse_init_options(rest)?))
|
|
||||||
}
|
|
||||||
"config" => parse_config_command(rest),
|
|
||||||
"identity" => Ok(Command::Identity(rest.to_vec())),
|
"identity" => Ok(Command::Identity(rest.to_vec())),
|
||||||
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
||||||
"migrate" => parse_migrate_command(rest),
|
"migrate" => parse_migrate_command(rest),
|
||||||
@@ -134,61 +110,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
Ok(Command::Help)
|
Ok(Command::Help)
|
||||||
}
|
}
|
||||||
other => Err(CliError(format!(
|
other => Err(CliError(format!(
|
||||||
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
|
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
run_init_with_database_path(options, ServerConfig::default_server_database_path()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_init_with_database_path(
|
|
||||||
options: InitOptions,
|
|
||||||
database_path: PathBuf,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
|
|
||||||
|
|
||||||
if let Some(parent) = database_path.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
|
||||||
let service = WorkspaceCatalogService::new(store);
|
|
||||||
service.create_with_workspace_id(
|
|
||||||
WorkspaceCreateRequest {
|
|
||||||
operation_key: format!("cli-init:{}", identity.workspace_id),
|
|
||||||
display_name: identity.display_name.clone(),
|
|
||||||
repository: InitialRepositoryIntent {
|
|
||||||
uri: options.workspace.display().to_string(),
|
|
||||||
display_name: Some("Main repository".to_string()),
|
|
||||||
default_ref: Some("HEAD".to_string()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
Some(identity.workspace_id.clone()),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
|
|
||||||
options.workspace.display(),
|
|
||||||
identity.workspace_id,
|
|
||||||
database_path.display()
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_config_default() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?;
|
|
||||||
print!("{}", diff.text);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct ServerIdentityFile {
|
struct ServerIdentityFile {
|
||||||
identity: RuntimeIdentityMaterial,
|
identity: RuntimeIdentityMaterial,
|
||||||
@@ -626,10 +552,15 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
.to_path_buf(),
|
.to_path_buf(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let host_config = match options.config.as_ref() {
|
||||||
|
Some(path) => ServerHostConfigFile::load_from_path(path)?,
|
||||||
|
None => ServerHostConfigFile::load_default()?,
|
||||||
|
};
|
||||||
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
||||||
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
|
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
&workspace_root,
|
&workspace_root,
|
||||||
identity,
|
identity,
|
||||||
|
&host_config,
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
)?;
|
)?;
|
||||||
resolved.database_path = database_path.clone();
|
resolved.database_path = database_path.clone();
|
||||||
@@ -638,7 +569,6 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
if let Some(listen) = options.listen {
|
if let Some(listen) = options.listen {
|
||||||
resolved = resolved.with_listen(listen);
|
resolved = resolved.with_listen(listen);
|
||||||
}
|
}
|
||||||
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
|
|
||||||
|
|
||||||
let listener = TcpListener::bind(resolved.listen).await?;
|
let listener = TcpListener::bind(resolved.listen).await?;
|
||||||
let local_addr = listener.local_addr()?;
|
let local_addr = listener.local_addr()?;
|
||||||
@@ -711,49 +641,15 @@ fn infer_workspace_root_from_repositories(
|
|||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
let repository_path = PathBuf::from(&repository.uri);
|
if repository.source.kind == workspace_api::RepositorySourceKind::Invalid {
|
||||||
if !repository_path.is_absolute() {
|
|
||||||
return Err(CliError(format!(
|
return Err(CliError(format!(
|
||||||
"repository `{}` has relative URI `{}`; repository records used by serve must be absolute paths",
|
"repository `{}` has an invalid migrated source and cannot be used by serve",
|
||||||
repository.repository_id, repository.uri
|
repository.repository_id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(repository_path)
|
Ok(ServerConfig::default_workspace_backend_data_root(
|
||||||
}
|
&workspace.workspace_id,
|
||||||
|
))
|
||||||
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
|
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
|
||||||
print_config_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
};
|
|
||||||
match subcommand.as_str() {
|
|
||||||
"default" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
print_config_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
}
|
|
||||||
if !rest.is_empty() {
|
|
||||||
return Err(CliError(
|
|
||||||
"config default does not accept options".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Command::ConfigDefault)
|
|
||||||
}
|
|
||||||
"diff" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
print_config_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
}
|
|
||||||
Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?))
|
|
||||||
}
|
|
||||||
"--help" | "-h" => {
|
|
||||||
print_config_help();
|
|
||||||
Ok(Command::Help)
|
|
||||||
}
|
|
||||||
other => Err(CliError(format!(
|
|
||||||
"unknown config subcommand `{other}`; expected `default` or `diff`"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
|
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
@@ -839,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
|
|||||||
Ok(SkillWorkspaceOptions { workspace_id })
|
Ok(SkillWorkspaceOptions { workspace_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
|
||||||
let mut workspace = std::env::current_dir()
|
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
|
||||||
let mut iter = args.iter();
|
|
||||||
while let Some(arg) = iter.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"--workspace" => {
|
|
||||||
let value = iter
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
|
|
||||||
workspace = PathBuf::from(value);
|
|
||||||
}
|
|
||||||
value if value.starts_with("--workspace=") => {
|
|
||||||
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
|
|
||||||
}
|
|
||||||
other => return Err(CliError(format!("unknown workspace option `{other}`"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let workspace = workspace
|
|
||||||
.canonicalize()
|
|
||||||
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
|
|
||||||
Ok(WorkspacePathOptions { workspace })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
|
|
||||||
let mut workspace = std::env::current_dir()
|
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
|
||||||
let mut iter = args.iter();
|
|
||||||
while let Some(arg) = iter.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"--workspace" => {
|
|
||||||
let value = iter
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
|
|
||||||
workspace = PathBuf::from(value);
|
|
||||||
}
|
|
||||||
value if value.starts_with("--workspace=") => {
|
|
||||||
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
|
|
||||||
}
|
|
||||||
other => return Err(CliError(format!("unknown init option `{other}`"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspace = workspace
|
|
||||||
.canonicalize()
|
|
||||||
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
|
|
||||||
Ok(InitOptions { workspace })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
||||||
let mut listen = None;
|
let mut listen = None;
|
||||||
|
let mut config = None;
|
||||||
|
|
||||||
let mut index = 0;
|
let mut index = 0;
|
||||||
while index < args.len() {
|
while index < args.len() {
|
||||||
@@ -905,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
|||||||
_ if arg.starts_with("--listen=") => {
|
_ if arg.starts_with("--listen=") => {
|
||||||
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
|
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
|
||||||
}
|
}
|
||||||
|
"--config" => {
|
||||||
|
index += 1;
|
||||||
|
let value = args
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| CliError("--config requires a path".to_string()))?;
|
||||||
|
config = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--config=") => {
|
||||||
|
config = Some(PathBuf::from(value_after_equals(arg, "--config")?));
|
||||||
|
}
|
||||||
_ if arg.starts_with('-') => {
|
_ if arg.starts_with('-') => {
|
||||||
return Err(CliError(format!("unknown serve option `{arg}`")));
|
return Err(CliError(format!("unknown serve option `{arg}`")));
|
||||||
}
|
}
|
||||||
@@ -917,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ServeOptions { listen })
|
Ok(ServeOptions { listen, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
|
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
|
||||||
@@ -939,23 +797,11 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
|||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
|
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
|
||||||
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_init_help() {
|
|
||||||
println!(
|
|
||||||
"yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_config_help() {
|
|
||||||
println!(
|
|
||||||
"yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_skills_help() {
|
fn print_skills_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
||||||
@@ -965,24 +811,23 @@ fn print_skills_help() {
|
|||||||
fn print_serve_help() {
|
fn print_serve_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
|
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
|
||||||
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
|
yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use yoi_workspace_server::{
|
|
||||||
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
|
||||||
WORKSPACE_IDENTITY_RELATIVE_PATH,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_init_defaults_workspace_to_cwd_or_flag() {
|
fn removed_repository_local_commands_are_rejected() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
for command in ["init", "config"] {
|
||||||
let args = vec!["--workspace".to_string(), temp.path().display().to_string()];
|
let error = parse_command(&[command.to_string()]).unwrap_err();
|
||||||
let options = parse_init_options(&args).unwrap();
|
assert!(
|
||||||
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
|
error.to_string().contains("unknown command"),
|
||||||
|
"unexpected error for {command}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1022,10 +867,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_serve_accepts_listen_only() {
|
fn parse_serve_accepts_listen_and_host_config() {
|
||||||
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
let args = vec![
|
||||||
|
"--listen".to_string(),
|
||||||
|
"127.0.0.1:0".to_string(),
|
||||||
|
"--config=/etc/yoi/server.toml".to_string(),
|
||||||
|
];
|
||||||
let options = parse_serve_options(&args).unwrap();
|
let options = parse_serve_options(&args).unwrap();
|
||||||
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
|
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
options.config.unwrap(),
|
||||||
|
PathBuf::from("/etc/yoi/server.toml")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1080,49 +933,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
|
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn init_creates_identity_local_config_and_server_records() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let database_path = temp.path().join("data").join("server").join("server.db");
|
|
||||||
std::fs::create_dir(temp.path().join(".git")).unwrap();
|
|
||||||
run_init_with_database_path(
|
|
||||||
InitOptions {
|
|
||||||
workspace: temp.path().canonicalize().unwrap(),
|
|
||||||
},
|
|
||||||
database_path.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
|
|
||||||
let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH);
|
|
||||||
assert!(local_config_path.exists());
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read_to_string(local_config_path).unwrap(),
|
|
||||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!temp
|
|
||||||
.path()
|
|
||||||
.join(".yoi/workspace-backend.default.toml")
|
|
||||||
.exists()
|
|
||||||
);
|
|
||||||
assert!(!temp.path().join(".yoi/workspace.db").exists());
|
|
||||||
assert!(!temp.path().join(".yoi/embedded-runtime").exists());
|
|
||||||
assert!(database_path.exists());
|
|
||||||
|
|
||||||
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
|
|
||||||
let workspaces = store.list_workspaces().unwrap();
|
|
||||||
assert_eq!(workspaces.len(), 1);
|
|
||||||
let repositories = store
|
|
||||||
.list_repositories(&workspaces[0].workspace_id)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(repositories.len(), 1);
|
|
||||||
assert_eq!(repositories[0].repository_id, "main");
|
|
||||||
assert_eq!(
|
|
||||||
repositories[0].uri,
|
|
||||||
temp.path().canonicalize().unwrap().display().to_string()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ pub struct TicketActionEligibility {
|
|||||||
pub can_unassign_orchestrator: bool,
|
pub can_unassign_orchestrator: bool,
|
||||||
pub can_queue: bool,
|
pub can_queue: bool,
|
||||||
pub can_start_manual_coder: bool,
|
pub can_start_manual_coder: bool,
|
||||||
|
pub queue_tickets: Vec<String>,
|
||||||
pub blockers: Vec<String>,
|
pub blockers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user