Compare commits
50
Commits
master
..
aaa9c7987c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa9c7987c | ||
|
|
048007a042 | ||
|
|
cb35b40b9d | ||
|
|
ccf7de1a55 | ||
|
|
ccf3c80d29 | ||
|
|
17c629136a | ||
|
|
52a5c4141f | ||
|
|
c9ba27c333 | ||
|
|
46f6e2c58b | ||
|
|
d1f47e5a22 | ||
|
|
65de94bad3 | ||
|
|
a9fb092834 | ||
|
|
3a21a68792 | ||
|
|
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
+521
-1
@@ -26,6 +26,16 @@ dependencies = [
|
||||
"pom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
@@ -37,6 +47,20 @@ dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agen"
|
||||
version = "0.2.1"
|
||||
@@ -326,6 +350,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -338,6 +368,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "base64urlsafedata"
|
||||
version = "0.5.5"
|
||||
@@ -349,6 +385,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt-pbkdf"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
|
||||
dependencies = [
|
||||
"blowfish",
|
||||
"pbkdf2",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
@@ -403,6 +450,16 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blowfish"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -435,6 +492,12 @@ version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -495,6 +558,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -654,6 +728,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
@@ -937,6 +1017,18 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -966,6 +1058,41 @@ dependencies = [
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1056,6 +1183,16 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "9.0.0"
|
||||
@@ -1114,7 +1251,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1124,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.1",
|
||||
]
|
||||
|
||||
@@ -1175,12 +1314,66 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -1284,6 +1477,22 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -1526,6 +1735,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1568,6 +1778,16 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.33.0"
|
||||
@@ -1636,6 +1856,17 @@ dependencies = [
|
||||
"memmap2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@@ -1724,6 +1955,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.26.0"
|
||||
@@ -2212,6 +2452,9 @@ name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
@@ -2672,6 +2915,22 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -2698,6 +2957,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2705,6 +2974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2765,6 +3035,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.76"
|
||||
@@ -2818,6 +3094,44 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p384"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p521"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"rand_core 0.6.4",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2847,6 +3161,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdf-extract"
|
||||
version = "0.10.0"
|
||||
@@ -2864,6 +3187,15 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -3009,6 +3341,27 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -3021,6 +3374,29 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pom"
|
||||
version = "1.1.0"
|
||||
@@ -3101,6 +3477,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -3525,6 +3910,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3539,6 +3934,27 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sha2 0.10.9",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.37.0"
|
||||
@@ -3745,6 +4161,20 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrets"
|
||||
version = "0.1.0"
|
||||
@@ -4060,6 +4490,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -4103,6 +4543,72 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-cipher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"cbc",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"poly1305",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-encoding"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"pem-rfc7468",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-key"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
|
||||
dependencies = [
|
||||
"bcrypt-pbkdf",
|
||||
"ed25519-dalek",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
"rand_core 0.6.4",
|
||||
"rsa",
|
||||
"sec1",
|
||||
"sha2 0.10.9",
|
||||
"signature",
|
||||
"ssh-cipher",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -4934,6 +5440,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
@@ -6131,9 +6647,11 @@ dependencies = [
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml",
|
||||
"tower",
|
||||
"url",
|
||||
"uuid",
|
||||
"workdir",
|
||||
"worker",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6257,11 +6775,13 @@ dependencies = [
|
||||
"project-record",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.11.0",
|
||||
"ssh-key",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
|
||||
@@ -115,6 +115,7 @@ tar = "0.4"
|
||||
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
||||
ring = "0.17.14"
|
||||
sha2 = "0.11"
|
||||
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
|
||||
tempfile = "3.27"
|
||||
thiserror = "2.0"
|
||||
tokio = "1.52"
|
||||
|
||||
+69
-16
@@ -179,14 +179,20 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
|
||||
history: Vec<Item>,
|
||||
/// History length at lock time (only meaningful in Locked state)
|
||||
locked_prefix_len: usize,
|
||||
/// AgentTurn count.
|
||||
/// AgentTurn count across the lifetime of this Engine.
|
||||
///
|
||||
/// Once retry (`agen-stream-continuation`) is implemented, an
|
||||
/// AgentTurn collapses N retried `LlmCall`s with identical input;
|
||||
/// today retry is not implemented so AgentTurn and LlmCall fire 1:1
|
||||
/// and the increment site (the LLM-call loop) is shared.
|
||||
/// `max_turns` is interpreted as a per-`run()` AgentTurn cap.
|
||||
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
|
||||
/// `turn_count` this never collapses retries.
|
||||
llm_call_count: usize,
|
||||
@@ -268,6 +274,20 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
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) {
|
||||
while self.cancel_rx.try_recv().is_ok() {}
|
||||
}
|
||||
@@ -650,6 +670,23 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
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
|
||||
/// collapsed by retry).
|
||||
pub fn llm_call_count(&self) -> usize {
|
||||
@@ -1123,6 +1160,19 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
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;
|
||||
if !continuing_stream {
|
||||
debug!(turn = current_turn, "Turn start");
|
||||
@@ -1314,6 +1364,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
cb(current_turn);
|
||||
}
|
||||
self.turn_count += 1;
|
||||
*self.active_run_turn_count.get_or_insert(0) += 1;
|
||||
|
||||
// Collect and commit assistant items. Routed through
|
||||
// `append_history_items` so observers see each item as it lands.
|
||||
@@ -1344,18 +1395,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
if let Some(result) = self.execute_and_commit_tools(tool_calls).await? {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1664,6 +1703,7 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
||||
history: Vec::new(),
|
||||
locked_prefix_len: 0,
|
||||
turn_count: 0,
|
||||
active_run_turn_count: None,
|
||||
llm_call_count: 0,
|
||||
tool_execution_batch_count: 0,
|
||||
max_turns: None,
|
||||
@@ -1866,6 +1906,9 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
||||
/// Set the last_run_interrupted flag (for session restoration)
|
||||
pub fn set_last_run_interrupted(&mut self, interrupted: bool) {
|
||||
self.last_run_interrupted = interrupted;
|
||||
if !interrupted {
|
||||
self.active_run_turn_count = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply configuration (reserved for future extensions)
|
||||
@@ -1934,6 +1977,7 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
||||
history: self.history,
|
||||
locked_prefix_len,
|
||||
turn_count: self.turn_count,
|
||||
active_run_turn_count: self.active_run_turn_count,
|
||||
llm_call_count: self.llm_call_count,
|
||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||
max_turns: self.max_turns,
|
||||
@@ -1974,6 +2018,8 @@ impl<C: LlmClient> Engine<C, Locked> {
|
||||
&mut self,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<EngineResult, EngineError> {
|
||||
// Supplying new user input abandons any paused/yielded logical run.
|
||||
self.active_run_turn_count = None;
|
||||
self.reset_interruption_state();
|
||||
// Interceptor: on_prompt_submit
|
||||
let mut user_item = Item::user_message(user_input);
|
||||
@@ -1991,8 +2037,11 @@ impl<C: LlmClient> Engine<C, Locked> {
|
||||
if !extras.is_empty() {
|
||||
self.append_history_items(extras)?;
|
||||
}
|
||||
self.start_logical_run();
|
||||
let result = self.run_turn_loop().await;
|
||||
self.finalize_interruption(result).await
|
||||
let result = self.finalize_interruption(result).await;
|
||||
self.finish_logical_run(&result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Resume execution (from Paused state)
|
||||
@@ -2000,8 +2049,11 @@ impl<C: LlmClient> Engine<C, Locked> {
|
||||
/// Resumes turn processing from current state without adding a new user message.
|
||||
pub async fn resume(&mut self) -> Result<EngineResult, EngineError> {
|
||||
self.reset_interruption_state();
|
||||
self.ensure_logical_run();
|
||||
let result = self.run_turn_loop().await;
|
||||
self.finalize_interruption(result).await
|
||||
let result = self.finalize_interruption(result).await;
|
||||
self.finish_logical_run(&result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Get the prefix length at lock time
|
||||
@@ -2027,6 +2079,7 @@ impl<C: LlmClient> Engine<C, Locked> {
|
||||
history: self.history,
|
||||
locked_prefix_len: 0,
|
||||
turn_count: self.turn_count,
|
||||
active_run_turn_count: self.active_run_turn_count,
|
||||
llm_call_count: self.llm_call_count,
|
||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||
max_turns: self.max_turns,
|
||||
|
||||
@@ -9,9 +9,12 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::Item;
|
||||
use agen::interceptor::{
|
||||
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
|
||||
};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, EngineError};
|
||||
use agen::{Engine, EngineError, EngineResult};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
@@ -561,3 +564,185 @@ fn test_system_prompt_change_after_unlock() {
|
||||
let relocked = unlocked.lock();
|
||||
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));
|
||||
engine.set_max_turns(Some(1));
|
||||
let mut engine = engine.lock();
|
||||
|
||||
assert_eq!(engine.run("first").await.unwrap(), EngineResult::Finished);
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
|
||||
assert_eq!(engine.run("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()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(YieldOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock();
|
||||
|
||||
assert_eq!(engine.run("start").await.unwrap(), EngineResult::Yielded);
|
||||
assert_eq!(engine.turn_count(), 0);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(0));
|
||||
|
||||
assert_eq!(engine.resume().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));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock();
|
||||
|
||||
assert_eq!(engine.run("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().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);
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock();
|
||||
|
||||
assert_eq!(engine.run("pause").await.unwrap(), EngineResult::Paused);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||
|
||||
assert_eq!(engine.run("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()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(ContinueTurnOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock();
|
||||
|
||||
assert_eq!(
|
||||
engine.run("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()));
|
||||
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();
|
||||
|
||||
assert_eq!(engine.resume().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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||
|
||||
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||
|
||||
@@ -44,8 +45,13 @@ pub struct CreateBackendWorkspaceRepositoryRecord {
|
||||
pub repository_id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub uri: String,
|
||||
pub provider: Option<String>,
|
||||
pub source: RepositorySource,
|
||||
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)]
|
||||
|
||||
@@ -473,8 +473,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, _queued_by: &str) -> ticket::Result<()> {
|
||||
self.send_unit::<()>(
|
||||
fn queue_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
_queued_by: &str,
|
||||
) -> ticket::Result<ticket::TicketQueueOutcome> {
|
||||
self.send_json::<(), _>(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/workflow/queue",
|
||||
|
||||
+84
-27
@@ -281,11 +281,44 @@ impl Method {
|
||||
/// Presentation category for an Internal Worker exposed through its parent's
|
||||
/// protocol stream. Internal Workers never become independently addressable
|
||||
/// protocol subjects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InternalWorkerKind {
|
||||
SubWorker,
|
||||
Service { kind: String },
|
||||
}
|
||||
|
||||
/// Stable parent-owned lifecycle for one compaction run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct CompactionLifecycle {
|
||||
pub schema_version: u32,
|
||||
pub compaction_id: String,
|
||||
pub revision: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub internal_worker: Option<InternalWorkerRef>,
|
||||
pub state: CompactionLifecycleState,
|
||||
/// Milliseconds since the Unix epoch.
|
||||
pub started_at_ms: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ended_at_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub new_segment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompactionLifecycleState {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
/// Stable presentation identity for one parent-owned Internal Worker session.
|
||||
@@ -607,23 +640,18 @@ pub enum Event {
|
||||
/// This is not part of LLM history or prompt context; clients may display it
|
||||
/// briefly as operational status.
|
||||
MemoryWorker(MemoryWorkerEvent),
|
||||
/// Worker has started compacting the current session.
|
||||
///
|
||||
/// Fired immediately before a compaction run. Success is signalled by
|
||||
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
|
||||
/// Broadcast to all clients; not replayed to late subscribers.
|
||||
CompactStart,
|
||||
/// Compaction completed and the session was rotated.
|
||||
///
|
||||
/// `new_segment_id` is the UUID of the freshly created session that
|
||||
/// replaced the old history.
|
||||
CompactDone {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "string"))]
|
||||
new_segment_id: uuid::Uuid,
|
||||
/// Worker has started compacting the current session, or bound the run to its
|
||||
/// observable Internal Worker. Revisions upsert one stable lifecycle item.
|
||||
CompactStart {
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
/// Compaction failed. The session is unchanged.
|
||||
/// Compaction completed and the session was rotated.
|
||||
CompactDone {
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
/// Compaction failed or was cancelled. The session is unchanged.
|
||||
CompactFailed {
|
||||
error: String,
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
@@ -1732,45 +1760,74 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64);
|
||||
}
|
||||
|
||||
fn test_compaction_lifecycle(state: CompactionLifecycleState) -> CompactionLifecycle {
|
||||
CompactionLifecycle {
|
||||
schema_version: 2,
|
||||
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
|
||||
revision: 1,
|
||||
internal_worker: None,
|
||||
state,
|
||||
started_at_ms: 1_700_000_000_000,
|
||||
ended_at_ms: None,
|
||||
summary: None,
|
||||
error: None,
|
||||
new_segment_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_start_roundtrip() {
|
||||
let event = Event::CompactStart;
|
||||
let event = Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(CompactionLifecycleState::Running),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(json, r#"{"event":"compact_start"}"#);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_start");
|
||||
assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(decoded, Event::CompactStart));
|
||||
assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_done_roundtrip() {
|
||||
let id = uuid::Uuid::parse_str("0192f0e8-4d84-7d6e-a000-000000000001").unwrap();
|
||||
let event = Event::CompactDone { new_segment_id: id };
|
||||
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Done);
|
||||
lifecycle.new_segment_id = Some(id.to_string());
|
||||
lifecycle.summary = Some("accepted summary".into());
|
||||
let event = Event::CompactDone { lifecycle };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_done");
|
||||
assert_eq!(
|
||||
parsed["data"]["new_segment_id"],
|
||||
parsed["data"]["lifecycle"]["new_segment_id"],
|
||||
"0192f0e8-4d84-7d6e-a000-000000000001"
|
||||
);
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::CompactDone { new_segment_id } => assert_eq!(new_segment_id, id),
|
||||
Event::CompactDone { lifecycle } => {
|
||||
assert_eq!(
|
||||
lifecycle.new_segment_id.as_deref(),
|
||||
Some(id.to_string().as_str())
|
||||
)
|
||||
}
|
||||
other => panic!("expected CompactDone, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_failed_roundtrip() {
|
||||
let event = Event::CompactFailed {
|
||||
error: "provider 429".into(),
|
||||
};
|
||||
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Failed);
|
||||
lifecycle.error = Some("provider 429".into());
|
||||
let event = Event::CompactFailed { lifecycle };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_failed");
|
||||
assert_eq!(parsed["data"]["error"], "provider 429");
|
||||
assert_eq!(parsed["data"]["lifecycle"]["error"], "provider 429");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::CompactFailed { error } => assert_eq!(error, "provider 429"),
|
||||
Event::CompactFailed { lifecycle } => {
|
||||
assert_eq!(lifecycle.error.as_deref(), Some("provider 429"))
|
||||
}
|
||||
other => panic!("expected CompactFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ use ts_rs::{Config, TS};
|
||||
|
||||
use crate::{
|
||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||
CommandStreamSlice, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
||||
InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef,
|
||||
InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary,
|
||||
RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent,
|
||||
WorkerStatus,
|
||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
||||
RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
@@ -53,6 +53,8 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<CommandStreamSlice>(&cfg, &mut output);
|
||||
push_decl::<CommandSnapshot>(&cfg, &mut output);
|
||||
push_decl::<CommandEvent>(&cfg, &mut output);
|
||||
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
|
||||
push_decl::<CompactionLifecycle>(&cfg, &mut output);
|
||||
push_decl::<ScopeRule>(&cfg, &mut output);
|
||||
push_decl::<CompletionEntry>(&cfg, &mut output);
|
||||
push_decl::<RewindTargetId>(&cfg, &mut output);
|
||||
|
||||
@@ -307,6 +307,7 @@ pub fn save_run_completed(
|
||||
segment_id: SegmentId,
|
||||
result: EngineResult,
|
||||
interrupted: bool,
|
||||
active_run_turn_count: Option<usize>,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
@@ -316,6 +317,7 @@ pub fn save_run_completed(
|
||||
ts: segment_log::now_millis(),
|
||||
interrupted,
|
||||
result,
|
||||
active_run_turn_count,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,11 +125,16 @@ pub enum LogEntry {
|
||||
TurnEnd { ts: u64, turn_count: usize },
|
||||
|
||||
/// `run()` / `resume()` が `EngineResult` で正常終了した。
|
||||
/// Audit-only metadata: replay は `interrupted` のみ反映する。
|
||||
/// Replay restores both interruption state and any resumable logical-run
|
||||
/// turn budget.
|
||||
RunCompleted {
|
||||
ts: u64,
|
||||
interrupted: bool,
|
||||
result: EngineResult,
|
||||
/// AgentTurns consumed by a paused/yielded logical run. Terminal
|
||||
/// outcomes persist `None`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
active_run_turn_count: Option<usize>,
|
||||
},
|
||||
|
||||
/// `run()` / `resume()` が `EngineError` で終了した。
|
||||
@@ -141,6 +146,15 @@ pub enum LogEntry {
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Restores an active logical-run budget at a segment boundary, notably
|
||||
/// after compaction replaced the segment that held the original Invoke and
|
||||
/// RunCompleted entries.
|
||||
ActiveRunCheckpoint {
|
||||
ts: u64,
|
||||
active_turn_count: usize,
|
||||
total_turn_count: usize,
|
||||
},
|
||||
|
||||
/// A paused interrupted turn was explicitly abandoned without calling
|
||||
/// `run()` or `resume()` again. Replay clears the interrupted marker so
|
||||
/// the restored Worker is idle and future user input starts a normal new turn.
|
||||
@@ -209,6 +223,8 @@ pub struct RestoredState {
|
||||
pub config: RequestConfig,
|
||||
pub history: Vec<Item>,
|
||||
pub turn_count: usize,
|
||||
/// AgentTurns consumed by the active paused/yielded logical run.
|
||||
pub active_run_turn_count: Option<usize>,
|
||||
pub last_run_interrupted: bool,
|
||||
/// Number of entries replayed. `0` means the segment log was empty.
|
||||
/// Writers track their own append count via the same counter so
|
||||
@@ -238,6 +254,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
config: RequestConfig::default(),
|
||||
history: Vec::new(),
|
||||
turn_count: 0,
|
||||
active_run_turn_count: None,
|
||||
last_run_interrupted: false,
|
||||
entries_count: 0,
|
||||
usage_history: Vec::new(),
|
||||
@@ -265,6 +282,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
// A terminal run record below clears or refines this. If the
|
||||
// log ends first, restore must treat the turn as interrupted.
|
||||
state.last_run_interrupted = true;
|
||||
state.active_run_turn_count = Some(0);
|
||||
}
|
||||
LogEntry::UserInput {
|
||||
segments,
|
||||
@@ -290,16 +308,44 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
state.history.push(item.to_history_item());
|
||||
}
|
||||
LogEntry::TurnEnd { turn_count, .. } => {
|
||||
if let Some(active_turn_count) = &mut state.active_run_turn_count {
|
||||
*active_turn_count += turn_count.saturating_sub(state.turn_count);
|
||||
}
|
||||
state.turn_count = *turn_count;
|
||||
}
|
||||
LogEntry::RunCompleted { interrupted, .. } => {
|
||||
LogEntry::RunCompleted {
|
||||
interrupted,
|
||||
result,
|
||||
active_run_turn_count,
|
||||
..
|
||||
} => {
|
||||
state.last_run_interrupted = *interrupted;
|
||||
if *interrupted && matches!(result, EngineResult::Paused | EngineResult::Yielded) {
|
||||
// Legacy entries omit the explicit field; retain the
|
||||
// Invoke/TurnEnd-derived count in that case.
|
||||
if let Some(turn_count) = active_run_turn_count {
|
||||
state.active_run_turn_count = Some(*turn_count);
|
||||
}
|
||||
} else {
|
||||
state.active_run_turn_count = None;
|
||||
}
|
||||
}
|
||||
LogEntry::RunErrored { interrupted, .. } => {
|
||||
state.last_run_interrupted = *interrupted;
|
||||
state.active_run_turn_count = None;
|
||||
}
|
||||
LogEntry::ActiveRunCheckpoint {
|
||||
active_turn_count,
|
||||
total_turn_count,
|
||||
..
|
||||
} => {
|
||||
state.active_run_turn_count = Some(*active_turn_count);
|
||||
state.turn_count = *total_turn_count;
|
||||
state.last_run_interrupted = true;
|
||||
}
|
||||
LogEntry::PausedTurnAbandoned { .. } => {
|
||||
state.last_run_interrupted = false;
|
||||
state.active_run_turn_count = None;
|
||||
}
|
||||
LogEntry::ConfigChanged { config, .. } => {
|
||||
state.config = config.clone();
|
||||
@@ -397,6 +443,7 @@ mod tests {
|
||||
ts: 3200,
|
||||
interrupted: false,
|
||||
result: EngineResult::Finished,
|
||||
active_run_turn_count: None,
|
||||
},
|
||||
]);
|
||||
assert_eq!(state.history.len(), 2);
|
||||
@@ -695,10 +742,93 @@ mod tests {
|
||||
ts: 100,
|
||||
interrupted: true,
|
||||
result: EngineResult::Paused,
|
||||
active_run_turn_count: Some(1),
|
||||
},
|
||||
LogEntry::PausedTurnAbandoned { ts: 200 },
|
||||
]);
|
||||
assert!(!state.last_run_interrupted);
|
||||
assert_eq!(state.active_run_turn_count, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_restores_active_run_budget_across_compaction_checkpoint() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::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]
|
||||
|
||||
@@ -51,6 +51,7 @@ fn round_trip_write_and_read() {
|
||||
ts: 3200,
|
||||
interrupted: false,
|
||||
result: EngineResult::Finished,
|
||||
active_run_turn_count: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ async fn run_and_persist(
|
||||
segment_id,
|
||||
r.clone(),
|
||||
worker.last_run_interrupted(),
|
||||
worker.active_run_turn_count(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -309,6 +310,7 @@ async fn session_resume_after_pause() {
|
||||
// Restore state and verify
|
||||
let state = session_store::restore(&store, sid, segid).unwrap();
|
||||
assert!(state.last_run_interrupted);
|
||||
assert_eq!(state.active_run_turn_count, Some(2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+1137
-232
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, \
|
||||
state_changed event, effective target, and planning -> ready transition atomically.";
|
||||
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
||||
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
|
||||
and rejects unresolved blocking relations.";
|
||||
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
|
||||
requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
|
||||
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
||||
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
||||
@@ -316,7 +316,11 @@ impl TicketBackend for TicketToolBackend {
|
||||
self.backend.mark_ready(id, request)
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
||||
fn queue_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
queued_by: &str,
|
||||
) -> TicketResult<crate::TicketQueueOutcome> {
|
||||
self.backend.queue_ready(id, queued_by)
|
||||
}
|
||||
|
||||
@@ -1219,12 +1223,22 @@ impl Tool for TicketQueueTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||
let queued_by = default_author();
|
||||
self.backend
|
||||
let outcome = self
|
||||
.backend
|
||||
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||
Ok(json_output(
|
||||
format!("Queued ticket {} for Orchestrator", params.ticket),
|
||||
json!({ "ticket": params.ticket, "state": "queued", "queued_by": queued_by, "ok": true }),
|
||||
format!(
|
||||
"Queued {} ticket(s) for Orchestrator",
|
||||
outcome.queued_tickets.len()
|
||||
),
|
||||
json!({
|
||||
"ticket": outcome.requested_ticket,
|
||||
"queued_tickets": outcome.queued_tickets,
|
||||
"state": "queued",
|
||||
"queued_by": queued_by,
|
||||
"ok": true
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
+51
-16
@@ -1342,13 +1342,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::CompactStart => {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
}));
|
||||
Event::CompactStart { .. } => {
|
||||
if self.last_streaming_compact_mut().is_none() {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Event::CompactDone { new_segment_id } => {
|
||||
Event::CompactDone { lifecycle } => {
|
||||
self.session_context_tokens = 0;
|
||||
let new_segment_id = lifecycle
|
||||
.new_segment_id
|
||||
.as_deref()
|
||||
.and_then(|value| uuid::Uuid::parse_str(value).ok())
|
||||
.unwrap_or_default();
|
||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||
let elapsed_secs = match evt {
|
||||
CompactEvent::Streaming { started_at } => {
|
||||
@@ -1367,7 +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() {
|
||||
let elapsed_secs = match evt {
|
||||
CompactEvent::Streaming { started_at } => {
|
||||
@@ -2486,7 +2496,7 @@ fn event_is_stale_after_rewind(event: &Event) -> bool {
|
||||
event,
|
||||
Event::Alert(_)
|
||||
| Event::MemoryWorker(_)
|
||||
| Event::CompactStart
|
||||
| Event::CompactStart { .. }
|
||||
| Event::CompactDone { .. }
|
||||
| Event::CompactFailed { .. }
|
||||
| Event::SegmentRotated { .. }
|
||||
@@ -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]
|
||||
fn compact_done_replaces_live_block() {
|
||||
let mut app = App::new("test".into());
|
||||
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
||||
|
||||
app.handle_worker_event(Event::CompactStart);
|
||||
app.handle_worker_event(Event::CompactDone { new_segment_id: id });
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||
lifecycle.new_segment_id = Some(id.to_string());
|
||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||
|
||||
assert_eq!(compact_block_count(&app), 1);
|
||||
assert!(matches!(
|
||||
@@ -4098,10 +4129,12 @@ mod completion_flow_tests {
|
||||
fn compact_failed_replaces_live_block() {
|
||||
let mut app = App::new("test".into());
|
||||
|
||||
app.handle_worker_event(Event::CompactStart);
|
||||
app.handle_worker_event(Event::CompactFailed {
|
||||
error: "provider 429".into(),
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
|
||||
lifecycle.error = Some("provider 429".into());
|
||||
app.handle_worker_event(Event::CompactFailed { lifecycle });
|
||||
|
||||
assert_eq!(compact_block_count(&app), 1);
|
||||
assert!(matches!(
|
||||
@@ -4117,7 +4150,9 @@ mod completion_flow_tests {
|
||||
fn shutdown_marks_live_compact_incomplete() {
|
||||
let mut app = App::new("test".into());
|
||||
|
||||
app.handle_worker_event(Event::CompactStart);
|
||||
app.handle_worker_event(Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
app.handle_worker_event(Event::Shutdown);
|
||||
|
||||
assert!(app.quit);
|
||||
@@ -4208,9 +4243,9 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.session_context_tokens = 42_000;
|
||||
|
||||
app.handle_worker_event(Event::CompactDone {
|
||||
new_segment_id: uuid::Uuid::nil(),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
|
||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||
|
||||
assert_eq!(app.session_context_tokens, 0);
|
||||
}
|
||||
|
||||
+136
-40
@@ -4,6 +4,7 @@ use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use client::ticket_role::{
|
||||
@@ -4125,7 +4126,10 @@ async fn dispatch_ticket_action(
|
||||
let config = TicketConfig::load_workspace(&request.workspace_root)
|
||||
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
|
||||
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 {
|
||||
return dispatch_panel_close(&backend, &request.ticket_id);
|
||||
}
|
||||
@@ -4201,17 +4205,32 @@ async fn dispatch_panel_queue(
|
||||
"root-ticket-state-after-orchestration-merge",
|
||||
&preflight.root_top_level,
|
||||
)?;
|
||||
backend
|
||||
let queue_outcome = backend
|
||||
.queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel")
|
||||
.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 sync = sync_panel_queue_to_orchestration(&preflight, &commit)?;
|
||||
verify_panel_queue_synced(&preflight, &commit)?;
|
||||
let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await;
|
||||
Ok(TicketActionOutcome {
|
||||
notice: format!(
|
||||
"Queued Ticket {}; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
|
||||
ticket_id,
|
||||
"Queued Ticket closure [{}]; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
|
||||
queue_outcome.queued_tickets.join(", "),
|
||||
commit.sha,
|
||||
root_merge.sentence(),
|
||||
sync.sentence(),
|
||||
@@ -4220,12 +4239,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)]
|
||||
struct PanelQueueHandoffPreflight {
|
||||
ticket_id: String,
|
||||
root_top_level: PathBuf,
|
||||
orchestration: OrchestrationWorktreeLayout,
|
||||
ticket_record_dir: PathBuf,
|
||||
queue_tickets: Vec<String>,
|
||||
ticket_record_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -4393,27 +4452,53 @@ fn prepare_panel_queue_handoff(
|
||||
&root_top_level,
|
||||
)?;
|
||||
|
||||
let ticket_record_dir = backend.root().join(ticket_id);
|
||||
if !ticket_record_dir.join("item.md").is_file() {
|
||||
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(
|
||||
"target-ticket-record",
|
||||
"dependency-queue-plan",
|
||||
ticket_id,
|
||||
&ticket_record_dir,
|
||||
"target Ticket item.md is missing".to_string(),
|
||||
&root_top_level,
|
||||
dependency_check
|
||||
.queue_guard
|
||||
.blocked_reason
|
||||
.or(dependency_check.queue_guard.reason)
|
||||
.unwrap_or_else(|| "Queue dependency validation failed".to_string()),
|
||||
));
|
||||
}
|
||||
ensure_git_path_clean(
|
||||
"root-ticket-clean",
|
||||
ticket_id,
|
||||
&root_top_level,
|
||||
&ticket_record_dir,
|
||||
)?;
|
||||
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() {
|
||||
return Err(queue_check_failed(
|
||||
"target-ticket-record",
|
||||
&queue_ticket,
|
||||
&ticket_record_dir,
|
||||
"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(
|
||||
clean_stage,
|
||||
&queue_ticket,
|
||||
&root_top_level,
|
||||
&ticket_record_dir,
|
||||
)?;
|
||||
ticket_record_dirs.push(ticket_record_dir);
|
||||
}
|
||||
|
||||
Ok(PanelQueueHandoffPreflight {
|
||||
ticket_id: ticket_id.to_string(),
|
||||
root_top_level,
|
||||
orchestration,
|
||||
ticket_record_dir,
|
||||
queue_tickets,
|
||||
ticket_record_dirs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4504,37 +4589,36 @@ fn sync_orchestration_to_root_before_queue(
|
||||
fn commit_panel_queue_ticket_record(
|
||||
preflight: &PanelQueueHandoffPreflight,
|
||||
) -> Result<PanelQueueCommit, TicketActionError> {
|
||||
let ticket_rel = path_relative_to_root(
|
||||
&preflight.root_top_level,
|
||||
&preflight.ticket_record_dir,
|
||||
"target-ticket-record",
|
||||
&preflight.ticket_id,
|
||||
)?;
|
||||
let ticket_rels = preflight
|
||||
.ticket_record_dirs
|
||||
.iter()
|
||||
.map(|ticket_record_dir| {
|
||||
path_relative_to_root(
|
||||
&preflight.root_top_level,
|
||||
ticket_record_dir,
|
||||
"target-ticket-record",
|
||||
&preflight.ticket_id,
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut add = Command::new("git");
|
||||
add.arg("-C")
|
||||
.arg(&preflight.root_top_level)
|
||||
.arg("add")
|
||||
.arg("--")
|
||||
.arg(&ticket_rel);
|
||||
run_git_command(add, "stage Queue Ticket record").map_err(|message| {
|
||||
.args(&ticket_rels);
|
||||
run_git_command(add, "stage Queue Ticket records").map_err(|message| {
|
||||
queue_check_failed(
|
||||
"queue-commit-stage",
|
||||
&preflight.ticket_id,
|
||||
&preflight.ticket_record_dir,
|
||||
&preflight.root_top_level,
|
||||
message,
|
||||
)
|
||||
})?;
|
||||
|
||||
let ticket_rel_string = git_path_string(&ticket_rel);
|
||||
let staged = git_capture(
|
||||
&preflight.root_top_level,
|
||||
&[
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-only",
|
||||
"--",
|
||||
ticket_rel_string.as_str(),
|
||||
],
|
||||
&["diff", "--cached", "--name-only"],
|
||||
"list staged Queue Ticket files",
|
||||
)
|
||||
.map_err(|message| {
|
||||
@@ -4545,19 +4629,31 @@ fn commit_panel_queue_ticket_record(
|
||||
message,
|
||||
)
|
||||
})?;
|
||||
let allowed = ticket_rels
|
||||
.iter()
|
||||
.map(|path| format!("{}/", git_path_string(path).trim_end_matches('/')))
|
||||
.collect::<Vec<_>>();
|
||||
let staged_paths = staged
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.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(
|
||||
"queue-commit-pathscope",
|
||||
&preflight.ticket_id,
|
||||
&preflight.ticket_record_dir,
|
||||
"Queue mutation produced no staged Ticket record changes".to_string(),
|
||||
&preflight.root_top_level,
|
||||
"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");
|
||||
commit
|
||||
.arg("-C")
|
||||
@@ -4567,8 +4663,8 @@ fn commit_panel_queue_ticket_record(
|
||||
.arg("-m")
|
||||
.arg(message)
|
||||
.arg("--")
|
||||
.arg(&ticket_rel);
|
||||
run_git_command(commit, "commit Queue Ticket record").map_err(|message| {
|
||||
.args(&ticket_rels);
|
||||
run_git_command(commit, "commit Queue Ticket records").map_err(|message| {
|
||||
queue_check_failed(
|
||||
"queue-commit-create",
|
||||
&preflight.ticket_id,
|
||||
|
||||
@@ -462,7 +462,7 @@ pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
|
||||
.as_ref()
|
||||
.and_then(|ticket| ticket.blocked_reason.as_deref())
|
||||
{
|
||||
parts.push(format!("Gate: waiting for {blocked_reason}"));
|
||||
parts.push(format!("Dependencies: {blocked_reason}"));
|
||||
} else {
|
||||
parts.push("Gate: clear".to_string());
|
||||
}
|
||||
|
||||
@@ -1846,24 +1846,23 @@ fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() {
|
||||
}
|
||||
|
||||
#[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(
|
||||
"00001WAITING",
|
||||
"Ready but gated",
|
||||
ActionPriority::Background,
|
||||
NextUserAction::Wait,
|
||||
"Ready with dependency context",
|
||||
ActionPriority::ReadyForQueue,
|
||||
NextUserAction::Queue,
|
||||
"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());
|
||||
|
||||
let lines = panel_row_lines(&row, true, 160);
|
||||
let detail = &lines[1];
|
||||
let detail_line = plain_line(&detail);
|
||||
|
||||
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
|
||||
assert!(detail_line.contains("Action: queue disabled"));
|
||||
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1"));
|
||||
assert!(detail_line.contains("Dependencies: BLOCKER-1 via depends_on"));
|
||||
assert!(detail_line.contains("Action: Queue"));
|
||||
assert!(!detail_line.contains("Queue disabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2203,7 +2203,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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();
|
||||
write_ticket_config(temp.path());
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
@@ -2235,12 +2235,7 @@ mod tests {
|
||||
assert_eq!(row.kind, PanelRowKind::Ticket);
|
||||
assert_eq!(row.next_action, Some(NextUserAction::Wait));
|
||||
assert_eq!(row.priority, ActionPriority::Background);
|
||||
assert!(
|
||||
row.disabled_reason
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("Queue disabled: waiting for")
|
||||
);
|
||||
assert!(row.disabled_reason.is_some());
|
||||
assert!(
|
||||
row.ticket
|
||||
.as_ref()
|
||||
@@ -2253,7 +2248,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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();
|
||||
write_ticket_config(temp.path());
|
||||
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.priority, ActionPriority::ReadyForQueue);
|
||||
assert!(row.disabled_reason.is_none());
|
||||
assert!(row.ticket.as_ref().unwrap().blocked_reason.is_none());
|
||||
assert!(
|
||||
row.key_hint
|
||||
.as_deref()
|
||||
row.ticket
|
||||
.as_ref()
|
||||
.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));
|
||||
}
|
||||
|
||||
|
||||
@@ -311,7 +311,10 @@ impl DelegatingWorkdirSession {
|
||||
if !self.capabilities.supports(WorkdirSessionCapability::Read)
|
||||
|| (writable
|
||||
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|
||||
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
|
||||
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)
|
||||
|| !self
|
||||
.capabilities
|
||||
.supports(WorkdirSessionCapability::Command)))
|
||||
{
|
||||
return Err(WorkdirError::Denied(
|
||||
"parent workdir session cannot delegate the requested capabilities".into(),
|
||||
@@ -342,6 +345,7 @@ impl DelegatingWorkdirSession {
|
||||
if writable {
|
||||
delegated.push(WorkdirSessionCapability::Write);
|
||||
delegated.push(WorkdirSessionCapability::Edit);
|
||||
delegated.push(WorkdirSessionCapability::Command);
|
||||
}
|
||||
Ok(WorkdirSessionCapabilities::from_capabilities(delegated))
|
||||
}
|
||||
@@ -929,6 +933,43 @@ mod tests {
|
||||
.delegate(request("leased", WorkdirDelegationPermission::Write))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
child
|
||||
.capabilities
|
||||
.supports(WorkdirSessionCapability::Command)
|
||||
);
|
||||
let command = child
|
||||
.scoped_session
|
||||
.start_command(CommandRequest {
|
||||
command: "printf child-command".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
tool_call_id: Some("delegated-child-command".into()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let command_output = child
|
||||
.scoped_session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle: command,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(command_output.content, "child-command");
|
||||
assert!(
|
||||
parent
|
||||
.start_command(CommandRequest {
|
||||
command: "printf parent-command".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
tool_call_id: Some("blocked-parent-command".into()),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
parent.write(write("leased/file", "parent")).await,
|
||||
|
||||
@@ -211,6 +211,7 @@ pub struct WorkingDirectoryListResponse {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryDetailResponse {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub item: WorkingDirectorySummary,
|
||||
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
|
||||
}
|
||||
@@ -306,6 +307,7 @@ mod tests {
|
||||
|
||||
let detail = WorkingDirectoryDetailResponse {
|
||||
workspace_id: decoded.workspace_id.clone(),
|
||||
runtime_id: "arcadia".to_string(),
|
||||
item: decoded.items[0].clone(),
|
||||
diagnostics: decoded.diagnostics.clone(),
|
||||
};
|
||||
|
||||
@@ -41,9 +41,11 @@ tar.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
||||
toml.workspace = true
|
||||
url.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
tower = { workspace = true, features = ["util"], optional = true }
|
||||
worker.workspace = true
|
||||
workspace-api = { path = "../workspace-api" }
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -3,6 +3,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
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_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
|
||||
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)]
|
||||
pub enum RuntimeAuthError {
|
||||
@@ -33,6 +39,10 @@ pub enum RuntimeAuthError {
|
||||
InvalidTokenFormat,
|
||||
#[error("malformed capability token claims: {0}")]
|
||||
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}`")]
|
||||
UnknownIssuer(String),
|
||||
#[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)]
|
||||
pub struct WorkerMutationSourceClaims {
|
||||
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]
|
||||
fn capability_token_verifies_signature_audience_expiry_and_permission() {
|
||||
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::identity::{RuntimeWorkerRef, WorkerId, WorkerRef};
|
||||
use crate::interaction::WorkerInput;
|
||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
@@ -85,9 +84,9 @@ impl std::ops::Deref for RepositorySelector {
|
||||
pub struct WorkingDirectoryRepository {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub uri: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub local_path: Option<PathBuf>,
|
||||
pub source: workspace_api::RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selector: Option<RepositorySelector>,
|
||||
}
|
||||
|
||||
@@ -160,15 +160,33 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
};
|
||||
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
|
||||
.with_runtime_store_dir(runtime_store_dir);
|
||||
if let Some(identity) = read_runtime_auth_file(&runtime_auth_path(config))?.identity {
|
||||
factory = factory.with_remote_worker_mutation_identity(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);
|
||||
}
|
||||
}
|
||||
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
|
||||
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(),
|
||||
));
|
||||
};
|
||||
factory = factory.with_resource_client(Arc::new(
|
||||
worker_runtime::resource::HttpBackendResourceClient::new(
|
||||
endpoint,
|
||||
config.backend_resource_token.clone(),
|
||||
),
|
||||
)
|
||||
.with_runtime_request_source(identity, trusted_server.server_id.clone()),
|
||||
));
|
||||
}
|
||||
let backend = Arc::new(
|
||||
|
||||
@@ -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::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
|
||||
use async_trait::async_trait;
|
||||
@@ -108,6 +112,8 @@ pub trait BackendResourceClient: Send + Sync + 'static {
|
||||
pub struct HttpBackendResourceClient {
|
||||
endpoint: String,
|
||||
bearer_token: Option<String>,
|
||||
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
||||
request_source_audience: Option<String>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
@@ -117,9 +123,21 @@ impl HttpBackendResourceClient {
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
bearer_token,
|
||||
request_source_signer: None,
|
||||
request_source_audience: None,
|
||||
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")]
|
||||
@@ -129,7 +147,44 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
||||
&self,
|
||||
request: BackendResourceFetchRequest,
|
||||
) -> 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() {
|
||||
builder.bearer_auth(token)
|
||||
} else {
|
||||
|
||||
@@ -14,7 +14,10 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
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::{
|
||||
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
@@ -295,6 +298,7 @@ pub struct ProfileRuntimeWorkerFactory {
|
||||
prompt_projection_cache: Arc<WorkspacePromptProjectionCache>,
|
||||
runtime_id: Option<String>,
|
||||
worker_mutation_identity: Option<RuntimeIdentityMaterial>,
|
||||
runtime_request_audience: Option<String>,
|
||||
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
||||
controller_transport: WorkerControllerTransport,
|
||||
}
|
||||
@@ -311,6 +315,7 @@ impl ProfileRuntimeWorkerFactory {
|
||||
prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()),
|
||||
runtime_id: None,
|
||||
worker_mutation_identity: None,
|
||||
runtime_request_audience: None,
|
||||
embedded_worker_mutation_dispatcher: None,
|
||||
controller_transport: WorkerControllerTransport::UnixSocket,
|
||||
}
|
||||
@@ -331,6 +336,17 @@ impl ProfileRuntimeWorkerFactory {
|
||||
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(
|
||||
mut self,
|
||||
runtime_id: impl Into<String>,
|
||||
@@ -457,13 +473,15 @@ impl ProfileRuntimeWorkerFactory {
|
||||
async fn resolve_profile_source_archive(
|
||||
&self,
|
||||
source: &ProfileSourceArchiveSource,
|
||||
request_audience: Option<&str>,
|
||||
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
|
||||
match source {
|
||||
ProfileSourceArchiveSource::Embedded { archive } => archive
|
||||
.verify()
|
||||
.map_err(|err| format!("failed to verify embedded profile source archive: {err}")),
|
||||
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(
|
||||
&self,
|
||||
location: &ProfileSourceArchiveHttpRef,
|
||||
request_audience: Option<&str>,
|
||||
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
|
||||
if let Some(cached) = self.profile_archive_cache.get(&location.archive.digest) {
|
||||
let response =
|
||||
fetch_profile_source_archive_http(location, Some(&location.archive.digest)).await?;
|
||||
let response = fetch_profile_source_archive_http(
|
||||
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 {
|
||||
self.profile_archive_cache.insert(fetched.clone());
|
||||
fetched.verify().map_err(|err| {
|
||||
@@ -486,12 +512,19 @@ impl ProfileRuntimeWorkerFactory {
|
||||
.map_err(|err| format!("failed to verify cached profile source archive: {err}"))
|
||||
}
|
||||
} else {
|
||||
let archive = fetch_profile_source_archive_http(location, None)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
"profile source archive HTTP revalidation returned 304 without a cached archive"
|
||||
.to_string()
|
||||
})?;
|
||||
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?
|
||||
.ok_or_else(|| {
|
||||
"profile source archive HTTP revalidation returned 304 without a cached archive"
|
||||
.to_string()
|
||||
})?;
|
||||
self.profile_archive_cache.insert(archive.clone());
|
||||
archive
|
||||
.verify()
|
||||
@@ -527,6 +560,7 @@ impl RuntimeWorkspaceBackendRef {
|
||||
worker_ref: &WorkerRef,
|
||||
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
|
||||
mutation_identity: Option<&RuntimeIdentityMaterial>,
|
||||
runtime_request_audience: Option<&str>,
|
||||
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
||||
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
||||
) -> WorkerWorkspaceContext {
|
||||
@@ -546,6 +580,13 @@ impl RuntimeWorkspaceBackendRef {
|
||||
if let Some(cache) = prompt_projection_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) {
|
||||
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
|
||||
identity,
|
||||
@@ -576,9 +617,40 @@ impl RuntimeWorkspaceBackendRef {
|
||||
async fn fetch_profile_source_archive_http(
|
||||
location: &ProfileSourceArchiveHttpRef,
|
||||
cached_digest: Option<&str>,
|
||||
identity: Option<&RuntimeIdentityMaterial>,
|
||||
audience: Option<&str>,
|
||||
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
|
||||
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 let Some(etag) = location.etag.as_deref() {
|
||||
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(
|
||||
_location: &ProfileSourceArchiveHttpRef,
|
||||
_cached_digest: Option<&str>,
|
||||
_identity: Option<&RuntimeIdentityMaterial>,
|
||||
_audience: Option<&str>,
|
||||
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
|
||||
Err(
|
||||
"HTTP profile source archive fetch requires the worker-runtime http-server feature"
|
||||
@@ -743,12 +817,19 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
&request.worker_ref,
|
||||
request.workspace_scope.as_ref(),
|
||||
self.worker_mutation_identity.as_ref(),
|
||||
self.runtime_request_audience.as_deref(),
|
||||
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||
Some(self.prompt_projection_cache.clone()),
|
||||
);
|
||||
let selector = profile.as_ref();
|
||||
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?;
|
||||
let (mut manifest, mut loader) = {
|
||||
let manifest = archive
|
||||
@@ -909,6 +990,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
&request.worker_ref,
|
||||
request.workspace_scope.as_ref(),
|
||||
self.worker_mutation_identity.as_ref(),
|
||||
self.runtime_request_audience.as_deref(),
|
||||
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||
Some(self.prompt_projection_cache.clone()),
|
||||
);
|
||||
@@ -2187,12 +2269,18 @@ mod tests {
|
||||
let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main");
|
||||
|
||||
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 (after_restore_kind, after_restore_workspace_id) = adapter
|
||||
.run_on_adapter_runtime(async move {
|
||||
let after_restore =
|
||||
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None);
|
||||
let after_restore = backend.worker_context(
|
||||
&worker_ref,
|
||||
Some(&scope),
|
||||
Some(&identity),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let client = after_restore.client_handle();
|
||||
Ok((
|
||||
client.kind().to_string(),
|
||||
@@ -2383,6 +2471,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let workspace_client = workspace_context.client_handle();
|
||||
self.observed_workspace_clients.lock().unwrap().push((
|
||||
@@ -2631,8 +2720,12 @@ mod tests {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repo-main".to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: ".".to_string(),
|
||||
local_path: Some(repo.to_path_buf()),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: repo.display().to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
selector: Some(RepositorySelector::from("HEAD")),
|
||||
},
|
||||
materializer: MaterializerKind::LocalGitWorktree,
|
||||
@@ -2781,7 +2874,7 @@ mod tests {
|
||||
archive: bundle.profile_source_archive.clone().unwrap(),
|
||||
};
|
||||
factory
|
||||
.resolve_profile_source_archive(&source)
|
||||
.resolve_profile_source_archive(&source, None)
|
||||
.await
|
||||
.expect("embedded archive should resolve without Backend resource client");
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ use worker::{
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
|
||||
WORKER_REMOVE_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
|
||||
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION,
|
||||
WORKSPACE_REQUEST_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
|
||||
WorkerMutationSourceClaims, new_token_id,
|
||||
};
|
||||
use crate::runtime::RuntimeWorkspaceScope;
|
||||
@@ -289,6 +290,8 @@ pub struct RuntimeOwnedWorkspaceClient {
|
||||
worker_id: String,
|
||||
request_timeout: Option<Duration>,
|
||||
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
||||
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
||||
request_source_audience: Option<String>,
|
||||
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
||||
}
|
||||
|
||||
@@ -306,6 +309,8 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
worker_id: worker_id.into(),
|
||||
request_timeout: None,
|
||||
worker_remove: None,
|
||||
request_source_signer: None,
|
||||
request_source_audience: None,
|
||||
prompt_projection_cache: None,
|
||||
}
|
||||
}
|
||||
@@ -315,6 +320,16 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
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(
|
||||
mut self,
|
||||
cache: Arc<WorkspacePromptProjectionCache>,
|
||||
@@ -363,15 +378,21 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let base_url = self.base_url.clone();
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let runtime_id = self.runtime_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;
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
std::thread::spawn(move || {
|
||||
execute_runtime_owned_workspace_http(
|
||||
&base_url,
|
||||
&workspace_id,
|
||||
&runtime_id,
|
||||
&worker_id,
|
||||
request_source_signer.as_ref(),
|
||||
request_source_audience.as_deref(),
|
||||
request_timeout,
|
||||
request,
|
||||
)
|
||||
@@ -383,8 +404,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
} else {
|
||||
execute_runtime_owned_workspace_http(
|
||||
&self.base_url,
|
||||
&self.workspace_id,
|
||||
&self.runtime_id,
|
||||
&self.worker_id,
|
||||
self.request_source_signer.as_ref(),
|
||||
self.request_source_audience.as_deref(),
|
||||
self.request_timeout,
|
||||
request,
|
||||
)
|
||||
@@ -484,8 +508,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
|
||||
fn execute_runtime_owned_workspace_http(
|
||||
base_url: &str,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
worker_id: &str,
|
||||
request_source_signer: Option<&RuntimeRequestSourceSigner>,
|
||||
request_source_audience: Option<&str>,
|
||||
request_timeout: Option<Duration>,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
@@ -510,11 +537,33 @@ fn execute_runtime_owned_workspace_http(
|
||||
))
|
||||
})?;
|
||||
let request_label = format!("{method} {}", request.path);
|
||||
let body = request.body.unwrap_or_default();
|
||||
let mut request_builder = client
|
||||
.request(method, url)
|
||||
.request(method.clone(), url)
|
||||
.header("x-yoi-runtime-id", runtime_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
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
@@ -590,8 +639,8 @@ fn unix_now_seconds() -> u64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{
|
||||
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
|
||||
verify_worker_mutation_source_proof,
|
||||
WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
|
||||
decode_worker_mutation_source_claims, verify_worker_mutation_source_proof,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -797,7 +846,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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::net::TcpListener;
|
||||
use std::sync::Mutex;
|
||||
@@ -817,21 +866,41 @@ mod tests {
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||
let client = RuntimeOwnedWorkspaceClient::new(
|
||||
"workspace-a",
|
||||
format!("http://{address}"),
|
||||
"runtime-a",
|
||||
"worker-a",
|
||||
);
|
||||
)
|
||||
.with_runtime_request_source(&identity, "server-a");
|
||||
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();
|
||||
assert_eq!(response.status, 200);
|
||||
server.join().unwrap();
|
||||
let request = received.lock().unwrap().to_ascii_lowercase();
|
||||
assert!(request.contains("x-yoi-runtime-id: runtime-a"));
|
||||
assert!(request.contains("x-yoi-worker-id: worker-a"));
|
||||
assert!(!request.contains("authorization:"));
|
||||
let request = received.lock().unwrap().clone();
|
||||
let lowercase_request = request.to_ascii_lowercase();
|
||||
assert!(lowercase_request.contains("x-yoi-runtime-id: runtime-a"));
|
||||
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]
|
||||
|
||||
@@ -318,18 +318,36 @@ impl LocalGitWorktreeMaterializer {
|
||||
),
|
||||
));
|
||||
}
|
||||
if is_remote_uri(&request.repository.uri) {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_remote_repository_unsupported",
|
||||
"remote repository URI materialization is not implemented in v0",
|
||||
));
|
||||
}
|
||||
|
||||
let source_path = request
|
||||
.repository
|
||||
.local_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from(&request.repository.uri));
|
||||
let source_path = match request.repository.source.kind {
|
||||
workspace_api::RepositorySourceKind::LocalPath => {
|
||||
PathBuf::from(&request.repository.source.uri)
|
||||
}
|
||||
workspace_api::RepositorySourceKind::File => {
|
||||
url::Url::parse(&request.repository.source.uri)
|
||||
.ok()
|
||||
.and_then(|uri| uri.to_file_path().ok())
|
||||
.ok_or_else(|| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_repository_source_invalid",
|
||||
"configured file Repository source is invalid",
|
||||
)
|
||||
})?
|
||||
}
|
||||
workspace_api::RepositorySourceKind::Ssh
|
||||
| workspace_api::RepositorySourceKind::Http
|
||||
| workspace_api::RepositorySourceKind::Https => {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_remote_repository_access_required",
|
||||
"remote Repository materialization requires an explicit authenticated access and trust handle",
|
||||
));
|
||||
}
|
||||
workspace_api::RepositorySourceKind::Invalid => {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_repository_source_invalid",
|
||||
"configured Repository source is invalid and cannot be materialized",
|
||||
));
|
||||
}
|
||||
};
|
||||
let source_root = git_stdout(&source_path, ["rev-parse", "--show-toplevel"])
|
||||
.map(|value| PathBuf::from(value.trim()))
|
||||
.map_err(|_| {
|
||||
@@ -661,10 +679,6 @@ fn path_str(path: &Path) -> Result<String, WorkingDirectoryDiagnostic> {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_remote_uri(uri: &str) -> bool {
|
||||
uri.contains("://") || uri.starts_with("git@") || uri.starts_with("ssh:")
|
||||
}
|
||||
|
||||
fn sanitize_path_component(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
@@ -793,8 +807,12 @@ mod tests {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repo-main".to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: ".".to_string(),
|
||||
local_path: Some(repo.to_path_buf()),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: repo.display().to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
selector: Some(RepositorySelector::from("HEAD")),
|
||||
},
|
||||
materializer: MaterializerKind::LocalGitWorktree,
|
||||
@@ -908,19 +926,21 @@ mod tests {
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
let mut remote = request(Path::new("."));
|
||||
remote.repository.local_path = None;
|
||||
remote.repository.uri = "https://example.invalid/repo.git".to_string();
|
||||
remote.repository.source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Https,
|
||||
uri: "https://example.invalid/repo.git".to_string(),
|
||||
};
|
||||
let error = materializer
|
||||
.materialize(&worker_ref(1), &remote)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.code,
|
||||
"working_directory_remote_repository_unsupported"
|
||||
"working_directory_remote_repository_access_required"
|
||||
);
|
||||
|
||||
let mut non_git = remote;
|
||||
non_git.repository.provider = "archive".to_string();
|
||||
non_git.repository.uri = ".".to_string();
|
||||
non_git.repository.source.uri = ".".to_string();
|
||||
let error = materializer
|
||||
.materialize(&worker_ref(2), &non_git)
|
||||
.unwrap_err();
|
||||
|
||||
@@ -31,12 +31,11 @@ use workdir::LocalWorkdirSession;
|
||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
||||
|
||||
use crate::compact::usage_tracker::UsageTracker;
|
||||
use crate::fs_view::ReadRequirement;
|
||||
#[cfg(test)]
|
||||
use crate::fs_view::slice_lines;
|
||||
use crate::session_capture::{
|
||||
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionCapture, ToolPart,
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
use crate::fs_view::ReadRequirement;
|
||||
|
||||
/// Aggregated output of a compact worker run.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
@@ -91,248 +90,19 @@ struct SummaryParams {
|
||||
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 \
|
||||
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. \
|
||||
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded. \
|
||||
Paths must be absolute.";
|
||||
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded.";
|
||||
|
||||
const REFERENCE_DESCRIPTION: &str = "Record a file path as a named reference in the compacted \
|
||||
context without injecting its contents. Use for files that are contextually relevant but \
|
||||
whose current content the next session can fetch on demand.";
|
||||
const REFERENCE_DESCRIPTION: &str = "Record a Workdir-relative file path as a named reference in \
|
||||
the compacted context without injecting its contents. Use for files that are contextually \
|
||||
relevant but whose current content the next session can fetch on demand.";
|
||||
|
||||
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 \
|
||||
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 {
|
||||
session: WorkdirSessionHandle,
|
||||
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 {
|
||||
let view = SessionCapture::new("compact-target", (*items).clone());
|
||||
let state = Arc::new(SessionLogToolState { items, view });
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SearchSessionParams);
|
||||
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)
|
||||
})
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CompactionOutputFeature {
|
||||
session: Option<WorkdirSessionHandle>,
|
||||
tracker: tools::Tracker,
|
||||
context: Arc<Mutex<CompactWorkerContext>>,
|
||||
}
|
||||
|
||||
pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
||||
let view = SessionCapture::new("compact-target", (*items).clone());
|
||||
let state = Arc::new(SessionLogToolState { items, view });
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(ReadSessionParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("read_session_items")
|
||||
.description(READ_SESSION_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
|
||||
state: state.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
impl CompactionOutputFeature {
|
||||
pub(crate) fn new(
|
||||
session: Option<WorkdirSessionHandle>,
|
||||
tracker: tools::Tracker,
|
||||
context: Arc<Mutex<CompactWorkerContext>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session,
|
||||
tracker,
|
||||
context,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -810,53 +607,4 @@ mod tests {
|
||||
assert_eq!(guard.references.len(), 1);
|
||||
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());
|
||||
// Also hand the raw broadcast sender so Worker-internal operations
|
||||
// can emit typed lifecycle `Event`s (currently: compact progress).
|
||||
worker.attach_internal_worker_registry(spawned_registry.clone());
|
||||
worker.attach_event_tx(event_tx.clone());
|
||||
|
||||
// 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);
|
||||
|
||||
// === 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(
|
||||
&mut worker,
|
||||
bash_output_dir,
|
||||
@@ -873,6 +873,14 @@ where
|
||||
}
|
||||
|
||||
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 {
|
||||
feature_registry.add_module(task_feature);
|
||||
}
|
||||
|
||||
+592
-83
@@ -12,7 +12,7 @@
|
||||
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
||||
|
||||
use std::any::{Any, type_name};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -447,6 +447,16 @@ impl ServiceVersionReq {
|
||||
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
|
||||
@@ -1430,30 +1440,34 @@ impl FeatureInstallContext<'_> {
|
||||
pub struct FeatureRegistryInstallReport {
|
||||
pub reports: Vec<FeatureInstallReport>,
|
||||
pub services: FeatureServiceRegistry,
|
||||
pub plan_error: Option<FeaturePlanError>,
|
||||
}
|
||||
|
||||
impl FeatureRegistryInstallReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.reports.iter().any(|report| {
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn error_message(&self) -> String {
|
||||
self.reports
|
||||
.iter()
|
||||
.flat_map(|report| {
|
||||
self.plan_error.is_some()
|
||||
|| self.reports.iter().any(|report| {
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
|
||||
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
pub fn error_message(&self) -> String {
|
||||
let mut errors = self
|
||||
.plan_error
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
errors.extend(self.reports.iter().flat_map(|report| {
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
|
||||
}));
|
||||
errors.join("; ")
|
||||
}
|
||||
|
||||
pub fn installed_tool_names(&self) -> Vec<String> {
|
||||
@@ -1485,12 +1499,267 @@ pub fn dedupe_instruction_contributions(
|
||||
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.
|
||||
#[derive(Default)]
|
||||
pub struct FeatureRegistryBuilder {
|
||||
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 requirement.required {
|
||||
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 {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -1523,6 +1792,10 @@ impl FeatureRegistryBuilder {
|
||||
.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.
|
||||
pub(crate) fn install_into_engine<C: LlmClient>(
|
||||
self,
|
||||
@@ -1568,53 +1841,52 @@ impl FeatureRegistryBuilder {
|
||||
.iter()
|
||||
.map(|module| module.descriptor())
|
||||
.collect();
|
||||
let mut service_registry = FeatureServiceRegistry::default();
|
||||
let mut reports = Vec::with_capacity(self.modules.len());
|
||||
let mut seen_features = HashSet::new();
|
||||
|
||||
let mut pending_modules: Vec<_> = self.modules.into_iter().zip(descriptors).collect();
|
||||
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()
|
||||
.position(|(_, descriptor)| {
|
||||
descriptor
|
||||
.requires_services
|
||||
.iter()
|
||||
.filter(|requirement| requirement.required)
|
||||
.all(|requirement| declared_services.contains(&requirement.id))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let entry = pending_modules.remove(next);
|
||||
declared_services.extend(
|
||||
entry
|
||||
.1
|
||||
.provides_services
|
||||
let plan = match build_feature_install_plan(&descriptors) {
|
||||
Ok(plan) => plan,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
let reports = descriptors
|
||||
.iter()
|
||||
.map(|service| service.id.clone()),
|
||||
);
|
||||
ordered_modules.push(entry);
|
||||
}
|
||||
.map(|descriptor| {
|
||||
let mut report = FeatureInstallReport::new(descriptor);
|
||||
report
|
||||
.diagnostics
|
||||
.push(FeatureDiagnostic::error(message.clone()));
|
||||
report.mark_skipped(
|
||||
FeatureContributionKind::Diagnostic,
|
||||
descriptor.id.to_string(),
|
||||
"feature installation plan rejected before installation",
|
||||
);
|
||||
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 {
|
||||
let declarations = FeatureContributionDeclarations::from_descriptor(&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;
|
||||
for requirement in descriptor.requires_services.iter().cloned() {
|
||||
if service_registry.provides(&requirement.id) {
|
||||
@@ -1681,6 +1953,7 @@ impl FeatureRegistryBuilder {
|
||||
FeatureRegistryInstallReport {
|
||||
reports,
|
||||
services: service_registry,
|
||||
plan_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1821,6 +2094,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 {
|
||||
FeatureInstructionDeclaration::new(
|
||||
FeatureInstructionId::builtin(id),
|
||||
@@ -1830,6 +2156,207 @@ mod tests {
|
||||
.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_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]
|
||||
fn descriptor_contributions_are_recorded() {
|
||||
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
||||
@@ -2382,10 +2909,6 @@ mod tests {
|
||||
);
|
||||
let consumer = FeatureDescriptor::builtin("consumer", "Consumer")
|
||||
.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 = FeatureDescriptor::builtin("optional", "Optional").with_service_requirement(
|
||||
ServiceRequirement::optional(optional_service, "nice to have"),
|
||||
@@ -2399,9 +2922,6 @@ mod tests {
|
||||
.with_module(ServiceFeature {
|
||||
descriptor: consumer,
|
||||
})
|
||||
.with_module(ServiceFeature {
|
||||
descriptor: missing,
|
||||
})
|
||||
.with_module(ServiceFeature {
|
||||
descriptor: optional,
|
||||
})
|
||||
@@ -2413,18 +2933,6 @@ mod tests {
|
||||
report.reports[1].resolved_service_requirements[0].id,
|
||||
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
|
||||
.reports
|
||||
.iter()
|
||||
@@ -2543,11 +3051,12 @@ mod tests {
|
||||
.with_module(ServiceFeature { descriptor })
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
assert!(report.has_errors());
|
||||
assert!(
|
||||
report
|
||||
.error_message()
|
||||
.contains("required service requirement")
|
||||
);
|
||||
assert!(matches!(
|
||||
report.plan_error,
|
||||
Some(FeaturePlanError::MissingServiceProvider { .. })
|
||||
));
|
||||
assert!(report.error_message().contains("builtin:consumer"));
|
||||
assert!(report.error_message().contains("builtin:missing-service"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -560,6 +560,7 @@ where
|
||||
attempt.instance_id, attempt.checked_state_revision
|
||||
)),
|
||||
max_turns: Some(12),
|
||||
engine_configurator: None,
|
||||
features,
|
||||
required_tools: &[
|
||||
"ShowOverview",
|
||||
@@ -571,6 +572,7 @@ where
|
||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||
filesystem: WorkerFilesystemAuthority::None,
|
||||
scope: Scope::empty(),
|
||||
workdir_session: None,
|
||||
},
|
||||
};
|
||||
match run_internal_worker(spec).await {
|
||||
|
||||
@@ -398,24 +398,35 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
workdir_output(format!("Listed {count} Workdir(s)"), &response)
|
||||
}
|
||||
|
||||
fn create(&self, input: WorkdirCreateInput) -> Result<ToolOutput, ToolError> {
|
||||
let runtime_id = validate_identity(&input.runtime_id, CREATE_TOOL, "runtime_id")?;
|
||||
fn create(
|
||||
&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 selector = validate_optional_selector(input.selector)?;
|
||||
let workspace_id = encode_path_segment(self.workspace_id()?);
|
||||
let runtime_path = encode_path_segment(runtime_id);
|
||||
let request = WorkdirCreateRequest {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
runtime_id: runtime_id.map(str::to_string),
|
||||
repository_id: repository_id.to_string(),
|
||||
selector,
|
||||
operation_id,
|
||||
};
|
||||
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json(
|
||||
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)?,
|
||||
))?;
|
||||
workdir_output(
|
||||
format!("Created Workdir {}", response.item.working_directory_id),
|
||||
format!(
|
||||
"Created Workdir {} on Runtime {}",
|
||||
response.item.working_directory_id, response.runtime_id
|
||||
),
|
||||
&response,
|
||||
)
|
||||
}
|
||||
@@ -518,16 +529,17 @@ impl Tool for WorkspaceHttpWorkdirTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
match self.operation {
|
||||
WorkdirOperation::List => {
|
||||
let _input = parse_input::<WorkdirListInput>(input_json)?;
|
||||
self.backend.list()
|
||||
}
|
||||
WorkdirOperation::Create => self
|
||||
.backend
|
||||
.create(parse_input::<WorkdirCreateInput>(input_json)?),
|
||||
WorkdirOperation::Create => self.backend.create(
|
||||
parse_input::<WorkdirCreateInput>(input_json)?,
|
||||
ctx.call_id.to_string(),
|
||||
),
|
||||
WorkdirOperation::Attach => self
|
||||
.backend
|
||||
.attach(parse_input::<WorkdirAttachInput>(input_json)?),
|
||||
@@ -635,9 +647,9 @@ fn create_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["runtime_id", "repository_id"],
|
||||
"required": ["repository_id"],
|
||||
"properties": {
|
||||
"runtime_id": {"type": "string", "minLength": 1},
|
||||
"runtime_id": {"type": ["string", "null"], "minLength": 1},
|
||||
"repository_id": {"type": "string", "minLength": 1},
|
||||
"selector": {"type": ["string", "null"], "minLength": 1}
|
||||
}
|
||||
@@ -677,7 +689,8 @@ struct WorkdirListInput {}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkdirCreateInput {
|
||||
runtime_id: String,
|
||||
#[serde(default)]
|
||||
runtime_id: Option<String>,
|
||||
repository_id: String,
|
||||
#[serde(default)]
|
||||
selector: Option<String>,
|
||||
@@ -685,10 +698,12 @@ struct WorkdirCreateInput {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorkdirCreateRequest {
|
||||
runtime_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
runtime_id: Option<String>,
|
||||
repository_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
selector: Option<String>,
|
||||
operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -916,7 +931,11 @@ mod tests {
|
||||
#[test]
|
||||
fn schemas_expose_identities_without_paths_or_session_handles() {
|
||||
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("session_id").is_none());
|
||||
assert_eq!(attach_schema()["required"], json!(["workdir_id"]));
|
||||
@@ -946,6 +965,7 @@ mod tests {
|
||||
})),
|
||||
response(json!({
|
||||
"workspace_id": "workspace/test",
|
||||
"runtime_id": "runtime/one",
|
||||
"item": workdir_json("wd-created"),
|
||||
"diagnostics": []
|
||||
})),
|
||||
@@ -961,6 +981,7 @@ mod tests {
|
||||
})),
|
||||
response(json!({
|
||||
"workspace_id": "workspace/test",
|
||||
"runtime_id": "runtime/one",
|
||||
"item": {
|
||||
"working_directory_id": "wd-created",
|
||||
"repository_id": "main",
|
||||
@@ -985,13 +1006,19 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
let created = backend
|
||||
.create(WorkdirCreateInput {
|
||||
runtime_id: "runtime/one".to_string(),
|
||||
repository_id: "main".to_string(),
|
||||
selector: Some("refs/heads/topic".to_string()),
|
||||
})
|
||||
.create(
|
||||
WorkdirCreateInput {
|
||||
runtime_id: Some("runtime/one".to_string()),
|
||||
repository_id: "main".to_string(),
|
||||
selector: Some("refs/heads/topic".to_string()),
|
||||
},
|
||||
"call-create-1".to_string(),
|
||||
)
|
||||
.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 =
|
||||
serde_json::from_str(created.content.as_deref().unwrap()).unwrap();
|
||||
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[1].path,
|
||||
"/api/w/workspace%2Ftest/runtimes/runtime%2Fone/working-directories"
|
||||
"/api/w/workspace%2Ftest/working-directories"
|
||||
);
|
||||
assert_eq!(requests[1].method, WorkspaceRequestMethod::Post);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
|
||||
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!(
|
||||
requests[2].path,
|
||||
@@ -1216,16 +1245,50 @@ mod tests {
|
||||
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]
|
||||
fn invalid_or_extra_inputs_are_rejected_before_workspace_request() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
|
||||
let error = backend
|
||||
.create(WorkdirCreateInput {
|
||||
runtime_id: " ".to_string(),
|
||||
repository_id: "main".to_string(),
|
||||
selector: None,
|
||||
})
|
||||
.create(
|
||||
WorkdirCreateInput {
|
||||
runtime_id: Some(" ".to_string()),
|
||||
repository_id: "main".to_string(),
|
||||
selector: None,
|
||||
},
|
||||
"call-invalid".to_string(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||
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 {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||
@@ -1046,6 +1092,42 @@ mod tests {
|
||||
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]
|
||||
fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -873,12 +873,13 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?),
|
||||
)
|
||||
.map(TicketBackendOperationResult::Ticket),
|
||||
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
|
||||
TicketBackendOperation::QueueReady { id, .. } => Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.map(TicketBackendOperationResult::QueueOutcome),
|
||||
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
@@ -1099,16 +1100,18 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
||||
)
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
||||
match self.invoke(TicketBackendOperation::QueueReady {
|
||||
id,
|
||||
queued_by: queued_by.to_string(),
|
||||
})? {
|
||||
TicketBackendOperationResult::Unit => Ok(()),
|
||||
other => Err(TicketError::Conflict(format!(
|
||||
"unexpected ticket backend response: {other:?}"
|
||||
))),
|
||||
}
|
||||
fn queue_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
queued_by: &str,
|
||||
) -> TicketResult<ticket::TicketQueueOutcome> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::QueueReady {
|
||||
id,
|
||||
queued_by: queued_by.to_string(),
|
||||
}),
|
||||
TicketBackendOperationResult::QueueOutcome
|
||||
)
|
||||
}
|
||||
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
||||
|
||||
@@ -43,6 +43,8 @@ pub(crate) struct InternalWorkerAuthority {
|
||||
pub workspace: WorkerWorkspaceContext,
|
||||
pub filesystem: WorkerFilesystemAuthority,
|
||||
pub scope: Scope,
|
||||
/// Provider-bound session inherited in an attenuated form from the owner.
|
||||
pub workdir_session: Option<workdir::WorkdirSessionHandle>,
|
||||
}
|
||||
|
||||
pub(crate) struct InternalWorkerSpec {
|
||||
@@ -53,6 +55,7 @@ pub(crate) struct InternalWorkerSpec {
|
||||
pub input: String,
|
||||
pub cache_key: Option<String>,
|
||||
pub max_turns: Option<u32>,
|
||||
pub engine_configurator: Option<Box<dyn FnOnce(&mut Engine<Box<dyn LlmClient>>) + Send>>,
|
||||
pub features: FeatureRegistryBuilder,
|
||||
pub required_tools: &'static [&'static str],
|
||||
pub authority: InternalWorkerAuthority,
|
||||
@@ -102,6 +105,7 @@ where
|
||||
input,
|
||||
cache_key,
|
||||
max_turns,
|
||||
engine_configurator,
|
||||
features,
|
||||
required_tools,
|
||||
authority,
|
||||
@@ -128,7 +132,11 @@ where
|
||||
});
|
||||
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,
|
||||
@@ -144,6 +152,9 @@ where
|
||||
identity: identity.clone(),
|
||||
history_entries: 0,
|
||||
})?;
|
||||
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();
|
||||
@@ -250,7 +261,6 @@ impl InternalWorkerSessionStatus {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum InternalWorkerSessionError {
|
||||
#[cfg(test)]
|
||||
#[error("failed to build internal Worker session: {message}")]
|
||||
Build { message: String },
|
||||
#[error("internal Worker session is busy")]
|
||||
@@ -410,7 +420,6 @@ impl InternalWorkerSessionHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus {
|
||||
loop {
|
||||
let notified = self.state_changed.notified();
|
||||
@@ -475,6 +484,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
input,
|
||||
cache_key,
|
||||
max_turns,
|
||||
engine_configurator,
|
||||
features,
|
||||
required_tools,
|
||||
authority,
|
||||
@@ -492,7 +502,11 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
});
|
||||
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,
|
||||
@@ -505,6 +519,9 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
.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
|
||||
@@ -539,6 +556,102 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
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::new(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>) {
|
||||
let (_, mut log_rx) = sink.subscribe_with_snapshot();
|
||||
tokio::spawn(async move {
|
||||
@@ -1076,12 +1189,14 @@ permission = "write"
|
||||
input: "input".to_string(),
|
||||
cache_key: Some("internal-test".to_string()),
|
||||
max_turns: Some(1),
|
||||
engine_configurator: None,
|
||||
features: FeatureRegistryBuilder::new(),
|
||||
required_tools,
|
||||
authority: InternalWorkerAuthority {
|
||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||
filesystem: WorkerFilesystemAuthority::None,
|
||||
scope: Scope::empty(),
|
||||
workdir_session: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ pub(crate) struct SearchHit {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum ReadSelector<'a> {
|
||||
Id(&'a str),
|
||||
#[cfg(test)]
|
||||
EntryRange([u64; 2]),
|
||||
}
|
||||
|
||||
@@ -418,6 +419,7 @@ impl SessionCapture {
|
||||
.iter()
|
||||
.filter(|entry| entry.id.as_str() == id)
|
||||
.collect(),
|
||||
#[cfg(test)]
|
||||
ReadSelector::EntryRange([start, end]) => self
|
||||
.index
|
||||
.iter()
|
||||
|
||||
@@ -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
|
||||
//! worker-observation use the same in-memory authority. Internal children are not persisted, restored, discovered as
|
||||
//! `SubWorkerSpawn` inserts controllable SubWorker handles, while host services such 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
|
||||
//! 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
|
||||
@@ -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 {
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
worker_name: String,
|
||||
@@ -214,6 +261,7 @@ impl Drop for InternalSpawnReservation {
|
||||
|
||||
pub struct SpawnedWorkerRegistry {
|
||||
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
|
||||
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
|
||||
internal_names: std::sync::Mutex<HashSet<String>>,
|
||||
parent_scope: Option<SharedScope>,
|
||||
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
||||
@@ -226,10 +274,21 @@ pub struct SpawnedWorkerRegistryLoad {
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn new(_runtime_dir: Arc<RuntimeDir>) -> 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),
|
||||
@@ -239,6 +298,7 @@ impl SpawnedWorkerRegistry {
|
||||
pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> 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: Some(parent_scope),
|
||||
parent_protocol: Mutex::new(None),
|
||||
@@ -317,6 +377,7 @@ impl SpawnedWorkerRegistry {
|
||||
Ok(SpawnedWorkerRegistryLoad {
|
||||
registry: 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,
|
||||
parent_protocol: Mutex::new(None),
|
||||
@@ -356,6 +417,144 @@ impl SpawnedWorkerRegistry {
|
||||
for record in self.internal_records.lock().unwrap().clone() {
|
||||
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) {
|
||||
@@ -427,28 +626,37 @@ impl SpawnedWorkerRegistry {
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|(_, id)| id.clone());
|
||||
self.internal_records
|
||||
let mut snapshots = self
|
||||
.internal_records
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient)
|
||||
.map(|record| {
|
||||
let snapshot = record.session.protocol_snapshot();
|
||||
InternalWorkerSnapshot {
|
||||
worker: record.protocol_ref(parent_session_id.clone()),
|
||||
revision: record.protocol_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,
|
||||
}
|
||||
internal_worker_snapshot(
|
||||
record.protocol_ref(parent_session_id.clone()),
|
||||
record.protocol_revision(),
|
||||
&record.session,
|
||||
)
|
||||
})
|
||||
.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> {
|
||||
@@ -567,6 +775,47 @@ impl SpawnedWorkerRegistry {
|
||||
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 {
|
||||
@@ -813,6 +1062,63 @@ mod tests {
|
||||
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]
|
||||
async fn service_private_internal_output_is_never_disclosed() {
|
||||
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(),
|
||||
));
|
||||
}
|
||||
if input
|
||||
if !input
|
||||
.scope
|
||||
.iter()
|
||||
.any(|rule| matches!(rule.permission, PermissionInput::Write))
|
||||
{
|
||||
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(())
|
||||
@@ -1008,28 +1008,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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!({
|
||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||
"scope":[{"target":"work","permission":"read"}],
|
||||
"scope":[{"target":"work","permission":"write"}],
|
||||
"review":{"ticket_id":"T1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&valid).is_ok());
|
||||
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
"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"}],
|
||||
"review":{"ticket_id":"T1"}
|
||||
}))
|
||||
.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 {
|
||||
@@ -1079,7 +1079,7 @@ extract_threshold = 4000
|
||||
}
|
||||
|
||||
#[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 workspace_root = runtime.path().join("project");
|
||||
let available_profiles = write_project_profile_registry(
|
||||
@@ -1140,7 +1140,7 @@ extract_threshold = 4000
|
||||
"task": "review immutable commit",
|
||||
"scope": [{
|
||||
"target": ".",
|
||||
"permission": "read",
|
||||
"permission": "write",
|
||||
"recursive": true
|
||||
}]
|
||||
});
|
||||
@@ -1171,11 +1171,10 @@ extract_threshold = 4000
|
||||
let record = registry
|
||||
.get_internal("reviewer-child")
|
||||
.expect("Internal reviewer registry record");
|
||||
assert!(record.installed_tools.iter().any(|name| name == "Read"));
|
||||
for denied in ["Write", "Edit", "Bash"] {
|
||||
for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
|
||||
assert!(
|
||||
!record.installed_tools.iter().any(|name| name == denied),
|
||||
"read-only child unexpectedly received {denied}: {:?}",
|
||||
record.installed_tools.iter().any(|name| name == required),
|
||||
"write-scoped child is missing {required}: {:?}",
|
||||
record.installed_tools
|
||||
);
|
||||
}
|
||||
|
||||
+428
-135
@@ -46,12 +46,11 @@ use crate::hook::{
|
||||
};
|
||||
use crate::in_flight::InFlightEvents;
|
||||
use crate::internal_worker::{
|
||||
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, run_internal_worker,
|
||||
run_internal_worker_with_cancel_sender,
|
||||
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, InternalWorkerVisibility,
|
||||
prepare_internal_worker_from_spec, run_internal_worker, run_internal_worker_with_cancel_sender,
|
||||
};
|
||||
|
||||
const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction";
|
||||
const COMPACTION_BLOCK_ID: &str = "compact";
|
||||
const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration";
|
||||
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration";
|
||||
|
||||
@@ -76,7 +75,8 @@ use crate::skill::{SkillActivationResponse, SkillClientError};
|
||||
#[cfg(test)]
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment,
|
||||
AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, Event, RewindSummary,
|
||||
RewindTarget, RewindTargetId, Segment,
|
||||
};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::broadcast;
|
||||
@@ -781,9 +781,21 @@ struct EmptyTurnRollbackSnapshot {
|
||||
usage_history_len: usize,
|
||||
ai_activity_count: usize,
|
||||
last_run_interrupted: bool,
|
||||
active_run_turn_count: Option<usize>,
|
||||
flow_runtime_state: Option<flow::FlowRuntimeState>,
|
||||
}
|
||||
|
||||
fn active_run_checkpoint_entry(
|
||||
active_run_turn_count: Option<usize>,
|
||||
total_turn_count: usize,
|
||||
) -> Option<LogEntry> {
|
||||
active_run_turn_count.map(|active_turn_count| LogEntry::ActiveRunCheckpoint {
|
||||
ts: segment_log::now_millis(),
|
||||
active_turn_count,
|
||||
total_turn_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_ai_materialized_item(item: &Item) -> bool {
|
||||
match item {
|
||||
Item::Message { role, .. } => *role == Role::Assistant,
|
||||
@@ -971,6 +983,9 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
/// notifications, events sent here are NOT replayed to clients that
|
||||
/// connect after the fact — they are fire-and-forget broadcasts.
|
||||
event_tx: Option<broadcast::Sender<Event>>,
|
||||
/// Parent-owned projection/control boundary for observable Internal service Workers.
|
||||
/// Service Workers are never exposed through the model-facing SubWorker control surface.
|
||||
internal_worker_registry: Option<Arc<crate::spawn::registry::SpawnedWorkerRegistry>>,
|
||||
in_flight: Option<InFlightEvents>,
|
||||
/// Monotonic counter incremented by worker event bridges when an
|
||||
/// assistant-side execution artifact becomes visible to clients before
|
||||
@@ -1115,6 +1130,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
|
||||
feature_instructions: self.feature_instructions.clone(),
|
||||
alerter: self.alerter.clone(),
|
||||
event_tx: self.event_tx.clone(),
|
||||
internal_worker_registry: self.internal_worker_registry.clone(),
|
||||
in_flight: self.in_flight.clone(),
|
||||
ai_activity_counter: self.ai_activity_counter.clone(),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -1315,6 +1331,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
feature_instructions: Vec::new(),
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -1772,6 +1789,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
self.engine_mut().set_turn_count(state.turn_count);
|
||||
self.engine_mut()
|
||||
.set_last_run_interrupted(state.last_run_interrupted);
|
||||
self.engine_mut()
|
||||
.set_active_run_turn_count(state.active_run_turn_count);
|
||||
self.user_segments = state.user_segments;
|
||||
*self.usage_history.lock().expect("usage_history poisoned") = state.usage_history;
|
||||
*self
|
||||
@@ -1957,9 +1976,21 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
/// Worker-internal operations (currently: compaction) can surface
|
||||
/// progress to connected clients.
|
||||
pub fn attach_event_tx(&mut self, event_tx: broadcast::Sender<Event>) {
|
||||
let session_id = self.session_id().to_string();
|
||||
let registry = self.internal_worker_registry.get_or_insert_with(
|
||||
crate::spawn::registry::SpawnedWorkerRegistry::new_for_internal_services,
|
||||
);
|
||||
registry.attach_parent_protocol(event_tx.clone(), session_id);
|
||||
self.event_tx = Some(event_tx);
|
||||
}
|
||||
|
||||
pub(crate) fn attach_internal_worker_registry(
|
||||
&mut self,
|
||||
registry: Arc<crate::spawn::registry::SpawnedWorkerRegistry>,
|
||||
) {
|
||||
self.internal_worker_registry = Some(registry);
|
||||
}
|
||||
|
||||
/// Shared activity counter incremented by worker event bridges when any
|
||||
/// assistant-side output is surfaced before history persistence.
|
||||
pub fn ai_activity_counter(&self) -> Arc<AtomicUsize> {
|
||||
@@ -2333,6 +2364,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
usage_history_len,
|
||||
ai_activity_count: self.ai_activity_counter.load(Ordering::SeqCst),
|
||||
last_run_interrupted: self.engine().last_run_interrupted(),
|
||||
active_run_turn_count: self.engine().active_run_turn_count(),
|
||||
flow_runtime_state: self
|
||||
.flow_runtime_state
|
||||
.lock()
|
||||
@@ -2364,6 +2396,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
self.engine_mut().truncate_history(snapshot.history_len);
|
||||
self.engine_mut()
|
||||
.set_last_run_interrupted(snapshot.last_run_interrupted);
|
||||
self.engine_mut()
|
||||
.set_active_run_turn_count(snapshot.active_run_turn_count);
|
||||
*self
|
||||
.flow_runtime_state
|
||||
.lock()
|
||||
@@ -2524,9 +2558,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
// `last_run_interrupted` flag; `Worker::resume` reuses the prior
|
||||
// context via a different entry point and never triggers this
|
||||
// path.
|
||||
if self.engine.as_ref().unwrap().last_run_interrupted() {
|
||||
self.apply_interrupt_prep()?;
|
||||
}
|
||||
self.prepare_interrupted_history_for_fresh_run()?;
|
||||
|
||||
self.prepare_for_run().await?;
|
||||
|
||||
@@ -2637,6 +2669,19 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Close interrupted history before a fresh user/notification run.
|
||||
///
|
||||
/// Clearing the interrupted flag also ends the old logical-run budget and
|
||||
/// must happen before `prepare_for_run`: proactive compaction checkpoints
|
||||
/// only resumable runs, never the run this invocation is abandoning.
|
||||
fn prepare_interrupted_history_for_fresh_run(&mut self) -> Result<(), WorkerError> {
|
||||
if self.engine().last_run_interrupted() {
|
||||
self.apply_interrupt_prep()?;
|
||||
self.engine_mut().set_last_run_interrupted(false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage the post-interruption cleanup at the front of worker
|
||||
/// history: close every unanswered `Item::ToolCall` with a synthetic
|
||||
/// `Item::ToolResult` (Anthropic wire-validity), then append a
|
||||
@@ -2752,12 +2797,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
),
|
||||
"run_for_notification expects a non-UserSend InvokeKind; got {kind:?}"
|
||||
);
|
||||
// This is a fresh Invoke, not an explicit resume of the interrupted
|
||||
// turn. Close any dangling tool calls before an auto-run notification
|
||||
// can enter `Engine::resume` and execute them again after a crash.
|
||||
if self.engine.as_ref().unwrap().last_run_interrupted() {
|
||||
self.apply_interrupt_prep()?;
|
||||
}
|
||||
self.prepare_interrupted_history_for_fresh_run()?;
|
||||
self.prepare_for_run().await?;
|
||||
|
||||
// IDLE → active marker for the buffered notification / worker-event
|
||||
@@ -2849,15 +2889,23 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}),
|
||||
compacted_from: None,
|
||||
};
|
||||
let mut initial_entries = vec![entry.clone()];
|
||||
if let Some(checkpoint) =
|
||||
active_run_checkpoint_entry(w.active_run_turn_count(), w.turn_count())
|
||||
{
|
||||
initial_entries.push(checkpoint);
|
||||
}
|
||||
self.store
|
||||
.create_segment(loc.session_id, fork_segment_id, &[entry.clone()])
|
||||
.create_segment(loc.session_id, fork_segment_id, &initial_entries)
|
||||
.map_err(WorkerError::from)?;
|
||||
self.segment_state.set_location(SegmentLocation {
|
||||
session_id: loc.session_id,
|
||||
segment_id: fork_segment_id,
|
||||
});
|
||||
self.segment_state.set_entries_written(1);
|
||||
self.sink.reset_with_initial(entry);
|
||||
self.segment_state
|
||||
.set_entries_written(initial_entries.len());
|
||||
self.sink
|
||||
.reset_with_initial_entries(initial_entries.clone());
|
||||
if self.scope_allocation.is_some() {
|
||||
worker_allocation::update_segment(&self.manifest.worker.name, fork_segment_id)?;
|
||||
}
|
||||
@@ -2895,52 +2943,46 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
.map_err(WorkerError::Engine)
|
||||
}
|
||||
|
||||
fn persist_compaction_block(
|
||||
fn persist_compaction_lifecycle(
|
||||
&mut self,
|
||||
state: &str,
|
||||
message: &str,
|
||||
error: Option<&str>,
|
||||
new_segment_id: Option<SegmentId>,
|
||||
lifecycle: &CompactionLifecycle,
|
||||
) -> Result<(), WorkerError> {
|
||||
let payload = serde_json::json!({
|
||||
"kind": "compaction_block",
|
||||
"schema_version": 1,
|
||||
"block_id": COMPACTION_BLOCK_ID,
|
||||
"state": state,
|
||||
"message": message,
|
||||
"error": error,
|
||||
"new_segment_id": new_segment_id.map(|id| id.to_string()),
|
||||
});
|
||||
Ok(self.commit_entry(LogEntry::Extension {
|
||||
ts: segment_log::now_millis(),
|
||||
domain: COMPACTION_EXTENSION_DOMAIN.into(),
|
||||
payload,
|
||||
payload: serde_json::to_value(lifecycle).map_err(|error| {
|
||||
WorkerError::InvalidState(format!(
|
||||
"serialize compaction lifecycle {}: {error}",
|
||||
lifecycle.compaction_id
|
||||
))
|
||||
})?,
|
||||
})?)
|
||||
}
|
||||
|
||||
fn persist_and_send_compact_start(&mut self) -> Result<(), WorkerError> {
|
||||
self.persist_compaction_block("running", "Compacting…", None, None)?;
|
||||
self.send_event(Event::CompactStart);
|
||||
fn persist_and_send_compact_start(
|
||||
&mut self,
|
||||
lifecycle: CompactionLifecycle,
|
||||
) -> Result<(), WorkerError> {
|
||||
self.persist_compaction_lifecycle(&lifecycle)?;
|
||||
self.send_event(Event::CompactStart { lifecycle });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_and_send_compact_done(
|
||||
&mut self,
|
||||
new_segment_id: SegmentId,
|
||||
lifecycle: CompactionLifecycle,
|
||||
) -> Result<(), WorkerError> {
|
||||
self.persist_compaction_block("done", "Compacted.", None, Some(new_segment_id))?;
|
||||
self.send_event(Event::CompactDone { new_segment_id });
|
||||
self.persist_compaction_lifecycle(&lifecycle)?;
|
||||
self.send_event(Event::CompactDone { lifecycle });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_and_send_compact_failed(&mut self, error: String) -> Result<(), WorkerError> {
|
||||
self.persist_compaction_block(
|
||||
"failed",
|
||||
&format!("Compact failed: {error}"),
|
||||
Some(error.as_str()),
|
||||
None,
|
||||
)?;
|
||||
self.send_event(Event::CompactFailed { error });
|
||||
fn persist_and_send_compact_failed(
|
||||
&mut self,
|
||||
lifecycle: CompactionLifecycle,
|
||||
) -> Result<(), WorkerError> {
|
||||
self.persist_compaction_lifecycle(&lifecycle)?;
|
||||
self.send_event(Event::CompactFailed { lifecycle });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2969,14 +3011,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
.map(|s| s.retained_tokens())
|
||||
.unwrap_or(manifest::defaults::COMPACT_RETAINED_TOKENS);
|
||||
|
||||
self.persist_and_send_compact_start()?;
|
||||
match self.compact(retained).await {
|
||||
Ok(new_segment_id) => {
|
||||
info!(
|
||||
new_segment_id = %new_segment_id,
|
||||
"Compaction succeeded, resuming execution"
|
||||
);
|
||||
self.persist_and_send_compact_done(new_segment_id)?;
|
||||
if let Some(ref state) = self.compact_state {
|
||||
state.record_compact_success();
|
||||
}
|
||||
@@ -2984,7 +3024,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Compaction failed during run");
|
||||
self.persist_and_send_compact_failed(e.to_string())?;
|
||||
self.alert(
|
||||
AlertLevel::Error,
|
||||
AlertSource::Compactor,
|
||||
@@ -3017,45 +3056,16 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
|
||||
let retained = state.retained_tokens();
|
||||
if let Err(err) = self.persist_and_send_compact_start() {
|
||||
warn!(error = %err, "failed to persist proactive compact start");
|
||||
self.alert(
|
||||
AlertLevel::Warn,
|
||||
AlertSource::Compactor,
|
||||
format!("pre-run compaction not started: failed to persist status block: {err}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
match self.compact(retained).await {
|
||||
Ok(new_segment_id) => {
|
||||
info!(
|
||||
new_segment_id = %new_segment_id,
|
||||
"Proactive pre-run compaction succeeded"
|
||||
);
|
||||
if let Err(err) = self.persist_and_send_compact_done(new_segment_id) {
|
||||
warn!(error = %err, "failed to persist proactive compact completion");
|
||||
self.alert(
|
||||
AlertLevel::Warn,
|
||||
AlertSource::Compactor,
|
||||
format!(
|
||||
"pre-run compaction completed but status block was not persisted: {err}"
|
||||
),
|
||||
);
|
||||
}
|
||||
state.record_compact_success();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Proactive pre-run compaction failed");
|
||||
if let Err(err) = self.persist_and_send_compact_failed(e.to_string()) {
|
||||
warn!(error = %err, "failed to persist proactive compact failure");
|
||||
self.alert(
|
||||
AlertLevel::Warn,
|
||||
AlertSource::Compactor,
|
||||
format!(
|
||||
"pre-run compaction failed and status block was not persisted: {err}"
|
||||
),
|
||||
);
|
||||
}
|
||||
self.alert(
|
||||
AlertLevel::Warn,
|
||||
AlertSource::Compactor,
|
||||
@@ -3113,11 +3123,9 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
|
||||
self.join_memory_task().await;
|
||||
self.persist_and_send_compact_start()?;
|
||||
match self.compact(retained).await {
|
||||
Ok(new_segment_id) => {
|
||||
info!(new_segment_id = %new_segment_id, "Manual compaction succeeded");
|
||||
self.persist_and_send_compact_done(new_segment_id)?;
|
||||
if let Some(ref state) = state {
|
||||
state.record_compact_success();
|
||||
}
|
||||
@@ -3125,7 +3133,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Manual compaction failed");
|
||||
self.persist_and_send_compact_failed(e.to_string())?;
|
||||
self.alert(
|
||||
AlertLevel::Error,
|
||||
AlertSource::Compactor,
|
||||
@@ -3242,12 +3249,14 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
|
||||
let interrupted = self.engine.as_ref().unwrap().last_run_interrupted();
|
||||
let active_run_turn_count = self.engine.as_ref().unwrap().active_run_turn_count();
|
||||
match result {
|
||||
Ok(r) => {
|
||||
self.commit_entry(LogEntry::RunCompleted {
|
||||
ts: segment_log::now_millis(),
|
||||
interrupted,
|
||||
result: r.clone(),
|
||||
active_run_turn_count,
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -3262,20 +3271,79 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compact the current session by summarising history via a
|
||||
/// disposable Engine, then replacing history with
|
||||
/// `[summary, ...recent_turns]` in a new Segment of the same Session.
|
||||
///
|
||||
/// The summary Engine uses:
|
||||
/// - `compaction.model` from the manifest if configured, or
|
||||
/// - a clone of the main LlmClient via `clone_boxed()`.
|
||||
///
|
||||
/// Returns the new Segment ID. The Worker keeps its Session ID.
|
||||
/// Runs one parent-owned observable compaction service and returns the new
|
||||
/// Segment ID. Lifecycle revisions are committed before they are broadcast.
|
||||
pub async fn compact(&mut self, retained_tokens: u64) -> Result<SegmentId, WorkerError> {
|
||||
let mut lifecycle = CompactionLifecycle {
|
||||
schema_version: 2,
|
||||
compaction_id: uuid::Uuid::now_v7().to_string(),
|
||||
revision: 1,
|
||||
internal_worker: None,
|
||||
state: CompactionLifecycleState::Running,
|
||||
started_at_ms: segment_log::now_millis(),
|
||||
ended_at_ms: None,
|
||||
summary: None,
|
||||
error: None,
|
||||
new_segment_id: None,
|
||||
};
|
||||
self.persist_and_send_compact_start(lifecycle.clone())?;
|
||||
match self.compact_impl(retained_tokens, &mut lifecycle).await {
|
||||
Ok((new_segment_id, summary)) => {
|
||||
lifecycle.revision = lifecycle.revision.saturating_add(1);
|
||||
lifecycle.state = CompactionLifecycleState::Done;
|
||||
lifecycle.ended_at_ms = Some(segment_log::now_millis());
|
||||
lifecycle.summary = Some(summary);
|
||||
lifecycle.new_segment_id = Some(new_segment_id.to_string());
|
||||
let terminal = self.persist_and_send_compact_done(lifecycle.clone());
|
||||
self.release_compaction_service(&lifecycle).await;
|
||||
terminal?;
|
||||
Ok(new_segment_id)
|
||||
}
|
||||
Err(error) => {
|
||||
lifecycle.revision = lifecycle.revision.saturating_add(1);
|
||||
lifecycle.state = if matches!(error, WorkerError::CompactCancelled) {
|
||||
CompactionLifecycleState::Interrupted
|
||||
} else {
|
||||
CompactionLifecycleState::Failed
|
||||
};
|
||||
lifecycle.ended_at_ms = Some(segment_log::now_millis());
|
||||
lifecycle.error = Some(error.to_string().chars().take(2_000).collect());
|
||||
let terminal = self.persist_and_send_compact_failed(lifecycle.clone());
|
||||
self.release_compaction_service(&lifecycle).await;
|
||||
terminal?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn release_compaction_service(&self, lifecycle: &CompactionLifecycle) {
|
||||
let Some(session_id) = lifecycle
|
||||
.internal_worker
|
||||
.as_ref()
|
||||
.map(|worker| worker.session_id.as_str())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(registry) = &self.internal_worker_registry else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = registry.stop_service(session_id).await {
|
||||
warn!(
|
||||
compaction_id = %lifecycle.compaction_id,
|
||||
internal_worker_session_id = %session_id,
|
||||
error = %error,
|
||||
"failed to release terminal compaction Internal Worker"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn compact_impl(
|
||||
&mut self,
|
||||
retained_tokens: u64,
|
||||
lifecycle: &mut CompactionLifecycle,
|
||||
) -> Result<(SegmentId, String), WorkerError> {
|
||||
use crate::compact::worker::{
|
||||
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
|
||||
mark_read_required_tool, read_session_items_tool, search_session_log_tool,
|
||||
write_summary_tool,
|
||||
CompactWorkerContext, CompactWorkerInterceptor, CompactionOutputFeature,
|
||||
};
|
||||
use crate::fs_view::WorkerFsView;
|
||||
|
||||
@@ -3385,10 +3453,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
auto_read_budget,
|
||||
)));
|
||||
|
||||
// Build an independent compact worker. It clones the main Worker's
|
||||
// provider handle, so compact-time reads use the same WorkdirSession instance.
|
||||
// No-workdir Workers deliberately omit compact-time filesystem tools.
|
||||
// Build a normal parent-owned Internal Worker over a pinned immutable
|
||||
// capture. Only SessionExplore and compaction output tools are installed.
|
||||
let workdir = self.workdir_session.clone();
|
||||
let read_only_workdir = workdir.clone().map(|session| {
|
||||
Arc::new(ReadOnlyWorkdirSession::new(session)) as workdir::WorkdirSessionHandle
|
||||
});
|
||||
let summary_tracker = tools::Tracker::new();
|
||||
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
||||
let summary_system_prompt = self
|
||||
@@ -3396,51 +3466,129 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
.load_full()
|
||||
.compact_system()
|
||||
.map_err(WorkerError::PromptCatalog)?;
|
||||
let mut summary_worker = Engine::new(summary_client).system_prompt(summary_system_prompt);
|
||||
summary_worker.set_cache_key(Some(self.segment_id().to_string()));
|
||||
|
||||
// Occupancy-based input-token meter + interceptor. The tracker pairs
|
||||
// each pre-request history length with the following UsageEvent, then
|
||||
// the interceptor projects current prompt occupancy with the same
|
||||
// UsageRecord counter used by the main Worker thresholds.
|
||||
let summary_usage_tracker = Arc::new(UsageTracker::new());
|
||||
{
|
||||
let tracker = summary_usage_tracker.clone();
|
||||
summary_worker.on_usage(move |event| {
|
||||
tracker.record_usage(event);
|
||||
});
|
||||
}
|
||||
let compactor_warning_cb = self.alerter.clone().map(|alerter| {
|
||||
Arc::new(move |message: String| {
|
||||
alerter.alert(AlertLevel::Warn, AlertSource::Compactor, message);
|
||||
}) as Arc<dyn Fn(String) + Send + Sync>
|
||||
});
|
||||
summary_worker.set_interceptor(CompactWorkerInterceptor::new(
|
||||
summary_usage_tracker,
|
||||
let interceptor = CompactWorkerInterceptor::new(
|
||||
summary_usage_tracker.clone(),
|
||||
worker_context_max_tokens,
|
||||
finish_warning_remaining_tokens,
|
||||
final_reserve_tokens,
|
||||
compactor_warning_cb,
|
||||
));
|
||||
summary_worker.set_max_turns(worker_max_turns);
|
||||
);
|
||||
let tracker_for_engine = summary_usage_tracker.clone();
|
||||
let features = crate::feature::FeatureRegistryBuilder::new()
|
||||
.with_module(
|
||||
crate::feature::builtin::session_explore::SessionExploreFeature::new(
|
||||
crate::feature::builtin::session_explore::SessionExploreState::new(
|
||||
crate::session_capture::SessionCapture::new(
|
||||
self.segment_id().to_string(),
|
||||
items_to_summarise.clone(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.with_module(CompactionOutputFeature::new(
|
||||
read_only_workdir.clone(),
|
||||
summary_tracker,
|
||||
ctx.clone(),
|
||||
));
|
||||
let required_tools: &'static [&'static str] = if read_only_workdir.is_some() {
|
||||
&[
|
||||
"ShowOverview",
|
||||
"SearchEntries",
|
||||
"ReadEntry",
|
||||
"Read",
|
||||
"mark_read_required",
|
||||
"add_reference",
|
||||
"write_summary",
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
"ShowOverview",
|
||||
"SearchEntries",
|
||||
"ReadEntry",
|
||||
"add_reference",
|
||||
"write_summary",
|
||||
]
|
||||
};
|
||||
let handle = prepare_internal_worker_from_spec(
|
||||
InternalWorkerSpec {
|
||||
identity: InternalWorkerIdentity {
|
||||
kind: "compaction",
|
||||
run_id: uuid::Uuid::parse_str(&lifecycle.compaction_id).map_err(|error| {
|
||||
WorkerError::InvalidState(format!("invalid compaction id: {error}"))
|
||||
})?,
|
||||
},
|
||||
manifest: self.manifest.clone(),
|
||||
client: summary_client,
|
||||
system_prompt: summary_system_prompt,
|
||||
input: summary_input.text.clone(),
|
||||
cache_key: Some(self.segment_id().to_string()),
|
||||
max_turns: worker_max_turns,
|
||||
engine_configurator: Some(Box::new(move |engine| {
|
||||
let tracker = tracker_for_engine;
|
||||
engine.on_usage(move |event| {
|
||||
tracker.record_usage(event);
|
||||
});
|
||||
engine.set_interceptor(interceptor);
|
||||
})),
|
||||
features,
|
||||
required_tools,
|
||||
authority: InternalWorkerAuthority {
|
||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||
filesystem: WorkerFilesystemAuthority::None,
|
||||
scope: Scope::empty(),
|
||||
workdir_session: read_only_workdir,
|
||||
},
|
||||
},
|
||||
InternalWorkerVisibility::ParentClient,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||
let registry = self
|
||||
.internal_worker_registry
|
||||
.get_or_insert_with(
|
||||
crate::spawn::registry::SpawnedWorkerRegistry::new_for_internal_services,
|
||||
)
|
||||
.clone();
|
||||
let internal_ref = registry
|
||||
.attach_service(crate::spawn::registry::InternalServiceWorkerRecord::new(
|
||||
"compaction",
|
||||
"Compaction",
|
||||
handle.clone(),
|
||||
))
|
||||
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||
lifecycle.revision = lifecycle.revision.saturating_add(1);
|
||||
lifecycle.internal_worker = Some(internal_ref);
|
||||
self.persist_and_send_compact_start(lifecycle.clone())?;
|
||||
|
||||
// Tools: read_file (shared scope, fresh tracker), bounded session
|
||||
// history exploration, and compact-specific tools that populate `ctx`.
|
||||
let compact_target_items = Arc::new(items_to_summarise.clone());
|
||||
if let Some(workdir) = workdir.clone() {
|
||||
summary_worker.register_tool(tools::read_tool(workdir.clone(), summary_tracker));
|
||||
summary_worker.register_tool(mark_read_required_tool(workdir, ctx.clone()));
|
||||
if let Err(error) = handle.send(summary_input.text).await {
|
||||
let _ = registry.remove_service(&handle.session_id_string());
|
||||
return Err(WorkerError::InvalidState(error.to_string()));
|
||||
}
|
||||
match handle.wait_until_idle().await {
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
|
||||
return Err(WorkerError::CompactCancelled);
|
||||
}
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Failed => {
|
||||
return Err(WorkerError::InvalidState(
|
||||
handle
|
||||
.protocol_snapshot()
|
||||
.error
|
||||
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
|
||||
));
|
||||
}
|
||||
status => {
|
||||
return Err(WorkerError::InvalidState(format!(
|
||||
"compactor Internal Worker ended in {status:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
summary_worker.register_tool(search_session_log_tool(compact_target_items.clone()));
|
||||
summary_worker.register_tool(read_session_items_tool(compact_target_items));
|
||||
summary_worker.register_tool(add_reference_tool(ctx.clone()));
|
||||
summary_worker.register_tool(write_summary_tool(ctx.clone()));
|
||||
|
||||
let out = summary_worker
|
||||
.run(summary_input.text)
|
||||
.await
|
||||
.map_err(WorkerError::Engine)?;
|
||||
let mut locked_engine = out.engine;
|
||||
|
||||
// Guard: nudge the worker once more if the expected outputs
|
||||
// (summary, and any auto-read nominations when default refs
|
||||
@@ -3469,10 +3617,24 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
};
|
||||
if let Some(prompt) = nudge {
|
||||
let _ = locked_engine
|
||||
.run(prompt)
|
||||
handle
|
||||
.send(prompt)
|
||||
.await
|
||||
.map_err(WorkerError::Engine)?;
|
||||
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||
match handle.wait_until_idle().await {
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
|
||||
return Err(WorkerError::CompactCancelled);
|
||||
}
|
||||
_ => {
|
||||
return Err(WorkerError::InvalidState(
|
||||
handle
|
||||
.protocol_snapshot()
|
||||
.error
|
||||
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
||||
@@ -3487,10 +3649,24 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
|
||||
same five sections but making it concise. Target ≈{summary_target_tokens} tokens."
|
||||
);
|
||||
let _ = locked_engine
|
||||
.run(prompt)
|
||||
handle
|
||||
.send(prompt)
|
||||
.await
|
||||
.map_err(WorkerError::Engine)?;
|
||||
.map_err(|error| WorkerError::InvalidState(error.to_string()))?;
|
||||
match handle.wait_until_idle().await {
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
|
||||
return Err(WorkerError::CompactCancelled);
|
||||
}
|
||||
_ => {
|
||||
return Err(WorkerError::InvalidState(
|
||||
handle
|
||||
.protocol_snapshot()
|
||||
.error
|
||||
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
|
||||
));
|
||||
}
|
||||
}
|
||||
final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
||||
summary_text = final_ctx
|
||||
.summary
|
||||
@@ -3611,6 +3787,11 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}),
|
||||
};
|
||||
let mut initial_entries = vec![entry.clone()];
|
||||
if let Some(checkpoint) =
|
||||
active_run_checkpoint_entry(w.active_run_turn_count(), source_turn_count)
|
||||
{
|
||||
initial_entries.push(checkpoint);
|
||||
}
|
||||
if let Some(flow_state) = self
|
||||
.flow_runtime_state
|
||||
.lock()
|
||||
@@ -3694,7 +3875,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
.lock()
|
||||
.expect("extract_pointer poisoned") = None;
|
||||
|
||||
Ok(new_segment_id)
|
||||
Ok((new_segment_id, summary_text))
|
||||
}
|
||||
|
||||
/// Build the LlmClient for the compactor Engine.
|
||||
@@ -4080,6 +4261,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
input: input_text,
|
||||
cache_key: Some(self.segment_id().to_string()),
|
||||
max_turns: extract_worker_max_turns,
|
||||
engine_configurator: None,
|
||||
features,
|
||||
required_tools: &[
|
||||
"ShowOverview",
|
||||
@@ -4092,6 +4274,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
workspace: self.workspace_context.clone(),
|
||||
filesystem: WorkerFilesystemAuthority::None,
|
||||
scope: Scope::empty(),
|
||||
workdir_session: None,
|
||||
},
|
||||
};
|
||||
let internal_result = match cancel_observer {
|
||||
@@ -4566,6 +4749,7 @@ where
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -4643,6 +4827,7 @@ where
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -4755,6 +4940,7 @@ where
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -5031,6 +5217,7 @@ where
|
||||
worker.set_request_config(state.config.clone());
|
||||
worker.set_turn_count(state.turn_count);
|
||||
worker.set_last_run_interrupted(state.last_run_interrupted);
|
||||
worker.set_active_run_turn_count(state.active_run_turn_count);
|
||||
if anchored_on_summary {
|
||||
worker.set_cache_anchor(Some(0));
|
||||
}
|
||||
@@ -5071,6 +5258,7 @@ where
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
internal_worker_registry: None,
|
||||
in_flight: None,
|
||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
@@ -5725,6 +5913,9 @@ pub enum WorkerError {
|
||||
#[error("compact worker did not produce a summary (write_summary was never called)")]
|
||||
CompactSummaryMissing,
|
||||
|
||||
#[error("compaction was cancelled")]
|
||||
CompactCancelled,
|
||||
|
||||
#[error("compact summary too large: {tokens} tokens exceeds max {max}")]
|
||||
CompactSummaryTooLarge { tokens: u64, max: u64 },
|
||||
|
||||
@@ -6805,6 +6996,108 @@ mod build_summary_prompt_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_run_clears_interrupted_budget_before_pre_run_compaction() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
|
||||
let mut worker = Worker::new(
|
||||
minimal_manifest(),
|
||||
Engine::new(NoopClient),
|
||||
store,
|
||||
WorkerWorkspaceContext::no_workspace(),
|
||||
WorkerFilesystemAuthority::None,
|
||||
Scope::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.ensure_segment_head().unwrap();
|
||||
worker.engine_mut().set_last_run_interrupted(true);
|
||||
worker.engine_mut().set_active_run_turn_count(Some(3));
|
||||
|
||||
worker.prepare_interrupted_history_for_fresh_run().unwrap();
|
||||
|
||||
assert!(!worker.engine().last_run_interrupted());
|
||||
assert_eq!(worker.engine().active_run_turn_count(), None);
|
||||
let checkpoint = active_run_checkpoint_entry(
|
||||
worker.engine().active_run_turn_count(),
|
||||
worker.engine().turn_count(),
|
||||
);
|
||||
assert!(checkpoint.is_none());
|
||||
|
||||
let mut replacement_entries = vec![LogEntry::SegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}];
|
||||
replacement_entries.extend(checkpoint);
|
||||
let restored = session_store::collect_state(&replacement_entries);
|
||||
assert!(!restored.last_run_interrupted);
|
||||
assert_eq!(restored.active_run_turn_count, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_fork_checkpoints_interrupted_run_budget_for_restore() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
|
||||
let mut worker = Worker::new(
|
||||
minimal_manifest(),
|
||||
Engine::new(NoopClient),
|
||||
store,
|
||||
WorkerWorkspaceContext::no_workspace(),
|
||||
WorkerFilesystemAuthority::None,
|
||||
Scope::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.ensure_segment_head().unwrap();
|
||||
worker.engine_mut().set_turn_count(7);
|
||||
worker.engine_mut().set_last_run_interrupted(true);
|
||||
worker.engine_mut().set_active_run_turn_count(Some(3));
|
||||
|
||||
let session_id = worker.session_id();
|
||||
let source_segment_id = worker.segment_id();
|
||||
worker
|
||||
.store()
|
||||
.append(
|
||||
session_id,
|
||||
source_segment_id,
|
||||
&LogEntry::Extension {
|
||||
ts: segment_log::now_millis(),
|
||||
domain: "test.auto_fork_drift".into(),
|
||||
payload: serde_json::json!({}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
worker.ensure_segment_head().unwrap();
|
||||
|
||||
let fork_segment_id = worker.segment_id();
|
||||
assert_ne!(fork_segment_id, source_segment_id);
|
||||
let fork_entries = worker
|
||||
.store()
|
||||
.read_all(session_id, fork_segment_id)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
fork_entries.as_slice(),
|
||||
[
|
||||
LogEntry::SegmentStart { .. },
|
||||
LogEntry::ActiveRunCheckpoint {
|
||||
active_turn_count: 3,
|
||||
total_turn_count: 7,
|
||||
..
|
||||
}
|
||||
]
|
||||
));
|
||||
let restored = session_store::collect_state(&fork_entries);
|
||||
assert!(restored.last_run_interrupted);
|
||||
assert_eq!(restored.turn_count, 7);
|
||||
assert_eq!(restored.active_run_turn_count, Some(3));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flow_transition_feature_installs_runtime_local_coordinator() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -431,7 +431,7 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Event::CompactStart => "start",
|
||||
Event::CompactStart { .. } => "start",
|
||||
Event::CompactDone { .. } => "done",
|
||||
Event::CompactFailed { .. } => "failed",
|
||||
_ => "other",
|
||||
@@ -445,10 +445,61 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
||||
!kinds.contains(&"failed"),
|
||||
"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.
|
||||
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,
|
||||
});
|
||||
assert!(new_id_in_event.is_some(), "CompactDone missing");
|
||||
@@ -488,7 +539,7 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Event::CompactStart => "start",
|
||||
Event::CompactStart { .. } => "start",
|
||||
Event::CompactDone { .. } => "done",
|
||||
Event::CompactFailed { .. } => "failed",
|
||||
_ => "other",
|
||||
@@ -504,7 +555,10 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
assert_eq!(new_id_in_event, Some(worker.segment_id()));
|
||||
@@ -659,7 +713,7 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Event::CompactStart => "start",
|
||||
Event::CompactStart { .. } => "start",
|
||||
Event::CompactDone { .. } => "done",
|
||||
Event::CompactFailed { .. } => "failed",
|
||||
_ => "other",
|
||||
@@ -817,11 +871,14 @@ async fn controller_compact_method_emits_start_and_done() {
|
||||
.expect("timeout waiting for compact events")
|
||||
.expect("event")
|
||||
{
|
||||
Event::CompactStart => saw_start = true,
|
||||
Event::CompactStart { .. } => saw_start = true,
|
||||
Event::CompactDone { .. } => {
|
||||
break;
|
||||
}
|
||||
Event::CompactFailed { error } => panic!("manual compact failed: {error}"),
|
||||
Event::CompactFailed { lifecycle } => panic!(
|
||||
"manual compact failed: {}",
|
||||
lifecycle.error.as_deref().unwrap_or("unknown error")
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ impl Tool for BigContentTool {
|
||||
Ok(ToolOutput {
|
||||
summary: self.summary.into(),
|
||||
content: Some(self.content.clone()),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,88 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
||||
|
||||
@@ -291,6 +373,116 @@ pub struct UpdateWorkspaceMemorySettingsRequest {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -25,11 +25,13 @@ manifest.workspace = true
|
||||
protocol = { workspace = true }
|
||||
project-record.workspace = true
|
||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||
ring.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha2.workspace = true
|
||||
ssh-key.workspace = true
|
||||
thiserror.workspace = true
|
||||
ticket.workspace = true
|
||||
memory.workspace = true
|
||||
|
||||
@@ -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 {
|
||||
format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax")
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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 {
|
||||
|
||||
@@ -704,6 +704,15 @@ impl SqliteWorkspaceAuthority {
|
||||
&self,
|
||||
reference: &str,
|
||||
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> {
|
||||
let id = self
|
||||
.store
|
||||
@@ -713,16 +722,19 @@ impl SqliteWorkspaceAuthority {
|
||||
reference,
|
||||
)?
|
||||
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?;
|
||||
let ticket = self.ticket_backend.show(TicketIdOrSlug::Id(id))?;
|
||||
self.ticket_detail_from_ticket(ticket, request)
|
||||
let ticket = backend.show(TicketIdOrSlug::Id(id))?;
|
||||
self.ticket_detail_from_ticket(ticket, request, backend)
|
||||
}
|
||||
|
||||
fn ticket_detail_from_ticket(
|
||||
&self,
|
||||
ticket: ticket::Ticket,
|
||||
request: TicketShowRequest,
|
||||
dependency_backend: &SqliteTicketBackend,
|
||||
) -> Result<TicketDetail> {
|
||||
let id = ticket.meta.id.as_str();
|
||||
let dependency_check =
|
||||
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
|
||||
let (body, body_truncated) =
|
||||
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
||||
let event_limit = request
|
||||
@@ -822,6 +834,27 @@ impl SqliteWorkspaceAuthority {
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
||||
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
|
||||
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();
|
||||
if let Some(legacy_assignee) = ticket
|
||||
.meta
|
||||
@@ -833,6 +866,19 @@ impl SqliteWorkspaceAuthority {
|
||||
"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 {
|
||||
can_assign_orchestrator: matches!(
|
||||
ticket.meta.workflow_state,
|
||||
@@ -848,19 +894,15 @@ impl SqliteWorkspaceAuthority {
|
||||
&& has_orchestrator
|
||||
&& !has_coder
|
||||
&& 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
|
||||
&& !has_orchestrator
|
||||
&& !has_coder
|
||||
&& has_target
|
||||
&& !has_blockers,
|
||||
blockers: [
|
||||
(!has_target).then_some("Ticket target is required".to_string()),
|
||||
has_blockers.then_some("unresolved blocking relations remain".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
queue_tickets: dependency_check.queue_tickets.clone(),
|
||||
blockers: action_blockers,
|
||||
};
|
||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||
Ok(request) => {
|
||||
@@ -1084,6 +1126,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||
event_limit: Some(TICKET_EVENT_LIMIT),
|
||||
event_cursor: None,
|
||||
},
|
||||
&self.ticket_backend,
|
||||
)?;
|
||||
if ticket_matches_query(
|
||||
&summary,
|
||||
@@ -2927,7 +2970,7 @@ mod tests {
|
||||
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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");
|
||||
let db_path = dir.path().join("workspace.db");
|
||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||
@@ -3038,10 +3081,22 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
.ticket_backend
|
||||
.add_ticket_relation(
|
||||
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 {
|
||||
kind: ticket::TicketRelationKind::DependsOn,
|
||||
target: "00000000001J6".to_string(),
|
||||
note: Some("separate dependency relation".to_string()),
|
||||
note: Some("transitive planning dependency".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);
|
||||
|
||||
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_truncated);
|
||||
assert!(!ticket.body.contains("Deep Ticket marker"));
|
||||
@@ -3139,8 +3202,8 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert!(note_only_kind.items.is_empty());
|
||||
let crossed_relation_filters = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
related_ticket_id: Some("00000000001J5".to_string()),
|
||||
relation_kind: Some("depends_on".to_string()),
|
||||
related_ticket_id: Some("00000000001J6".to_string()),
|
||||
relation_kind: Some("related".to_string()),
|
||||
..TicketQueryRequest::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -471,14 +471,18 @@ fn resolve_repository(
|
||||
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 (source, path) = resolve_repository_source(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,
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||
source,
|
||||
source_revision: 1,
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path,
|
||||
display_name,
|
||||
default_selector,
|
||||
@@ -517,13 +521,41 @@ fn validate_repository_id(id: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)));
|
||||
fn resolve_repository_source(
|
||||
workspace_root: &Path,
|
||||
id: &str,
|
||||
uri: &str,
|
||||
) -> Result<(workspace_api::RepositorySource, Option<PathBuf>)> {
|
||||
match crate::repository_source::parse_repository_source(uri) {
|
||||
Ok(source) => {
|
||||
let path = match source.kind {
|
||||
workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)),
|
||||
workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri)
|
||||
.ok()
|
||||
.and_then(|uri| uri.to_file_path().ok()),
|
||||
workspace_api::RepositorySourceKind::Ssh
|
||||
| workspace_api::RepositorySourceKind::Http
|
||||
| workspace_api::RepositorySourceKind::Https => None,
|
||||
workspace_api::RepositorySourceKind::Invalid => {
|
||||
return Err(Error::Config(format!(
|
||||
"repository `{id}` has an invalid source"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok((source, path))
|
||||
}
|
||||
Err(_) if !Path::new(uri).is_absolute() && !uri.contains("://") => {
|
||||
let path = resolve_workspace_path(workspace_root, Path::new(uri));
|
||||
let source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: path.to_string_lossy().into_owned(),
|
||||
};
|
||||
Ok((source, Some(path)))
|
||||
}
|
||||
Err(error) => Err(Error::Config(format!(
|
||||
"repository `{id}` has an invalid source: {error}"
|
||||
))),
|
||||
}
|
||||
Ok(resolve_workspace_path(workspace_root, Path::new(uri)))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_remote_runtime(
|
||||
@@ -741,13 +773,13 @@ default_selector = "HEAD"
|
||||
|
||||
assert_eq!(repository.id, "main");
|
||||
assert_eq!(repository.provider, "git");
|
||||
assert_eq!(repository.path, dir.path());
|
||||
assert_eq!(repository.path.as_deref(), Some(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() {
|
||||
fn remote_repository_source_is_preserved_without_a_local_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = WorkspaceBackendConfigFile::parse_str(
|
||||
r#"
|
||||
@@ -759,17 +791,15 @@ 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,
|
||||
};
|
||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
||||
let repository = &resolved.server.repositories[0];
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("remote repository materialization is not implemented"),
|
||||
"unexpected error: {error}"
|
||||
assert_eq!(
|
||||
repository.source.kind,
|
||||
workspace_api::RepositorySourceKind::Https
|
||||
);
|
||||
assert_eq!(repository.source.uri, "https://example.com/org/repo.git");
|
||||
assert!(repository.path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,12 +20,16 @@ pub mod records;
|
||||
#[cfg(feature = "typescript")]
|
||||
pub use records::ticket_api_typescript;
|
||||
pub mod repositories;
|
||||
pub mod repository_access;
|
||||
pub mod repository_source;
|
||||
pub mod resource_broker;
|
||||
pub mod retention;
|
||||
pub mod runtime_settings;
|
||||
pub mod runtime_subscription;
|
||||
pub mod server;
|
||||
pub mod skills;
|
||||
pub mod store;
|
||||
pub mod workdir_create_operations;
|
||||
pub mod worker_source;
|
||||
pub mod workspace_catalog;
|
||||
mod workspace_subscription;
|
||||
@@ -113,6 +117,8 @@ pub enum Error {
|
||||
TicketAssignmentConflict(String),
|
||||
#[error("Workdir attachment conflict: {0}")]
|
||||
WorkdirAttachmentConflict(String),
|
||||
#[error("Workspace permission denied: {0}")]
|
||||
WorkspacePermissionDenied(String),
|
||||
#[error("Workspace config update conflict: {0}")]
|
||||
WorkspaceConfigConflict(String),
|
||||
#[error("Registry inconsistency: {0}")]
|
||||
|
||||
@@ -711,14 +711,15 @@ fn infer_workspace_root_from_repositories(
|
||||
)));
|
||||
};
|
||||
|
||||
let repository_path = PathBuf::from(&repository.uri);
|
||||
if !repository_path.is_absolute() {
|
||||
if repository.source.kind == workspace_api::RepositorySourceKind::Invalid {
|
||||
return Err(CliError(format!(
|
||||
"repository `{}` has relative URI `{}`; repository records used by serve must be absolute paths",
|
||||
repository.repository_id, repository.uri
|
||||
"repository `{}` has an invalid migrated source and cannot be used by serve",
|
||||
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> {
|
||||
@@ -1121,7 +1122,7 @@ mod tests {
|
||||
assert_eq!(repositories.len(), 1);
|
||||
assert_eq!(repositories[0].repository_id, "main");
|
||||
assert_eq!(
|
||||
repositories[0].uri,
|
||||
repositories[0].source.uri,
|
||||
temp.path().canonicalize().unwrap().display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,6 +296,7 @@ pub struct TicketActionEligibility {
|
||||
pub can_unassign_orchestrator: bool,
|
||||
pub can_queue: bool,
|
||||
pub can_start_manual_coder: bool,
|
||||
pub queue_tickets: Vec<String>,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::{
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||
|
||||
pub type RepositoryId = String;
|
||||
pub type RepositorySelector = String;
|
||||
@@ -13,8 +14,12 @@ pub type RepositorySelector = String;
|
||||
pub struct ConfiguredRepository {
|
||||
pub id: RepositoryId,
|
||||
pub provider: String,
|
||||
pub uri: String,
|
||||
pub path: PathBuf,
|
||||
pub source: RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
pub observed_at: Option<String>,
|
||||
pub path: Option<PathBuf>,
|
||||
pub display_name: Option<String>,
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
}
|
||||
@@ -25,6 +30,12 @@ pub struct RepositorySummary {
|
||||
pub display_name: String,
|
||||
pub kind: String,
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub observed_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
pub record_authority: String,
|
||||
@@ -262,9 +273,17 @@ impl RepositoryRegistryReader {
|
||||
descendant: &str,
|
||||
) -> Result<(), RepositoryLookupError> {
|
||||
let repository = self.merge_repository(id)?;
|
||||
let repository_path =
|
||||
repository
|
||||
.path
|
||||
.as_ref()
|
||||
.ok_or_else(|| RepositoryLookupError::ProviderFailure {
|
||||
id: id.into(),
|
||||
operation: "repository source is not materialized for local Git access".into(),
|
||||
})?;
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repository.path)
|
||||
.arg(repository_path)
|
||||
.args(["merge-base", "--is-ancestor", ancestor, descendant])
|
||||
.status()
|
||||
.map_err(|_| RepositoryLookupError::ProviderFailure {
|
||||
@@ -306,7 +325,24 @@ impl RepositoryRegistryReader {
|
||||
.clone()
|
||||
.unwrap_or_else(|| repository.id.clone());
|
||||
let mut diagnostics = Vec::new();
|
||||
if repository.source.kind == workspace_api::RepositorySourceKind::Http {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_source_insecure_http".to_string(),
|
||||
message:
|
||||
"HTTP Repository source is unencrypted; prefer HTTPS or SSH when available."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
let git = match repository.provider.as_str() {
|
||||
"git" if repository.path.is_none() => {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "info".to_string(),
|
||||
code: "repository_source_unverified".to_string(),
|
||||
message: "Remote Repository source is registered but is not materialized for server-local inspection.".to_string(),
|
||||
});
|
||||
None
|
||||
}
|
||||
"git" => match self.inspect_git(repository) {
|
||||
Ok(git) => Some(git),
|
||||
Err(message) => {
|
||||
@@ -335,6 +371,11 @@ impl RepositoryRegistryReader {
|
||||
display_name,
|
||||
kind: repository.provider.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
source: repository.source.clone(),
|
||||
source_revision: repository.source_revision,
|
||||
source_fingerprint: repository.source_fingerprint.clone(),
|
||||
observed_status: repository.observed_status,
|
||||
observed_at: repository.observed_at.clone(),
|
||||
default_selector: repository.default_selector.clone(),
|
||||
record_authority: "workspace-control-plane".to_string(),
|
||||
git,
|
||||
@@ -346,12 +387,15 @@ impl RepositoryRegistryReader {
|
||||
&self,
|
||||
repository: &ConfiguredRepository,
|
||||
) -> Result<GitRepositorySummary, String> {
|
||||
let head = git_stdout(&repository.path, ["rev-parse", "HEAD"])?;
|
||||
let branch = git_stdout(&repository.path, ["branch", "--show-current"])
|
||||
let path = repository.path.as_ref().ok_or_else(|| {
|
||||
"Repository source is not materialized for local Git inspection.".to_string()
|
||||
})?;
|
||||
let head = git_stdout(path, ["rev-parse", "HEAD"])?;
|
||||
let branch = git_stdout(path, ["branch", "--show-current"])
|
||||
.ok()
|
||||
.and_then(|value| non_empty_string(value.trim()));
|
||||
let status = git_stdout(&repository.path, ["status", "--porcelain"])?;
|
||||
let remotes = git_stdout(&repository.path, ["remote", "-v"])
|
||||
let status = git_stdout(path, ["status", "--porcelain"])?;
|
||||
let remotes = git_stdout(path, ["remote", "-v"])
|
||||
.map(|raw| parse_remotes(&raw))
|
||||
.unwrap_or_default();
|
||||
Ok(GitRepositorySummary {
|
||||
@@ -369,8 +413,11 @@ impl RepositoryRegistryReader {
|
||||
limit: usize,
|
||||
) -> Result<Vec<GitCommitSummary>, String> {
|
||||
let limit_arg = format!("-{limit}");
|
||||
let path = repository.path.as_ref().ok_or_else(|| {
|
||||
"Repository source is not materialized for local Git log access.".to_string()
|
||||
})?;
|
||||
let output = git_stdout(
|
||||
&repository.path,
|
||||
path,
|
||||
[
|
||||
"log",
|
||||
"--date=iso-strict",
|
||||
@@ -419,11 +466,16 @@ fn merge_git_stdout(
|
||||
operation: &str,
|
||||
args: &[&str],
|
||||
) -> Result<String, RepositoryLookupError> {
|
||||
git_stdout(&repository.path, args.iter().copied()).map_err(|_| {
|
||||
RepositoryLookupError::ProviderFailure {
|
||||
let path = repository
|
||||
.path
|
||||
.as_ref()
|
||||
.ok_or_else(|| RepositoryLookupError::ProviderFailure {
|
||||
id: repository.id.clone(),
|
||||
operation: operation.into(),
|
||||
}
|
||||
operation: "repository source is not materialized for local Git access".into(),
|
||||
})?;
|
||||
git_stdout(path, args.iter().copied()).map_err(|_| RepositoryLookupError::ProviderFailure {
|
||||
id: repository.id.clone(),
|
||||
operation: operation.into(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -593,6 +645,47 @@ mod tests {
|
||||
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
|
||||
let source = RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
uri: "git@example.test:org/repository.git".to_string(),
|
||||
};
|
||||
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
|
||||
id: "remote".into(),
|
||||
display_name: Some("Remote".into()),
|
||||
provider: "git".into(),
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||
source,
|
||||
source_revision: 1,
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path: None,
|
||||
default_selector: Some("main".into()),
|
||||
}]);
|
||||
|
||||
let projection = reader.list();
|
||||
let summary = &projection.items[0];
|
||||
assert_eq!(
|
||||
summary.source.kind,
|
||||
workspace_api::RepositorySourceKind::Ssh
|
||||
);
|
||||
assert_eq!(
|
||||
summary.observed_status,
|
||||
RepositoryObservedStatus::Unverified
|
||||
);
|
||||
assert!(summary.git.is_none());
|
||||
assert_eq!(summary.diagnostics[0].code, "repository_source_unverified");
|
||||
|
||||
let repository = reader.merge_repository("remote").unwrap();
|
||||
let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
RepositoryLookupError::ProviderFailure { operation, .. }
|
||||
if operation.contains("not materialized")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_evidence_is_resolved_by_repository_identity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -675,12 +768,22 @@ mod tests {
|
||||
.success()
|
||||
);
|
||||
|
||||
let source_descriptor = RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: path.display().to_string(),
|
||||
};
|
||||
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
|
||||
id: "main".into(),
|
||||
display_name: Some("Main".into()),
|
||||
provider: "git".into(),
|
||||
path: path.to_path_buf(),
|
||||
uri: path.display().to_string(),
|
||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(
|
||||
&source_descriptor,
|
||||
),
|
||||
source: source_descriptor,
|
||||
source_revision: 1,
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
path: Some(path.to_path_buf()),
|
||||
default_selector: Some("main".into()),
|
||||
}]);
|
||||
let target = reader.observe_merge_target("main", Some("main")).unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
||||
use std::path::Path;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
use workspace_api::{RepositorySource, RepositorySourceKind};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const MAX_REPOSITORY_SOURCE_BYTES: usize = 4096;
|
||||
|
||||
/// Parse and canonicalize a user-authored Git source without accessing the
|
||||
/// filesystem or network.
|
||||
pub fn parse_repository_source(value: &str) -> Result<RepositorySource> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.len() > MAX_REPOSITORY_SOURCE_BYTES {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"initial repository source must be between 1 and {MAX_REPOSITORY_SOURCE_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
if value.chars().any(char::is_control) {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository source must not contain control characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if Path::new(value).is_absolute() {
|
||||
return Ok(RepositorySource {
|
||||
kind: RepositorySourceKind::LocalPath,
|
||||
uri: value.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if is_scp_like_ssh(value) {
|
||||
validate_scp_like_ssh(value)?;
|
||||
return Ok(RepositorySource {
|
||||
kind: RepositorySourceKind::Ssh,
|
||||
uri: value.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let parsed = Url::parse(value).map_err(|_| {
|
||||
Error::InvalidInput(
|
||||
"initial repository source must be an absolute local path or a supported Git URI"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
if parsed.query().is_some() || parsed.fragment().is_some() {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository source must not contain query parameters or fragments".to_string(),
|
||||
));
|
||||
}
|
||||
if parsed.password().is_some() {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository source must not embed a password or token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let kind = match parsed.scheme() {
|
||||
"file" => {
|
||||
if !parsed.username().is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"file repository URI must not contain user information".to_string(),
|
||||
));
|
||||
}
|
||||
if parsed.host_str().is_some_and(|host| host != "localhost") {
|
||||
return Err(Error::InvalidInput(
|
||||
"file repository URI host must be empty or localhost".to_string(),
|
||||
));
|
||||
}
|
||||
parsed.to_file_path().map_err(|_| {
|
||||
Error::InvalidInput("file repository URI must contain an absolute path".to_string())
|
||||
})?;
|
||||
RepositorySourceKind::File
|
||||
}
|
||||
"ssh" => {
|
||||
require_remote_host_and_path(&parsed)?;
|
||||
RepositorySourceKind::Ssh
|
||||
}
|
||||
"http" | "https" => {
|
||||
if !parsed.username().is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"HTTP repository URI must not contain user information".to_string(),
|
||||
));
|
||||
}
|
||||
require_remote_host_and_path(&parsed)?;
|
||||
if parsed.scheme() == "http" {
|
||||
RepositorySourceKind::Http
|
||||
} else {
|
||||
RepositorySourceKind::Https
|
||||
}
|
||||
}
|
||||
scheme => {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"unsupported initial repository source scheme `{scheme}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(RepositorySource {
|
||||
kind,
|
||||
uri: parsed.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Classify persisted pre-source-contract rows without guessing a usable remote
|
||||
/// when the legacy value is malformed. No filesystem or network access occurs.
|
||||
pub fn classify_legacy_repository_source(value: &str) -> RepositorySource {
|
||||
parse_repository_source(value).unwrap_or_else(|_| RepositorySource {
|
||||
kind: RepositorySourceKind::Invalid,
|
||||
uri: value.trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
|
||||
let payload = serde_json::to_vec(source).expect("Repository source serializes");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"yoi.repository-source.v1\0");
|
||||
hasher.update(payload);
|
||||
let digest = hasher.finalize();
|
||||
let mut encoded = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
||||
}
|
||||
format!("sha256:{encoded}")
|
||||
}
|
||||
|
||||
fn require_remote_host_and_path(parsed: &Url) -> Result<()> {
|
||||
if parsed.host_str().is_none() || parsed.path().is_empty() || parsed.path() == "/" {
|
||||
return Err(Error::InvalidInput(
|
||||
"remote repository URI must contain a host and repository path".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_scp_like_ssh(value: &str) -> bool {
|
||||
!value.contains("://")
|
||||
&& value
|
||||
.split_once(':')
|
||||
.is_some_and(|(identity, _)| identity.contains('@'))
|
||||
}
|
||||
|
||||
fn validate_scp_like_ssh(value: &str) -> Result<()> {
|
||||
let (identity, path) = value.split_once(':').ok_or_else(|| {
|
||||
Error::InvalidInput("scp-like SSH source must contain `host:path`".to_string())
|
||||
})?;
|
||||
let (username, host) = identity.split_once('@').ok_or_else(|| {
|
||||
Error::InvalidInput("scp-like SSH source must contain `user@host:path`".to_string())
|
||||
})?;
|
||||
if username.is_empty()
|
||||
|| host.is_empty()
|
||||
|| path.is_empty()
|
||||
|| username.contains('@')
|
||||
|| username.contains(':')
|
||||
|| host.contains('@')
|
||||
|| path.starts_with('-')
|
||||
|| value.contains('?')
|
||||
|| value.contains('#')
|
||||
|| value.chars().any(char::is_whitespace)
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"scp-like SSH source must use `user@host:path` without credentials or parameters"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_local_file_ssh_http_and_https_sources_without_io() {
|
||||
let cases = [
|
||||
("/runtime/repos/project", RepositorySourceKind::LocalPath),
|
||||
("file:///runtime/repos/project", RepositorySourceKind::File),
|
||||
(
|
||||
"ssh://git@example.test/org/project.git",
|
||||
RepositorySourceKind::Ssh,
|
||||
),
|
||||
(
|
||||
"git@example.test:org/project.git",
|
||||
RepositorySourceKind::Ssh,
|
||||
),
|
||||
(
|
||||
"http://git.test/org/project.git",
|
||||
RepositorySourceKind::Http,
|
||||
),
|
||||
(
|
||||
"https://git.test/org/project.git",
|
||||
RepositorySourceKind::Https,
|
||||
),
|
||||
];
|
||||
for (source, expected_kind) in cases {
|
||||
assert_eq!(parse_repository_source(source).unwrap().kind, expected_kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_relative_unsupported_and_credential_bearing_sources() {
|
||||
for source in [
|
||||
"relative/project",
|
||||
"ftp://git.test/project.git",
|
||||
"https://user@git.test/project.git",
|
||||
"https://git.test/project.git?token=secret",
|
||||
"ssh://git:secret@git.test/project.git",
|
||||
"git@example.test:",
|
||||
"git:secret@example.test:org/project.git",
|
||||
"https://git.test/project.git\nother",
|
||||
] {
|
||||
assert!(
|
||||
parse_repository_source(source).is_err(),
|
||||
"accepted {source:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_uses_canonical_source_identity() {
|
||||
let first = parse_repository_source(" https://EXAMPLE.test/a/../project.git ").unwrap();
|
||||
let second = parse_repository_source("https://example.test/project.git").unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(
|
||||
repository_source_fingerprint(&first),
|
||||
repository_source_fingerprint(&second)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use config_source::ConfigSchemaContribution;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config_source::{
|
||||
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
const RUNTIME_SCHEMA_SOURCE: &str = r#"{
|
||||
runtime = {
|
||||
default_runtime_id = String default "";
|
||||
};
|
||||
}"#;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RuntimeConfigSchemaProvider;
|
||||
|
||||
impl WorkspaceConfigSchemaProvider for RuntimeConfigSchemaProvider {
|
||||
fn contribution(&self) -> Result<ConfigSchemaContribution> {
|
||||
ConfigSchemaContribution::new("builtin:runtime", "runtime", "1", RUNTIME_SCHEMA_SOURCE)
|
||||
.map_err(|error| Error::Config(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeConfigProjection {
|
||||
pub config_revision: u64,
|
||||
pub projection_digest: String,
|
||||
pub default_runtime_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VirtualRuntimeConfig {
|
||||
runtime: VirtualRuntimeSection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct VirtualRuntimeSection {
|
||||
default_runtime_id: String,
|
||||
}
|
||||
|
||||
pub fn project_runtime_from_workspace_config(
|
||||
workspace_id: &str,
|
||||
state: &WorkspaceConfigState,
|
||||
) -> Result<RuntimeConfigProjection> {
|
||||
let has_runtime_schema = state
|
||||
.contract
|
||||
.schema_bundle
|
||||
.contributions
|
||||
.iter()
|
||||
.any(|entry| entry.provider_id == "builtin:runtime");
|
||||
if !has_runtime_schema {
|
||||
return Ok(RuntimeConfigProjection {
|
||||
config_revision: state.snapshot.revision,
|
||||
projection_digest: state.projection_digest.clone(),
|
||||
default_runtime_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?;
|
||||
if evaluation.projection_digest != state.projection_digest {
|
||||
return Err(Error::RegistryInconsistency(format!(
|
||||
"Runtime projection digest mismatch for Workspace {workspace_id}"
|
||||
)));
|
||||
}
|
||||
let projected = evaluation.projections.first().ok_or_else(|| {
|
||||
Error::RegistryInconsistency("Workspace config has no active projection".to_string())
|
||||
})?;
|
||||
let config: VirtualRuntimeConfig = serde_json::from_value(projected.data_json.clone())
|
||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
||||
let default_runtime_id = normalize_runtime_id(&config.runtime.default_runtime_id)?;
|
||||
Ok(RuntimeConfigProjection {
|
||||
config_revision: state.snapshot.revision,
|
||||
projection_digest: evaluation.projection_digest,
|
||||
default_runtime_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_runtime_id(value: &str) -> Result<Option<String>> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if value.chars().any(char::is_control) {
|
||||
return Err(Error::InvalidRuntimeIdentifier {
|
||||
kind: "runtime_id".to_string(),
|
||||
value: "[redacted invalid value]".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(Some(value.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn state(source: &str) -> WorkspaceConfigState {
|
||||
let bundle =
|
||||
config_source::WorkspaceConfigSchemaBundle::compose([RuntimeConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
7,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
source,
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let contract = config_source::ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
vec![VirtualPath::parse("main.dcdl").unwrap()],
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
bundle,
|
||||
);
|
||||
let projection_digest = config_source::SnapshotEnvironment::new(snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
.unwrap()
|
||||
.projection_digest;
|
||||
WorkspaceConfigState {
|
||||
snapshot,
|
||||
contract,
|
||||
projection_digest,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_projection_reads_default_and_preserves_revision_evidence() {
|
||||
let projection = project_runtime_from_workspace_config(
|
||||
"workspace",
|
||||
&state(
|
||||
r#"{ runtime = { default_runtime_id = "arcadia"; }; } as WorkspaceConfigSchema"#,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(projection.default_runtime_id.as_deref(), Some("arcadia"));
|
||||
assert_eq!(projection.config_revision, 7);
|
||||
assert!(!projection.projection_digest.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_projection_treats_missing_default_as_unconfigured() {
|
||||
let projection = project_runtime_from_workspace_config(
|
||||
"workspace",
|
||||
&state("{} as WorkspaceConfigSchema"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(projection.default_runtime_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_projection_treats_pre_runtime_schema_bundle_as_unconfigured() {
|
||||
let bundle =
|
||||
config_source::WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:legacy",
|
||||
"legacy",
|
||||
"1",
|
||||
"{ legacy = { enabled = Bool default false; }; }",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
6,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{ legacy = { enabled = true; }; } as WorkspaceConfigSchema",
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let contract = config_source::ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
vec![VirtualPath::parse("main.dcdl").unwrap()],
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
bundle,
|
||||
);
|
||||
let projection_digest = config_source::SnapshotEnvironment::new(snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
.unwrap()
|
||||
.projection_digest;
|
||||
let state = WorkspaceConfigState {
|
||||
snapshot,
|
||||
contract,
|
||||
projection_digest: projection_digest.clone(),
|
||||
};
|
||||
|
||||
let projection = project_runtime_from_workspace_config("workspace", &state).unwrap();
|
||||
assert_eq!(projection.default_runtime_id, None);
|
||||
assert_eq!(projection.config_revision, 6);
|
||||
assert_eq!(projection.projection_digest, projection_digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_schema_rejects_non_string_default() {
|
||||
let bundle =
|
||||
config_source::WorkspaceConfigSchemaBundle::compose([RuntimeConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{ runtime = { default_runtime_id = 42; }; } as WorkspaceConfigSchema",
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let contract = config_source::ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
vec![VirtualPath::parse("main.dcdl").unwrap()],
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
bundle,
|
||||
);
|
||||
assert!(
|
||||
config_source::SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&contract)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
+2884
-262
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::store::WorkdirCreateOperationRecord;
|
||||
use crate::{Error, Result, SqliteWorkspaceStore};
|
||||
|
||||
pub fn request_fingerprint(
|
||||
repository_id: &str,
|
||||
selector: Option<&str>,
|
||||
requested_runtime_id: Option<&str>,
|
||||
) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
for value in [Some(repository_id), selector, requested_runtime_id] {
|
||||
match value {
|
||||
Some(value) => {
|
||||
hasher.update([1]);
|
||||
hasher.update((value.len() as u64).to_be_bytes());
|
||||
hasher.update(value.as_bytes());
|
||||
}
|
||||
None => hasher.update([0]),
|
||||
}
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
let mut encoded = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
||||
}
|
||||
format!("sha256:{encoded}")
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub fn reserve_workdir_create_operation(
|
||||
&self,
|
||||
record: &WorkdirCreateOperationRecord,
|
||||
) -> Result<WorkdirCreateOperationRecord> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
r#"INSERT OR IGNORE INTO workdir_create_operations (
|
||||
workspace_id, operation_id, request_fingerprint, repository_id, selector,
|
||||
requested_runtime_id, resolved_runtime_id, config_revision,
|
||||
config_projection_digest, working_directory_id, state, failure,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.operation_id,
|
||||
record.request_fingerprint,
|
||||
record.repository_id,
|
||||
record.selector,
|
||||
record.requested_runtime_id,
|
||||
record.resolved_runtime_id,
|
||||
record.config_revision as i64,
|
||||
record.config_projection_digest,
|
||||
record.working_directory_id,
|
||||
record.state,
|
||||
record.failure,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
],
|
||||
)?;
|
||||
let persisted =
|
||||
read_workdir_create_operation(&tx, &record.workspace_id, &record.operation_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workdir create operation `{}` was not persisted",
|
||||
record.operation_id
|
||||
))
|
||||
})?;
|
||||
if persisted.request_fingerprint != record.request_fingerprint {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Workdir create operation `{}` was reused with different input",
|
||||
record.operation_id
|
||||
)));
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(persisted)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish_workdir_create_operation(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
request_fingerprint: &str,
|
||||
succeeded: bool,
|
||||
failure: Option<&str>,
|
||||
updated_at: &str,
|
||||
) -> Result<WorkdirCreateOperationRecord> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let changed = conn.execute(
|
||||
r#"UPDATE workdir_create_operations
|
||||
SET state = ?1, failure = ?2, updated_at = ?3
|
||||
WHERE workspace_id = ?4 AND operation_id = ?5
|
||||
AND request_fingerprint = ?6"#,
|
||||
params![
|
||||
if succeeded { "succeeded" } else { "failed" },
|
||||
failure,
|
||||
updated_at,
|
||||
workspace_id,
|
||||
operation_id,
|
||||
request_fingerprint,
|
||||
],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(Error::RegistryInconsistency(format!(
|
||||
"Workdir create operation `{operation_id}` could not be finalized"
|
||||
)));
|
||||
}
|
||||
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Workdir create operation `{operation_id}` disappeared"
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_workdir_create_operation(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
) -> Result<Option<WorkdirCreateOperationRecord>> {
|
||||
self.with_conn(|conn| read_workdir_create_operation(conn, workspace_id, operation_id))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_workdir_create_operation(
|
||||
conn: &rusqlite::Connection,
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
) -> Result<Option<WorkdirCreateOperationRecord>> {
|
||||
conn.query_row(
|
||||
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
|
||||
requested_runtime_id, resolved_runtime_id, config_revision,
|
||||
config_projection_digest, working_directory_id, state, failure,
|
||||
created_at, updated_at
|
||||
FROM workdir_create_operations
|
||||
WHERE workspace_id = ?1 AND operation_id = ?2"#,
|
||||
params![workspace_id, operation_id],
|
||||
|row| {
|
||||
Ok(WorkdirCreateOperationRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
operation_id: row.get(1)?,
|
||||
request_fingerprint: row.get(2)?,
|
||||
repository_id: row.get(3)?,
|
||||
selector: row.get(4)?,
|
||||
requested_runtime_id: row.get(5)?,
|
||||
resolved_runtime_id: row.get(6)?,
|
||||
config_revision: row.get::<_, i64>(7)? as u64,
|
||||
config_projection_digest: row.get(8)?,
|
||||
working_directory_id: row.get(9)?,
|
||||
state: row.get(10)?,
|
||||
failure: row.get(11)?,
|
||||
created_at: row.get(12)?,
|
||||
updated_at: row.get(13)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::{ControlPlaneStore, RepositoryRecord, WorkspaceRecord};
|
||||
|
||||
#[test]
|
||||
fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "workspace".to_string(),
|
||||
owner_account_id: None,
|
||||
display_name: "Workspace".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "2026-08-24T00:00:00Z".to_string(),
|
||||
updated_at: "2026-08-24T00:00:00Z".to_string(),
|
||||
}))
|
||||
.unwrap();
|
||||
store
|
||||
.upsert_repository(&RepositoryRecord {
|
||||
workspace_id: "workspace".to_string(),
|
||||
repository_id: "main".to_string(),
|
||||
name: "main".to_string(),
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
uri: "/tmp/main".to_string(),
|
||||
},
|
||||
default_ref: Some("develop".to_string()),
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: "2026-08-24T00:00:00Z".to_string(),
|
||||
updated_at: "2026-08-24T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let record = WorkdirCreateOperationRecord {
|
||||
workspace_id: "workspace".to_string(),
|
||||
operation_id: "call-1".to_string(),
|
||||
request_fingerprint: request_fingerprint("main", Some("develop"), None),
|
||||
repository_id: "main".to_string(),
|
||||
selector: Some("develop".to_string()),
|
||||
requested_runtime_id: None,
|
||||
resolved_runtime_id: "arcadia".to_string(),
|
||||
config_revision: 7,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
working_directory_id: "wd-1".to_string(),
|
||||
state: "pending".to_string(),
|
||||
failure: None,
|
||||
created_at: "2026-08-24T00:00:00Z".to_string(),
|
||||
updated_at: "2026-08-24T00:00:00Z".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
store.reserve_workdir_create_operation(&record).unwrap(),
|
||||
record
|
||||
);
|
||||
let mut changed_resolution = record.clone();
|
||||
changed_resolution.resolved_runtime_id = "other".to_string();
|
||||
changed_resolution.config_revision = 8;
|
||||
assert_eq!(
|
||||
store
|
||||
.reserve_workdir_create_operation(&changed_resolution)
|
||||
.unwrap(),
|
||||
record
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.load_workdir_create_operation("workspace", "call-1")
|
||||
.unwrap(),
|
||||
Some(record.clone())
|
||||
);
|
||||
let mut changed_input = record.clone();
|
||||
changed_input.request_fingerprint = request_fingerprint("main", Some("main"), None);
|
||||
assert!(
|
||||
store
|
||||
.reserve_workdir_create_operation(&changed_input)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("reused with different input")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,118 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use worker_runtime::auth::{
|
||||
WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims,
|
||||
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
|
||||
verify_worker_mutation_source_proof,
|
||||
RuntimeRequestSourceExpectation, WorkerMutationActorKind, WorkerMutationOperation,
|
||||
WorkerMutationSourceClaims, WorkerMutationSourceExpectation,
|
||||
decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
|
||||
verify_runtime_request_source, verify_worker_mutation_source_proof,
|
||||
};
|
||||
use worker_runtime::worker_source::InProcessWorkerMutationProof;
|
||||
|
||||
use crate::hosts::RemoteRuntimeConfig;
|
||||
use crate::server::WorkspaceApi;
|
||||
use crate::server::{ServerConfig, WorkspaceApi};
|
||||
use crate::store::ControlPlaneStore;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerifiedRuntimeRequestSource {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn verify_runtime_request_source_proof(
|
||||
api: &WorkspaceApi,
|
||||
proof: &str,
|
||||
workspace_id: &str,
|
||||
permission: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body_digest: &str,
|
||||
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
|
||||
verify_runtime_request_source_proof_with_store(
|
||||
api.store.as_ref(),
|
||||
&api.config,
|
||||
proof,
|
||||
workspace_id,
|
||||
permission,
|
||||
method,
|
||||
path,
|
||||
body_digest,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn verify_runtime_request_source_proof_with_store(
|
||||
store: &dyn ControlPlaneStore,
|
||||
config: &ServerConfig,
|
||||
proof: &str,
|
||||
workspace_id: &str,
|
||||
permission: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body_digest: &str,
|
||||
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
|
||||
let unverified = decode_runtime_request_source_claims(proof)
|
||||
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
|
||||
let audience = remote_audience(config, &unverified.iss, workspace_id)?;
|
||||
let trusted = store
|
||||
.get_trusted_runtime(&unverified.iss)
|
||||
.await
|
||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
||||
.filter(|record| record.revoked_at.is_none())
|
||||
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
||||
let trusted_for_workspace = trusted.workspace_id.as_deref() == Some(workspace_id)
|
||||
|| (unverified.iss == crate::hosts::EMBEDDED_RUNTIME_ID && trusted.workspace_id.is_none());
|
||||
if !trusted_for_workspace {
|
||||
return Err(WorkerMutationSourceProofError::WrongWorkspace);
|
||||
}
|
||||
let expected = RuntimeRequestSourceExpectation {
|
||||
identity_id: &unverified.iss,
|
||||
audience: audience.as_ref(),
|
||||
workspace_id,
|
||||
worker_id: unverified.worker_id.as_deref(),
|
||||
permission,
|
||||
method,
|
||||
path,
|
||||
body_digest,
|
||||
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||
};
|
||||
let claims = verify_runtime_request_source(proof, &trusted.public_key, &expected)
|
||||
.map_err(map_auth_error)?;
|
||||
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
|
||||
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
|
||||
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
|
||||
.ok_or(WorkerMutationSourceProofError::Expired)?
|
||||
.to_rfc3339();
|
||||
if !store
|
||||
.consume_worker_mutation_source_jti(
|
||||
&claims.iss,
|
||||
&claims.jti,
|
||||
expires_at,
|
||||
now_seconds,
|
||||
&consumed_at,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
||||
{
|
||||
return Err(WorkerMutationSourceProofError::Replay);
|
||||
}
|
||||
if let Some(worker_id) = claims.worker_id.as_deref() {
|
||||
let worker = worker_runtime::identity::RuntimeWorkerRef {
|
||||
runtime_id: claims.iss.clone(),
|
||||
worker_id: worker_id.to_owned(),
|
||||
};
|
||||
let member = store
|
||||
.get_worker_registry(workspace_id, &worker)
|
||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
||||
if member.is_none() {
|
||||
return Err(WorkerMutationSourceProofError::WorkerCatalogMembership);
|
||||
}
|
||||
}
|
||||
Ok(VerifiedRuntimeRequestSource {
|
||||
runtime_id: claims.iss,
|
||||
worker_id: claims.worker_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PresentedWorkerMutationSourceProof<'a> {
|
||||
@@ -97,16 +201,19 @@ async fn verify_worker_remove_source_with(
|
||||
PresentedWorkerMutationSourceProof::Remote(token) => {
|
||||
let unverified = decode_worker_mutation_source_claims(token)
|
||||
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
|
||||
let audience = remote_audience(config, &unverified.iss)?;
|
||||
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?;
|
||||
let trusted = store
|
||||
.get_trusted_runtime(&unverified.iss)
|
||||
.await
|
||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
||||
.filter(|record| record.revoked_at.is_none())
|
||||
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
||||
if trusted.workspace_id.as_deref() != Some(config.workspace_id.as_str()) {
|
||||
return Err(WorkerMutationSourceProofError::WrongWorkspace);
|
||||
}
|
||||
let expected = WorkerMutationSourceExpectation {
|
||||
runtime_id: &unverified.iss,
|
||||
audience,
|
||||
audience: audience.as_ref(),
|
||||
workspace_id: &config.workspace_id,
|
||||
worker_id: None,
|
||||
actor_kind: WorkerMutationActorKind::Worker,
|
||||
@@ -247,13 +354,17 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
|
||||
fn remote_audience<'a>(
|
||||
config: &'a crate::server::ServerConfig,
|
||||
runtime_id: &str,
|
||||
) -> Result<&'a str, WorkerMutationSourceProofError> {
|
||||
workspace_id: &str,
|
||||
) -> Result<std::borrow::Cow<'a, str>, WorkerMutationSourceProofError> {
|
||||
if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID {
|
||||
return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}")));
|
||||
}
|
||||
config
|
||||
.remote_runtime_sources
|
||||
.iter()
|
||||
.find(|runtime| runtime.runtime_id == runtime_id)
|
||||
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref())
|
||||
.map(|auth| auth.server_id.as_str())
|
||||
.map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str()))
|
||||
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
@@ -6,6 +5,9 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||
|
||||
use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
|
||||
use crate::store::{
|
||||
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
|
||||
};
|
||||
@@ -109,8 +111,8 @@ impl WorkspaceCatalogService {
|
||||
)?;
|
||||
let display_name =
|
||||
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
|
||||
let repository_path = validate_repository_uri(&request.repository.uri)?;
|
||||
let repository_uri = repository_path.to_string_lossy().into_owned();
|
||||
let repository_source = validate_repository_source(&request.repository.uri)?;
|
||||
let repository_uri = repository_source.uri.clone();
|
||||
let repository_name = request
|
||||
.repository
|
||||
.display_name
|
||||
@@ -166,10 +168,12 @@ impl WorkspaceCatalogService {
|
||||
name: repository_name,
|
||||
kind: "git".to_string(),
|
||||
provider: Some("git".to_string()),
|
||||
uri: repository_uri,
|
||||
source: repository_source.clone(),
|
||||
default_ref: Some(default_ref),
|
||||
auth_ref_kind: None,
|
||||
auth_ref_key: None,
|
||||
source_revision: 1,
|
||||
source_fingerprint: repository_source_fingerprint(&repository_source),
|
||||
observed_status: RepositoryObservedStatus::Unverified,
|
||||
observed_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -194,35 +198,8 @@ fn normalize_required(field: &str, value: String, max_bytes: usize) -> Result<St
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_repository_uri(uri: &str) -> Result<PathBuf> {
|
||||
let uri = uri.trim();
|
||||
if uri.is_empty() || uri.contains("://") {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository uri must be an absolute server-local path".to_string(),
|
||||
));
|
||||
}
|
||||
let path = Path::new(uri);
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository uri must be an absolute server-local path".to_string(),
|
||||
));
|
||||
}
|
||||
let path = path.canonicalize().map_err(|error| {
|
||||
Error::InvalidInput(format!("initial repository path is unavailable: {error}"))
|
||||
})?;
|
||||
if !path.is_dir() {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository path must be a directory".to_string(),
|
||||
));
|
||||
}
|
||||
let normal_git = path.join(".git").exists();
|
||||
let bare_git = path.join("HEAD").is_file() && path.join("objects").is_dir();
|
||||
if !normal_git && !bare_git {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial repository path is not a Git repository".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(path)
|
||||
fn validate_repository_source(uri: &str) -> Result<RepositorySource> {
|
||||
parse_repository_source(uri)
|
||||
}
|
||||
|
||||
fn workspace_create_fingerprint(
|
||||
@@ -259,6 +236,7 @@ fn workspace_create_fingerprint(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::SqliteWorkspaceStore;
|
||||
use workspace_api::RepositorySourceKind;
|
||||
|
||||
fn git_repository() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -383,12 +361,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_intent_rejects_remote_and_non_git_paths() {
|
||||
let remote = validate_repository_uri("https://example.test/repo.git").unwrap_err();
|
||||
assert!(remote.to_string().contains("server-local path"));
|
||||
fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
|
||||
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
|
||||
assert_eq!(remote.kind, RepositorySourceKind::Https);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let non_git = validate_repository_uri(&dir.path().display().to_string()).unwrap_err();
|
||||
assert!(non_git.to_string().contains("not a Git repository"));
|
||||
let local = validate_repository_source("/runtime-only/missing/repository").unwrap();
|
||||
assert_eq!(local.kind, RepositorySourceKind::LocalPath);
|
||||
assert_eq!(local.uri, "/runtime-only/missing/repository");
|
||||
|
||||
let file = validate_repository_source("file:///runtime-only/missing/repository").unwrap();
|
||||
assert_eq!(file.kind, RepositorySourceKind::File);
|
||||
|
||||
assert!(validate_repository_source("relative/repository").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
|
||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||
let service = WorkspaceCatalogService::new(store.clone());
|
||||
let result = service
|
||||
.create_first_ownerless(WorkspaceCreateRequest {
|
||||
operation_key: "remote-create".to_string(),
|
||||
display_name: "Remote Workspace".to_string(),
|
||||
repository: InitialRepositoryIntent {
|
||||
uri: "ssh://git@example.test/org/repository.git".to_string(),
|
||||
display_name: Some("Remote Repository".to_string()),
|
||||
default_ref: Some("main".to_string()),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let persisted = store
|
||||
.get_repository(
|
||||
&result.workspace.workspace_id,
|
||||
&result.repository.repository_id,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(persisted.source.kind, RepositorySourceKind::Ssh);
|
||||
assert_eq!(persisted.source_revision, 1);
|
||||
assert!(persisted.source_fingerprint.starts_with("sha256:"));
|
||||
assert_eq!(
|
||||
persisted.observed_status,
|
||||
RepositoryObservedStatus::Unverified
|
||||
);
|
||||
let json = serde_json::to_value(&persisted).unwrap();
|
||||
assert!(json.get("source").is_some());
|
||||
assert!(json.get("auth_ref_kind").is_none());
|
||||
assert!(json.get("auth_ref_key").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ Implementation normally happens in a child git worktree created by the Orchestra
|
||||
|
||||
### 5. Review
|
||||
|
||||
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with read-only scope and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval.
|
||||
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with write scope, so it can use the Workdir command tools required for inspection and validation, and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval.
|
||||
|
||||
The Reviewer records the structured result with `MergeRequestReview`. Request changes requires a new immutable revision and a fresh child attempt. The Orchestrator uses `MergeRequestReadinessCheck` and then `MergeRequestComplete` for guarded integration with operation-id dedupe/CAS semantics; Flow transitions are not completion authority.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
};
|
||||
|
||||
review = {
|
||||
instructions = "Use the current Ticket Merge Request as review authority. Confirm its immutable source selector resolves to the exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, read-only scope, and a structured review handoff bound to the current immutable Merge Request revision. The trusted spawn layer records `ReviewRequested`; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit MergeRequestReview; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition.";
|
||||
instructions = "Use the current Ticket Merge Request as review authority. Confirm its immutable source selector resolves to the exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope for Workdir inspection and command validation, and a structured review handoff bound to the current immutable Merge Request revision. The trusted spawn layer records `ReviewRequested`; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit MergeRequestReview; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition.";
|
||||
transitions = {
|
||||
approved = {
|
||||
target = "complete";
|
||||
@@ -29,7 +29,7 @@
|
||||
};
|
||||
|
||||
fix = {
|
||||
instructions = "Resolve every open Reviewer finding on the same Ticket work branch, rerun the validation affected by the fixes, commit the corrected implementation as a new revision, and preserve concrete evidence. Publish only the updated Ticket work branch with a normal non-force push, verify that the configured repository provider resolves the published source ref to the exact new HEAD, and update the linked Merge Request so its current revision records that same subject. Request review from a fresh read-only Reviewer child so the trusted spawn layer captures the new immutable subject. Do not rewrite the previously reviewed commit, claim approval from the prior request_changes review, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected committed revision is published and ready for a new independent review.";
|
||||
instructions = "Resolve every open Reviewer finding on the same Ticket work branch, rerun the validation affected by the fixes, commit the corrected implementation as a new revision, and preserve concrete evidence. Publish only the updated Ticket work branch with a normal non-force push, verify that the configured repository provider resolves the published source ref to the exact new HEAD, and update the linked Merge Request so its current revision records that same subject. Request review from a fresh Reviewer child with write scope so it can use the Workdir command tools required for inspection and validation while the trusted spawn layer captures the new immutable subject. Do not rewrite the previously reviewed commit, claim approval from the prior request_changes review, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected committed revision is published and ready for a new independent review.";
|
||||
transitions = {
|
||||
review = {
|
||||
target = "review";
|
||||
|
||||
@@ -4,6 +4,6 @@ Use the available typed Ticket tools as the authority for Ticket reads and mutat
|
||||
|
||||
Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
|
||||
|
||||
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
|
||||
Keep durable Ticket records centered on user intent, confirmed background, requirements, acceptance criteria, binding decisions, and implementation/review evidence. For implementation and review workflows, keep routine revision, verdict, fix, and rereview evidence on the Merge Request; use Ticket comments only for blockers or decisions requiring orchestration attention and the final approved handoff. Use `QueryObjective` for bounded Objective discovery and `ShowObjective` for authoritative revision and linked-Ticket context when coordinating broader work. Separate confirmed facts from user claims, hypotheses, and open questions. Avoid prematurely turning implementation tactics into requirements.
|
||||
|
||||
Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish implementation completion from review and closure, and perform only lifecycle actions supported by the tools and authority available to the current Worker.
|
||||
|
||||
@@ -5,8 +5,8 @@ The conversation input is a bounded overview/index, not the full transcript. Tre
|
||||
## Workflow
|
||||
|
||||
1. Read the provided overview/index and current TaskStore snapshot.
|
||||
2. If the overview does not contain enough detail, use `search_session_log` to find relevant compact-target history items, then `read_session_items` to inspect only the needed range.
|
||||
3. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
||||
2. If the provided index is not enough, call `ShowOverview` on the pinned capture. Use `SearchEntries` to locate relevant entries, then `ReadEntry` with the returned stable entry reference to inspect only what is needed.
|
||||
3. Use `Read` to inspect referenced Workdir files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
||||
4. For files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately.
|
||||
5. For files the next session should know about but can fetch on demand, call `add_reference` to record the path without embedding contents.
|
||||
6. Finish with `write_summary` carrying the final text. You may call it multiple times; only the last call is kept.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request.
|
||||
|
||||
Use the Merge Request as the routine authority for review requests, verdicts, fixes, and rereview cycles. Do not add a Ticket comment for each review or fix iteration. Add a Ticket comment only when a blocker or decision requires Orchestrator attention, or once after approval to hand off the final implementation and validation evidence.
|
||||
|
||||
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose.
|
||||
|
||||
Before opening a Merge Request, publish only the committed Ticket work branch with a normal non-force push and verify that the Ticket repository remote resolves it to the exact local `HEAD`; a local branch name or dirty Workdir is not immutable review evidence. Do not push the target branch, tags, or unrelated refs, and never force-push.
|
||||
|
||||
{% include "common.git" %}
|
||||
|
||||
Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReview` through its injected capability authority.
|
||||
Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate write scope so it can use the Workdir command tools required for inspection and validation, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReview` through its injected capability authority.
|
||||
|
||||
A request-changes result requires a freshly published immutable subject and a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. After the exact current Merge Request subject has authoritative approval, keep that source ref immutable, leave concise implementation evidence on the Ticket when useful, and hand off integration to the Orchestrator. Do not update the target selector. Do not call `MergeRequestComplete`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
|
||||
|
||||
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, update a repository ref, or take over implementation.
|
||||
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only, never as a supplied verdict. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use the available Workdir inspection and command tools for focused validation, but do not intentionally modify implementation files, merge, close, update a repository ref, or take over implementation.
|
||||
|
||||
Your prose response is not review authority. Before finishing, call `MergeRequestReview` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work.
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ export type CommandSnapshot = { command_id: string, tool_call_id: string | null,
|
||||
|
||||
export type CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, observed_at_ms: number, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, observed_at_ms: number, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, stdout_end_offset: number, stderr_end_offset: number, observed_at_ms: number, };
|
||||
|
||||
export type CompactionLifecycleState = "running" | "done" | "failed" | "interrupted";
|
||||
|
||||
export type CompactionLifecycle = { schema_version: number, compaction_id: string, revision: number, internal_worker?: InternalWorkerRef | null, state: CompactionLifecycleState,
|
||||
/**
|
||||
* Milliseconds since the Unix epoch.
|
||||
*/
|
||||
started_at_ms: number, ended_at_ms?: number | null, summary?: string | null, error?: string | null, new_segment_id?: string | null, };
|
||||
|
||||
export type ScopeRule = {
|
||||
/**
|
||||
* Target path. Must be absolute by the time a `Scope` is built from
|
||||
@@ -63,7 +71,7 @@ export type InFlightBlock = { "kind": "text", text: string, finished?: boolean,
|
||||
|
||||
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>, };
|
||||
|
||||
export type InternalWorkerKind = "sub_worker";
|
||||
export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
|
||||
|
||||
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
|
||||
|
||||
@@ -193,4 +201,4 @@ in_flight?: InFlightSnapshot,
|
||||
* Parent-owned Internal Worker sessions visible to this client.
|
||||
* Service-private Internal Workers are deliberately excluded.
|
||||
*/
|
||||
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
|
||||
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
|
||||
|
||||
@@ -21,7 +21,7 @@ export type TicketRoleAssignmentSummary = { assignment_id: string, role: string,
|
||||
|
||||
export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, };
|
||||
|
||||
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, blockers: Array<string>, };
|
||||
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, queue_tickets: Array<string>, blockers: Array<string>, };
|
||||
|
||||
export type TicketMergeRequestSummary = { merge_request_id: string, repository_id: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
||||
|
||||
|
||||
@@ -7,13 +7,29 @@ export type WorkspaceCatalogRecord = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type RepositorySourceKind =
|
||||
| "local_path"
|
||||
| "file"
|
||||
| "ssh"
|
||||
| "http"
|
||||
| "https"
|
||||
| "invalid";
|
||||
|
||||
export type WorkspaceRepositoryRecord = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
uri: string;
|
||||
provider: string | null;
|
||||
source: {
|
||||
kind: RepositorySourceKind;
|
||||
uri: string;
|
||||
};
|
||||
default_ref: string | null;
|
||||
source_revision: number;
|
||||
source_fingerprint: string;
|
||||
observed_status: "unverified" | "ready" | "invalid";
|
||||
observed_at: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
|
||||
|
||||
@@ -8,6 +8,33 @@
|
||||
};
|
||||
|
||||
let { item }: Props = $props();
|
||||
let nowMs = $state(Date.now());
|
||||
|
||||
$effect(() => {
|
||||
if (item.compaction?.state !== 'running') return;
|
||||
nowMs = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
nowMs = Date.now();
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
});
|
||||
|
||||
function compactionElapsedMs(line: ConsoleLine): number {
|
||||
const compaction = line.compaction;
|
||||
if (!compaction) return 0;
|
||||
return Math.max(0, (compaction.endedAtMs ?? nowMs) - compaction.startedAtMs);
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
|
||||
function compactionState(line: ConsoleLine): string {
|
||||
return line.compaction?.state.replace('_', ' ') ?? '';
|
||||
}
|
||||
|
||||
function lineClass(line: ConsoleLine): string {
|
||||
return line.error ? 'error' : line.kind;
|
||||
@@ -52,7 +79,29 @@
|
||||
class:error-line={item.error}
|
||||
data-console-line-id={item.id}
|
||||
>
|
||||
{#if shouldRenderHeading(item)}
|
||||
{#if item.compaction}
|
||||
<div class="compaction-heading">
|
||||
<span>Compaction · {compactionState(item)}</span>
|
||||
<span>{formatElapsed(compactionElapsedMs(item))}</span>
|
||||
</div>
|
||||
{#if item.compaction.activity.length > 0}
|
||||
<ul class="compaction-activity">
|
||||
{#each item.compaction.activity as activity}
|
||||
<li>{activity}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if item.compaction.summary}
|
||||
<div class="compaction-summary">{item.compaction.summary}</div>
|
||||
{:else if item.compaction.candidate}
|
||||
<div class="compaction-candidate">
|
||||
<span class="compaction-candidate-label">candidate</span>
|
||||
{item.compaction.candidate}
|
||||
</div>
|
||||
{:else if item.compaction.error}
|
||||
<div class="compaction-error">{item.compaction.error}</div>
|
||||
{/if}
|
||||
{:else if shouldRenderHeading(item)}
|
||||
<div class="message-heading">
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
@@ -63,7 +112,9 @@
|
||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.kind === 'tool'}
|
||||
{#if item.compaction}
|
||||
<!-- rendered as one lifecycle item above -->
|
||||
{:else if item.kind === 'tool'}
|
||||
{#if bodyTextAfterToolSummary(item)}
|
||||
<p class="console-plain-text">
|
||||
{#if isBashTool(item)}
|
||||
@@ -141,6 +192,43 @@
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.compaction-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.compaction-activity {
|
||||
margin: var(--space-1) 0;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--tui-dark-gray);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.compaction-summary,
|
||||
.compaction-candidate,
|
||||
.compaction-error {
|
||||
margin-top: var(--space-1);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.compaction-candidate-label {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.compaction-error {
|
||||
color: var(--tui-error);
|
||||
}
|
||||
|
||||
.activity-summary {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -647,42 +647,173 @@ Deno.test("projectConsole renders alert events", () => {
|
||||
assertEquals(projection.lines[1].error, true);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole shows compact progress as a status block", () => {
|
||||
Deno.test("projectConsole upserts compaction lifecycle by stable id", () => {
|
||||
const running = {
|
||||
schema_version: 2,
|
||||
compaction_id: "compaction-1",
|
||||
revision: 1,
|
||||
internal_worker: null,
|
||||
state: "running",
|
||||
started_at_ms: 1_000,
|
||||
ended_at_ms: null,
|
||||
summary: null,
|
||||
error: null,
|
||||
new_segment_id: null,
|
||||
} as const;
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "compact-1",
|
||||
event: { event: "compact_start" } satisfies Event,
|
||||
event: { event: "compact_start", data: { lifecycle: running } } satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(projection.lines.length, 1);
|
||||
assertEquals(projection.lines[0].id, "status-compact");
|
||||
assertEquals(projection.lines[0].kind, "status");
|
||||
assertEquals(projection.lines[0].body, "Compacting…");
|
||||
assertEquals(projection.lines[0].id, "compaction-compaction-1");
|
||||
assertEquals(projection.lines[0].compaction?.state, "running");
|
||||
assertEquals(projection.lines[0].streaming, true);
|
||||
|
||||
const completed = projectConsole([
|
||||
{
|
||||
eventId: "compact-1",
|
||||
event: { event: "compact_start" } satisfies Event,
|
||||
event: { event: "compact_start", data: { lifecycle: running } } satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "compact-2",
|
||||
event: {
|
||||
event: "compact_done",
|
||||
data: { new_segment_id: "00000000-0000-0000-0000-000000000001" },
|
||||
data: {
|
||||
lifecycle: {
|
||||
...running,
|
||||
revision: 2,
|
||||
state: "done",
|
||||
ended_at_ms: 4_000,
|
||||
summary: "accepted summary",
|
||||
new_segment_id: "00000000-0000-0000-0000-000000000001",
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(completed.lines.length, 1);
|
||||
assertEquals(completed.lines[0].id, "status-compact");
|
||||
assertEquals(completed.lines[0].body, "Compacted.");
|
||||
assertEquals(completed.lines[0].id, "compaction-compaction-1");
|
||||
assertEquals(completed.lines[0].compaction?.state, "done");
|
||||
assertEquals(completed.lines[0].compaction?.summary, "accepted summary");
|
||||
assertEquals(completed.lines[0].streaming, false);
|
||||
});
|
||||
|
||||
Deno.test("createConsoleProjector updates only compact status block", () => {
|
||||
Deno.test("compaction service activity stays nested in one lifecycle item", () => {
|
||||
const worker = {
|
||||
session_id: "compactor-session",
|
||||
name: "Compaction",
|
||||
parent_session_id: "parent-session",
|
||||
kind: { service: { kind: "compaction" } },
|
||||
} as const;
|
||||
const lifecycle = {
|
||||
schema_version: 2,
|
||||
compaction_id: "compaction-nested",
|
||||
revision: 2,
|
||||
internal_worker: worker,
|
||||
state: "running",
|
||||
started_at_ms: 1_000,
|
||||
ended_at_ms: null,
|
||||
summary: null,
|
||||
error: null,
|
||||
new_segment_id: null,
|
||||
} as const;
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "compaction-start",
|
||||
event: { event: "compact_start", data: { lifecycle } } satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "compaction-tool",
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 1,
|
||||
event: {
|
||||
event: "tool_call_start",
|
||||
data: { id: "call-1", name: "write_summary" },
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "compaction-tool-done",
|
||||
event: {
|
||||
event: "internal_worker",
|
||||
data: {
|
||||
worker,
|
||||
revision: 2,
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "call-1",
|
||||
name: "write_summary",
|
||||
arguments: JSON.stringify({ text: "draft candidate" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(projection.lines.length, 1);
|
||||
assertEquals(projection.lines[0].id, "compaction-compaction-nested");
|
||||
assertEquals(projection.lines[0].compaction?.activity, ["write_summary — running"]);
|
||||
assertEquals(projection.lines[0].compaction?.candidate, "draft candidate");
|
||||
assert(
|
||||
consoleWorkerViews(projection).length === 1,
|
||||
"service is not a selectable SubWorker pane",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("snapshot normalizes orphaned running compaction to interrupted", () => {
|
||||
const projection = projectConsole([{
|
||||
eventId: "snapshot",
|
||||
observedAtMs: 9_000,
|
||||
event: snapshotEvent("/repo", [{
|
||||
kind: "extension",
|
||||
ts: 1,
|
||||
domain: "yoi.compaction",
|
||||
payload: {
|
||||
schema_version: 2,
|
||||
compaction_id: "compaction-orphaned",
|
||||
revision: 2,
|
||||
internal_worker: {
|
||||
session_id: "missing-service",
|
||||
name: "Compaction",
|
||||
parent_session_id: "parent",
|
||||
kind: { service: { kind: "compaction" } },
|
||||
},
|
||||
state: "running",
|
||||
started_at_ms: 1_000,
|
||||
},
|
||||
}]),
|
||||
}]);
|
||||
|
||||
assertEquals(projection.lines.length, 1);
|
||||
assertEquals(projection.lines[0].compaction?.state, "interrupted");
|
||||
assertEquals(projection.lines[0].compaction?.endedAtMs, 9_000);
|
||||
assertEquals(projection.lines[0].streaming, false);
|
||||
});
|
||||
|
||||
Deno.test("createConsoleProjector ignores stale compaction revisions", () => {
|
||||
const projector = createConsoleProjector();
|
||||
const base = {
|
||||
schema_version: 2,
|
||||
compaction_id: "compaction-identity",
|
||||
revision: 1,
|
||||
internal_worker: null,
|
||||
state: "running",
|
||||
started_at_ms: 1_000,
|
||||
ended_at_ms: null,
|
||||
summary: null,
|
||||
error: null,
|
||||
new_segment_id: null,
|
||||
} as const;
|
||||
let projection = projector.append([
|
||||
{
|
||||
eventId: "compact-identity-1",
|
||||
@@ -693,31 +824,37 @@ Deno.test("createConsoleProjector updates only compact status block", () => {
|
||||
},
|
||||
{
|
||||
eventId: "compact-identity-2",
|
||||
event: { event: "compact_start" } satisfies Event,
|
||||
event: { event: "compact_start", data: { lifecycle: base } } satisfies Event,
|
||||
},
|
||||
]);
|
||||
const userLine = projection.lines[0];
|
||||
const compactLine = projection.lines[1];
|
||||
|
||||
projection = projector.append([
|
||||
{
|
||||
eventId: "compact-identity-3",
|
||||
event: {
|
||||
event: "compact_done",
|
||||
data: { new_segment_id: "00000000-0000-0000-0000-000000000001" },
|
||||
data: {
|
||||
lifecycle: {
|
||||
...base,
|
||||
revision: 2,
|
||||
state: "done",
|
||||
ended_at_ms: 2_000,
|
||||
summary: "accepted",
|
||||
new_segment_id: "00000000-0000-0000-0000-000000000001",
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "compact-identity-stale",
|
||||
event: { event: "compact_start", data: { lifecycle: base } } satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assert(
|
||||
projection.lines[0] === userLine,
|
||||
"unrelated message line should keep object identity",
|
||||
);
|
||||
assert(
|
||||
projection.lines[1] !== compactLine,
|
||||
"compact status line should update object identity",
|
||||
);
|
||||
assertEquals(projection.lines[1].body, "Compacted.");
|
||||
assert(projection.lines[0] === userLine, "unrelated line retains identity");
|
||||
assertEquals(projection.lines[1].compaction?.state, "done");
|
||||
assertEquals(projection.lines[1].compaction?.summary, "accepted");
|
||||
});
|
||||
|
||||
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CommandEvent,
|
||||
CommandSnapshot,
|
||||
CommandStreamSlice,
|
||||
CompactionLifecycle,
|
||||
Event as ProtocolEvent,
|
||||
InFlightBlock,
|
||||
InFlightToolCallState,
|
||||
@@ -67,12 +68,26 @@ export type ConsoleDiffLine = {
|
||||
|
||||
export type ConsoleViewMode = "overview" | "normal";
|
||||
|
||||
export type ConsoleCompaction = {
|
||||
id: string;
|
||||
revision: number;
|
||||
state: "running" | "done" | "failed" | "interrupted";
|
||||
startedAtMs: number;
|
||||
endedAtMs?: number;
|
||||
summary?: string;
|
||||
candidate?: string;
|
||||
error?: string;
|
||||
internalWorkerSessionId?: string;
|
||||
activity: string[];
|
||||
};
|
||||
|
||||
export type ConsoleLine = {
|
||||
id: string;
|
||||
kind: ConsoleLineKind;
|
||||
title: string;
|
||||
body: string;
|
||||
detail?: string;
|
||||
compaction?: ConsoleCompaction;
|
||||
diff?: ConsoleDiffLine[];
|
||||
eventId?: string | null;
|
||||
source: "event";
|
||||
@@ -114,16 +129,17 @@ export type ConsoleWorkerView = {
|
||||
export function consoleWorkerViews(
|
||||
projection: ConsoleProjection,
|
||||
): ConsoleWorkerView[] {
|
||||
const labels = projection.internalWorkers.map((worker) =>
|
||||
worker.worker.name || "subworker"
|
||||
const children = projection.internalWorkers.filter((worker) =>
|
||||
worker.worker.kind === "sub_worker"
|
||||
);
|
||||
const labels = children.map((worker) => worker.worker.name || "subworker");
|
||||
const labelCounts = new Map<string, number>();
|
||||
for (const label of labels) {
|
||||
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
||||
}
|
||||
return [
|
||||
{ sessionId: null, label: "main", console: projection },
|
||||
...projection.internalWorkers.map((worker, index) => {
|
||||
...children.map((worker, index) => {
|
||||
const label = labels[index] ?? "subworker";
|
||||
return {
|
||||
sessionId: worker.worker.session_id,
|
||||
@@ -664,6 +680,105 @@ function projectInternalWorkerSnapshot(
|
||||
return { worker: snapshot.worker, revision: snapshot.revision, console };
|
||||
}
|
||||
|
||||
function compactionCandidate(
|
||||
projection: ConsoleProjection,
|
||||
sessionId: string | undefined,
|
||||
): string | undefined {
|
||||
if (!sessionId) return undefined;
|
||||
const worker = projection.internalWorkers.find(
|
||||
(candidate) => candidate.worker.session_id === sessionId,
|
||||
);
|
||||
const calls = worker?.console.lines
|
||||
.map((line) => line.toolCall)
|
||||
.filter((call): call is ToolCallView => call?.name === "write_summary") ?? [];
|
||||
const latest = calls.at(-1);
|
||||
const raw = latest?.arguments ?? latest?.argsStream;
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { text?: unknown };
|
||||
return typeof parsed.text === "string" ? parsed.text : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function compactionActivity(
|
||||
projection: ConsoleProjection,
|
||||
sessionId: string | undefined
|
||||
): string[] {
|
||||
if (!sessionId) return [];
|
||||
const worker = projection.internalWorkers.find(
|
||||
(candidate) => candidate.worker.session_id === sessionId
|
||||
);
|
||||
if (!worker) return [];
|
||||
return worker.console.lines
|
||||
.filter((line) => line.kind === "tool" || line.kind === "status" || line.kind === "error")
|
||||
.slice(-12)
|
||||
.map((line) =>
|
||||
line.toolCall?.name === "write_summary"
|
||||
? `write_summary — ${line.toolCall.state}`
|
||||
: line.body || line.title
|
||||
)
|
||||
.filter((value, index, values) => value.length > 0 && values.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function applyCompactionLifecycle(
|
||||
projection: ConsoleProjection,
|
||||
lifecycle: CompactionLifecycle
|
||||
): ConsoleProjection {
|
||||
const lineId = `compaction-${lifecycle.compaction_id}`;
|
||||
const existing = projection.lines.find((line) => line.id === lineId)?.compaction;
|
||||
if (existing && existing.revision >= lifecycle.revision) return projection;
|
||||
const internalWorkerSessionId = lifecycle.internal_worker?.session_id;
|
||||
const compaction: ConsoleCompaction = {
|
||||
id: lifecycle.compaction_id,
|
||||
revision: lifecycle.revision,
|
||||
state: lifecycle.state,
|
||||
startedAtMs: lifecycle.started_at_ms,
|
||||
endedAtMs: lifecycle.ended_at_ms ?? undefined,
|
||||
summary: lifecycle.summary ?? undefined,
|
||||
candidate: compactionCandidate(projection, internalWorkerSessionId),
|
||||
error: lifecycle.error ?? undefined,
|
||||
internalWorkerSessionId,
|
||||
activity: compactionActivity(projection, internalWorkerSessionId)
|
||||
};
|
||||
const line: ConsoleLine = {
|
||||
id: lineId,
|
||||
source: "event",
|
||||
kind: compaction.state === "failed" ? "error" : "status",
|
||||
title: "Compaction",
|
||||
body: compaction.summary ?? compaction.error ?? "",
|
||||
streaming: compaction.state === "running",
|
||||
error: compaction.state === "failed",
|
||||
compaction
|
||||
};
|
||||
const index = projection.lines.findIndex((candidate) => candidate.id === lineId);
|
||||
const lines = [...projection.lines];
|
||||
if (index >= 0) lines[index] = line;
|
||||
else lines.push(line);
|
||||
return { ...projection, lines };
|
||||
}
|
||||
|
||||
function refreshCompactionActivity(
|
||||
projection: ConsoleProjection,
|
||||
sessionId: string
|
||||
): ConsoleProjection {
|
||||
let changed = false;
|
||||
const lines = projection.lines.map((line) => {
|
||||
if (line.compaction?.internalWorkerSessionId !== sessionId) return line;
|
||||
changed = true;
|
||||
return {
|
||||
...line,
|
||||
compaction: {
|
||||
...line.compaction,
|
||||
candidate: compactionCandidate(projection, sessionId),
|
||||
activity: compactionActivity(projection, sessionId)
|
||||
}
|
||||
};
|
||||
});
|
||||
return changed ? { ...projection, lines } : projection;
|
||||
}
|
||||
|
||||
export function applyProtocolEvent(
|
||||
projection: ConsoleProjection,
|
||||
envelope: ConsoleEventInput,
|
||||
@@ -809,6 +924,31 @@ export function applyProtocolEvent(
|
||||
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
|
||||
);
|
||||
next.removedInternalWorkers = {};
|
||||
for (const line of next.lines) {
|
||||
const compaction = line.compaction;
|
||||
if (!compaction) continue;
|
||||
const sessionId = compaction.internalWorkerSessionId;
|
||||
if (sessionId) {
|
||||
line.compaction = {
|
||||
...compaction,
|
||||
candidate: compactionCandidate(next, sessionId),
|
||||
activity: compactionActivity(next, sessionId),
|
||||
};
|
||||
}
|
||||
if (
|
||||
compaction.state === "running" &&
|
||||
(!sessionId || !next.internalWorkers.some((worker) =>
|
||||
worker.worker.session_id === sessionId
|
||||
))
|
||||
) {
|
||||
line.streaming = false;
|
||||
line.compaction = {
|
||||
...line.compaction!,
|
||||
state: "interrupted",
|
||||
endedAtMs: envelope.observedAtMs ?? Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "internal_worker": {
|
||||
@@ -841,7 +981,7 @@ export function applyProtocolEvent(
|
||||
};
|
||||
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
|
||||
else next.internalWorkers.push(updated);
|
||||
break;
|
||||
return refreshCompactionActivity(next, event.data.worker.session_id);
|
||||
}
|
||||
case "internal_worker_removed": {
|
||||
const existingIndex = next.internalWorkers.findIndex((worker) =>
|
||||
@@ -905,21 +1045,9 @@ export function applyProtocolEvent(
|
||||
// them to the conversation surface; browser Console should not either.
|
||||
break;
|
||||
case "compact_start":
|
||||
upsertStatusLine(next, "compact", envelope.eventId, "Compacting…", true);
|
||||
break;
|
||||
case "compact_done":
|
||||
upsertStatusLine(next, "compact", envelope.eventId, "Compacted.", false);
|
||||
break;
|
||||
case "compact_failed":
|
||||
upsertStatusLine(
|
||||
next,
|
||||
"compact",
|
||||
envelope.eventId,
|
||||
`Compact failed: ${event.data.error}`,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
break;
|
||||
return applyCompactionLifecycle(next, event.data.lifecycle);
|
||||
case "shutdown":
|
||||
next.status = "shutdown";
|
||||
break;
|
||||
@@ -1880,7 +2008,22 @@ function applyExtensionEntry(
|
||||
return;
|
||||
}
|
||||
const payload = entry["payload"];
|
||||
if (!isRecord(payload) || payload["kind"] !== "compaction_block") {
|
||||
if (!isRecord(payload)) return;
|
||||
if (
|
||||
typeof payload["compaction_id"] === "string" &&
|
||||
typeof payload["revision"] === "number" &&
|
||||
typeof payload["state"] === "string" &&
|
||||
typeof payload["started_at_ms"] === "number"
|
||||
) {
|
||||
const updated = applyCompactionLifecycle(
|
||||
projection,
|
||||
payload as unknown as CompactionLifecycle,
|
||||
);
|
||||
projection.lines = updated.lines;
|
||||
return;
|
||||
}
|
||||
// Schema v1 remains readable historical evidence.
|
||||
if (payload["kind"] !== "compaction_block") {
|
||||
return;
|
||||
}
|
||||
const blockId = stringField(payload, "block_id") || "compact";
|
||||
|
||||
@@ -926,8 +926,9 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
|
||||
);
|
||||
assert(
|
||||
consoleModel.includes("consoleWorkerViews") &&
|
||||
consoleModel.includes("projection.internalWorkers.map") &&
|
||||
consoleModel.includes('worker.worker.kind === "sub_worker"') &&
|
||||
consoleModel.includes("children.map") &&
|
||||
consoleModel.includes("resolveConsoleWorkerView"),
|
||||
"Worker view selection should use direct Internal Worker session identities with main fallback",
|
||||
"Worker view selection should expose only direct SubWorker session identities with main fallback",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -38,14 +38,36 @@ Deno.test("settings section navigation stays under the settings route", () => {
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("settings shell advertises no fake browser admin model", () => {
|
||||
Deno.test("settings shell advertises scoped account authority", () => {
|
||||
assert(
|
||||
SETTINGS_PERMISSION_NOTICE.includes("no browser user, role, permission"),
|
||||
"notice should explicitly deny a browser permission model",
|
||||
SETTINGS_PERMISSION_NOTICE.includes("authenticated account authority"),
|
||||
"notice should identify authenticated account authority",
|
||||
);
|
||||
assert(
|
||||
SETTINGS_PERMISSION_NOTICE.includes("does not create an admin role"),
|
||||
"notice should not imply an admin role exists",
|
||||
SETTINGS_PERMISSION_NOTICE.includes("current Workspace owner"),
|
||||
"notice should state the Repository secret permission boundary",
|
||||
);
|
||||
assert(
|
||||
SETTINGS_PERMISSION_NOTICE.includes("does not expose secret material"),
|
||||
"notice should not imply that stored secret material is readable",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository access settings are editable and canonically routed", () => {
|
||||
const section = SETTINGS_SECTIONS.find((entry) =>
|
||||
entry.id === "repository-access"
|
||||
);
|
||||
assert(
|
||||
section?.status === "editable",
|
||||
"Repository Access should be editable",
|
||||
);
|
||||
assert(
|
||||
settingsSectionHref("repository-access") === "/settings/repository-access",
|
||||
"Repository Access should have a dedicated settings route",
|
||||
);
|
||||
assert(
|
||||
section?.bullets.join("\n").includes("write-only"),
|
||||
"Repository Access copy should preserve write-only secret semantics",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SettingsSectionId =
|
||||
| "runtime-connections"
|
||||
| "runtime-inventory"
|
||||
| "configuration-sources"
|
||||
| "repository-access"
|
||||
| "profile-sources"
|
||||
| "backend-config"
|
||||
| "workspace-identity";
|
||||
@@ -72,7 +73,7 @@ export type RemoteRuntimeTestResponse = {
|
||||
export const SETTINGS_ROUTE = "/settings";
|
||||
|
||||
export const SETTINGS_PERMISSION_NOTICE =
|
||||
"Yoi currently has no browser user, role, permission, or multi-user authorization model. This local settings surface uses typed Backend APIs only; it does not create an admin role or grant broad mutation authority.";
|
||||
"Workspace settings use authenticated account authority and Workspace-scoped typed Backend APIs. Repository secret management requires the current Workspace owner; this surface does not expose secret material or grant Runtime execution authority.";
|
||||
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
{
|
||||
@@ -111,6 +112,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
"Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "repository-access",
|
||||
label: "Repository Access",
|
||||
status: "editable",
|
||||
summary:
|
||||
"Manage Workspace-scoped SSH credentials and pinned host keys without exposing stored secret material.",
|
||||
bullets: [
|
||||
"Private keys and passphrases are write-only; list and detail responses contain public metadata only.",
|
||||
"Host trust requires an explicitly pinned key and never uses accept-new or TOFU.",
|
||||
"Repository bindings are committed through the shared Workspace configuration editor and validated against these records.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "profile-sources",
|
||||
label: "Profile Sources",
|
||||
@@ -175,6 +188,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
||||
return `${SETTINGS_ROUTE}/runtimes`;
|
||||
case "configuration-sources":
|
||||
return `${SETTINGS_ROUTE}/configuration`;
|
||||
case "repository-access":
|
||||
return `${SETTINGS_ROUTE}/repository-access`;
|
||||
case "profile-sources":
|
||||
return `${SETTINGS_ROUTE}/profiles`;
|
||||
case "workspace-identity":
|
||||
|
||||
@@ -269,6 +269,14 @@ export type RepositorySummary = {
|
||||
display_name: string;
|
||||
kind: string;
|
||||
provider: string;
|
||||
source: {
|
||||
kind: "local_path" | "file" | "ssh" | "http" | "https" | "invalid";
|
||||
uri: string;
|
||||
};
|
||||
source_revision: number;
|
||||
source_fingerprint: string;
|
||||
observed_status: "unverified" | "ready" | "invalid";
|
||||
observed_at?: string | null;
|
||||
default_selector?: string | null;
|
||||
record_authority: string;
|
||||
git?: GitRepositorySummary | null;
|
||||
|
||||
@@ -119,6 +119,14 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
|
||||
);
|
||||
|
||||
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
|
||||
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
|
||||
assertEquals(source.includes("ticket.action_eligibility.queue_tickets"), true);
|
||||
assertEquals(source.includes("This operation queues:"), true);
|
||||
assertEquals(source.includes("outcome.queued_tickets.join"), true);
|
||||
assertEquals(
|
||||
source.includes("resolve the listed blockers before Queue"),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
|
||||
true,
|
||||
|
||||
@@ -155,8 +155,7 @@
|
||||
<p class="workspace-catalog-eyebrow">New team space</p>
|
||||
<h2 id="workspace-create-title">Create Workspace</h2>
|
||||
<p>
|
||||
Repository paths and URIs are interpreted by the Backend. Browser-local paths are
|
||||
not authority.
|
||||
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
||||
</p>
|
||||
</div>
|
||||
<form onsubmit={submitCreation}>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div>
|
||||
<h3>{data.repository.item.display_name}</h3>
|
||||
</div>
|
||||
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
|
||||
<span class="status-pill" class:warn={data.repository.item.observed_status !== 'ready'}>{data.repository.item.observed_status}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
@@ -27,6 +27,22 @@
|
||||
<dt>Provider</dt>
|
||||
<dd>{data.repository.item.provider}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd>{data.repository.item.source.kind} · {data.repository.item.source.uri}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Repository access</dt>
|
||||
<dd><a href={`/w/${encodeURIComponent(data.workspace.workspace_id)}/settings/repository-access`}>Manage SSH credentials and pinned host keys</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source revision</dt>
|
||||
<dd>{data.repository.item.source_revision} · {data.repository.item.source_fingerprint}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Observed</dt>
|
||||
<dd>{data.repository.item.observed_at ?? 'not observed'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
<dd>{data.repository.item.record_authority}</dd>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageProps } from './$types';
|
||||
import type { RepositorySshCredential, RepositorySshHostTrust } from './+page';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||
let message = $state<string | null>(null);
|
||||
let pending = $state(false);
|
||||
|
||||
let credentialId = $state('');
|
||||
let credentialName = $state('');
|
||||
let privateKey = $state('');
|
||||
let passphrase = $state('');
|
||||
let rotateCredentialId = $state<string | null>(null);
|
||||
let rotatePrivateKey = $state('');
|
||||
let rotatePassphrase = $state('');
|
||||
|
||||
let hostTrustId = $state('');
|
||||
let hostname = $state('');
|
||||
let port = $state(22);
|
||||
let hostKey = $state('');
|
||||
let hostExpectedRevision = $state<number | null>(null);
|
||||
|
||||
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
|
||||
|
||||
function operationId(prefix: string): string {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, method: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = `request failed (${response.status})`;
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string; message?: string };
|
||||
detail = payload.message ?? payload.error ?? detail;
|
||||
} catch {
|
||||
// Do not surface submitted secret values from response bodies.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function createCredential() {
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const created = await request<RepositorySshCredential>('/credentials', 'POST', {
|
||||
operation_id: operationId('credential-create'),
|
||||
credential_id: credentialId,
|
||||
name: credentialName,
|
||||
private_key: privateKey,
|
||||
passphrase: passphrase || null
|
||||
});
|
||||
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
||||
credentialId = '';
|
||||
credentialName = '';
|
||||
message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Credential creation failed';
|
||||
} finally {
|
||||
privateKey = '';
|
||||
passphrase = '';
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateCredential(credential: RepositorySshCredential) {
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const rotated = await request<RepositorySshCredential>(
|
||||
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
|
||||
'POST',
|
||||
{
|
||||
operation_id: operationId('credential-rotate'),
|
||||
expected_revision: credential.current_revision,
|
||||
private_key: rotatePrivateKey,
|
||||
passphrase: rotatePassphrase || null
|
||||
}
|
||||
);
|
||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||
rotateCredentialId = null;
|
||||
message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Credential rotation failed';
|
||||
} finally {
|
||||
rotatePrivateKey = '';
|
||||
rotatePassphrase = '';
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCredential(credential: RepositorySshCredential) {
|
||||
if (!confirm(`Delete credential ${credential.credential_id}?`)) return;
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', {
|
||||
operation_id: operationId('credential-delete'),
|
||||
expected_revision: credential.current_revision
|
||||
});
|
||||
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
||||
message = `Credential ${credential.credential_id} deleted.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Credential deletion failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createHostTrust() {
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const created = await request<RepositorySshHostTrust>('/host-trusts', 'POST', {
|
||||
operation_id: operationId('host-trust-create'),
|
||||
host_trust_id: hostTrustId,
|
||||
hostname,
|
||||
port,
|
||||
host_key: hostKey,
|
||||
expected_revision: hostExpectedRevision
|
||||
});
|
||||
hostTrusts = hostExpectedRevision === null
|
||||
? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id))
|
||||
: hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry);
|
||||
hostTrustId = '';
|
||||
hostname = '';
|
||||
port = 22;
|
||||
hostKey = '';
|
||||
hostExpectedRevision = null;
|
||||
message = `Host trust ${created.host_trust_id} saved at revision ${created.current_revision}.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Host trust creation failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editHostTrust(hostTrust: RepositorySshHostTrust) {
|
||||
hostTrustId = hostTrust.host_trust_id;
|
||||
hostname = hostTrust.hostname;
|
||||
port = hostTrust.port;
|
||||
hostKey = hostTrust.host_key;
|
||||
hostExpectedRevision = hostTrust.current_revision;
|
||||
}
|
||||
|
||||
async function deleteHostTrust(hostTrust: RepositorySshHostTrust) {
|
||||
if (!confirm(`Delete host trust ${hostTrust.host_trust_id}?`)) return;
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', {
|
||||
operation_id: operationId('host-trust-delete'),
|
||||
expected_revision: hostTrust.current_revision
|
||||
});
|
||||
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
||||
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Host trust deletion failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Repository Access · Yoi Workspace</title></svelte:head>
|
||||
|
||||
<section class="card settings-section">
|
||||
<header class="settings-section-header">
|
||||
<div><p class="eyebrow">owner only</p><h2>Repository Access</h2></div>
|
||||
<span class="badge success">encrypted</span>
|
||||
</header>
|
||||
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
|
||||
{#if message}<p class="status-message">{message}</p>{/if}
|
||||
|
||||
<div class="settings-runtime-list">
|
||||
<h3>SSH credentials</h3>
|
||||
{#if credentials.length === 0}<p>No credentials configured.</p>{/if}
|
||||
{#each credentials as credential (credential.credential_id)}
|
||||
<div class="card">
|
||||
<strong>{credential.name}</strong> <code>{credential.credential_id}</code>
|
||||
<p>{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}</p>
|
||||
<p>References: {credential.referenced_repositories.join(', ') || 'none'}</p>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => (rotateCredentialId = rotateCredentialId === credential.credential_id ? null : credential.credential_id)}>Rotate</button>
|
||||
<button type="button" class="danger" disabled={pending || credential.referenced_repositories.length > 0} onclick={() => void deleteCredential(credential)}>Delete</button>
|
||||
</div>
|
||||
{#if rotateCredentialId === credential.credential_id}
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void rotateCredential(credential); }}>
|
||||
<label><span>New private key</span><textarea bind:value={rotatePrivateKey} required rows="8" autocomplete="off"></textarea></label>
|
||||
<label><span>Passphrase (only for an encrypted key)</span><input type="password" bind:value={rotatePassphrase} autocomplete="new-password" /></label>
|
||||
<button type="submit" disabled={pending}>Rotate credential</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
|
||||
<h3>Add SSH credential</h3>
|
||||
<label><span>Credential id</span><input bind:value={credentialId} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
||||
<label><span>Name</span><input bind:value={credentialName} required maxlength="200" /></label>
|
||||
<label><span>OpenSSH private key (ssh-ed25519)</span><textarea bind:value={privateKey} required rows="10" autocomplete="off"></textarea></label>
|
||||
<label><span>Passphrase (only for an encrypted key)</span><input type="password" bind:value={passphrase} autocomplete="new-password" /></label>
|
||||
<button type="submit" disabled={pending}>Add credential</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-runtime-list">
|
||||
<h3>Pinned SSH host keys</h3>
|
||||
{#if hostTrusts.length === 0}<p>No host trust records configured.</p>{/if}
|
||||
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
|
||||
<div class="card">
|
||||
<strong>{hostTrust.hostname}:{hostTrust.port}</strong> <code>{hostTrust.host_trust_id}</code>
|
||||
<p>{hostTrust.key_algorithm} · {hostTrust.fingerprint} · revision {hostTrust.current_revision}</p>
|
||||
<p>References: {hostTrust.referenced_repositories.join(', ') || 'none'}</p>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => editHostTrust(hostTrust)}>Rotate key</button>
|
||||
<button type="button" class="danger" disabled={pending || hostTrust.referenced_repositories.length > 0} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createHostTrust(); }}>
|
||||
<h3>{hostExpectedRevision === null ? 'Add pinned host key' : 'Rotate pinned host key'}</h3>
|
||||
<label><span>Host trust id</span><input bind:value={hostTrustId} disabled={hostExpectedRevision !== null} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
||||
<label><span>Hostname</span><input bind:value={hostname} required /></label>
|
||||
<label><span>Port</span><input type="number" bind:value={port} min="1" max="65535" required /></label>
|
||||
<label><span>OpenSSH public host key (ssh-ed25519)</span><textarea bind:value={hostKey} required rows="4"></textarea></label>
|
||||
<button type="submit" disabled={pending}>{hostExpectedRevision === null ? 'Add host key' : 'Save new revision'}</button>
|
||||
{#if hostExpectedRevision !== null}<button type="button" onclick={() => { hostTrustId = ''; hostname = ''; port = 22; hostKey = ''; hostExpectedRevision = null; }}>Cancel</button>{/if}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
import { loadJson } from "$lib/workspace/api/http";
|
||||
|
||||
export interface RepositorySshCredential {
|
||||
credential_id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
public_key_algorithm: string;
|
||||
public_key_fingerprint: string;
|
||||
current_revision: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
rotated_at: string | null;
|
||||
referenced_repositories: string[];
|
||||
}
|
||||
|
||||
export interface RepositorySshHostTrust {
|
||||
host_trust_id: string;
|
||||
workspace_id: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
key_algorithm: string;
|
||||
host_key: string;
|
||||
fingerprint: string;
|
||||
current_revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
referenced_repositories: string[];
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const base = `/api/w/${
|
||||
encodeURIComponent(params.workspaceId)
|
||||
}/settings/repository-access`;
|
||||
const [credentialResult, hostTrustResult] = await Promise.all([
|
||||
loadJson<RepositorySshCredential[]>(fetch, `${base}/credentials`),
|
||||
loadJson<RepositorySshHostTrust[]>(fetch, `${base}/host-trusts`),
|
||||
]);
|
||||
if (!credentialResult.data || !hostTrustResult.data) {
|
||||
throw new Error(
|
||||
credentialResult.error ?? hostTrustResult.error ??
|
||||
"Repository access settings unavailable",
|
||||
);
|
||||
}
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
credentials: credentialResult.data,
|
||||
hostTrusts: hostTrustResult.data,
|
||||
};
|
||||
};
|
||||
@@ -38,6 +38,11 @@
|
||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||
const loadedRepositories = initialData.repositories.data;
|
||||
|
||||
type QueueOutcome = {
|
||||
requested_ticket: string;
|
||||
queued_tickets: string[];
|
||||
};
|
||||
|
||||
let ticket = $state<TicketDetail>(loadedTicket);
|
||||
const mergeRequest = $derived(ticket.merge_request);
|
||||
let editing = $state(false);
|
||||
@@ -52,9 +57,14 @@
|
||||
let resolution = $state("");
|
||||
let busy = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let queueMessage = $state<string | null>(null);
|
||||
let readyOperationKey = $state<string | null>(null);
|
||||
let manualRuntimeId = $state("");
|
||||
let manualWorkerId = $state("");
|
||||
let cancellationReason = $state("");
|
||||
const coderAssignment = $derived(
|
||||
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
||||
);
|
||||
const selectedRepository = $derived(
|
||||
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
|
||||
);
|
||||
@@ -117,6 +127,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function queueTicket(): Promise<void> {
|
||||
if (busy) return;
|
||||
busy = "queue";
|
||||
errorMessage = null;
|
||||
queueMessage = null;
|
||||
try {
|
||||
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
||||
`${ticketPath}/queue`,
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
);
|
||||
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateAssignment(
|
||||
action: string,
|
||||
role: "orchestrator" | "coder",
|
||||
@@ -162,6 +191,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelImplementation(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!coderAssignment || !cancellationReason.trim()) return;
|
||||
if (
|
||||
await mutate("cancel-implementation", "/implementation-cancellations", {
|
||||
operation_id: crypto.randomUUID(),
|
||||
assignment_id: coderAssignment.assignment_id,
|
||||
reason: cancellationReason.trim(),
|
||||
})
|
||||
) cancellationReason = "";
|
||||
}
|
||||
|
||||
async function saveEdit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
@@ -272,6 +313,10 @@
|
||||
<div class="workspace-callout is-error" role="alert">{errorMessage}</div>
|
||||
{/if}
|
||||
|
||||
{#if queueMessage}
|
||||
<div class="workspace-callout" role="status">{queueMessage}</div>
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
<form class="ticket-editor" onsubmit={saveEdit}>
|
||||
<label>Title<input bind:value={editTitle} required /></label>
|
||||
@@ -416,6 +461,24 @@
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if ticket.state === "inprogress" && coderAssignment}
|
||||
<details class="ticket-cancel-implementation">
|
||||
<summary>Cancel implementation</summary>
|
||||
<form class="ticket-control-form" onsubmit={cancelImplementation}>
|
||||
<p class="workspace-empty-copy">
|
||||
Cancel the assigned Coder, remove its assignment, and return this Ticket to ready.
|
||||
</p>
|
||||
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
|
||||
<button
|
||||
class="workspace-danger-button"
|
||||
type="submit"
|
||||
disabled={busy !== null || !cancellationReason.trim()}
|
||||
>
|
||||
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel and return to ready"}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
{/if}
|
||||
{#if ticket.assignment_diagnostics.length > 0}
|
||||
{#each ticket.assignment_diagnostics as diagnostic}
|
||||
<p class="workspace-callout">{diagnostic}</p>
|
||||
@@ -462,11 +525,16 @@
|
||||
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
|
||||
{/if}
|
||||
{:else if ticket.state === "ready"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => mutate("queue", "/queue", {})}>
|
||||
{busy === "queue" ? "Queueing…" : "Queue ticket"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>
|
||||
{busy === "queue" ? "Queueing…" : `Queue ${ticket.action_eligibility.queue_tickets.length} Ticket(s)`}
|
||||
</button>
|
||||
{#if !ticket.action_eligibility.can_queue}
|
||||
<p class="workspace-empty-copy">Assign the Orchestrator role and resolve the listed blockers before Queue.</p>
|
||||
<p class="workspace-empty-copy">Queue requires a valid target, an active Orchestrator assignment, no active Coder assignment, and no dependency still in planning.</p>
|
||||
{:else if ticket.action_eligibility.queue_tickets.length > 0}
|
||||
<p class="workspace-empty-copy">This operation queues: {ticket.action_eligibility.queue_tickets.join(", ")}.</p>
|
||||
{#if ticket.relations.blockers.length > 0}
|
||||
<p class="workspace-empty-copy">Ready dependencies are queued atomically. Queued or in-progress dependencies remain unchanged for the Orchestrator to schedule.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
type TestRegistrar = (name: string, body: () => void | Promise<void>) => void;
|
||||
|
||||
const test =
|
||||
(globalThis as unknown as { Deno: { test: TestRegistrar } }).Deno.test;
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/routes/w/[workspaceId]/settings/repository-access/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
||||
const createStart = source.indexOf("async function createCredential()");
|
||||
const rotateStart = source.indexOf("async function rotateCredential(");
|
||||
const deleteStart = source.indexOf("async function deleteCredential(");
|
||||
assert(
|
||||
createStart >= 0 && rotateStart > createStart && deleteStart > rotateStart,
|
||||
"credential handlers should appear in source order",
|
||||
);
|
||||
|
||||
const createBody = source.slice(createStart, rotateStart);
|
||||
const rotateBody = source.slice(rotateStart, deleteStart);
|
||||
for (const token of ["finally", "privateKey = ''", "passphrase = ''"]) {
|
||||
assert(
|
||||
createBody.includes(token),
|
||||
`create handler should contain ${token}`,
|
||||
);
|
||||
}
|
||||
for (
|
||||
const token of [
|
||||
"finally",
|
||||
"rotatePrivateKey = ''",
|
||||
"rotatePassphrase = ''",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
rotateBody.includes(token),
|
||||
`rotate handler should contain ${token}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user