Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62ada5eaa4 | ||
|
|
f5ff0b7c13 | ||
|
|
3337cafcdf | ||
|
|
e87784118b | ||
|
|
40fada28ea | ||
|
|
58cc94d4b7 | ||
|
|
ccabea59c9 | ||
|
|
183c37446e | ||
|
|
7aa06afc45 | ||
|
|
1515a2fb86 | ||
|
|
ec798c58d7 | ||
|
|
e365189276 | ||
|
|
116d610ad0 | ||
|
|
75c570962d | ||
|
|
cae8ac1799 | ||
|
|
917cc222a3 | ||
|
|
7edc588202 | ||
|
|
c83461508b | ||
|
|
374449e663 | ||
|
|
4c876a201b | ||
|
|
21b3dd1da1 | ||
|
|
5ca0ea9228 | ||
|
|
d5c3a68a37 | ||
|
|
2b33b9158d | ||
|
|
b31642e284 | ||
|
|
3a7a3307ef | ||
|
|
0496cd907b | ||
|
|
975b4fa700 | ||
|
|
060f280fdf | ||
|
|
7aaf189247 | ||
|
|
63306cf017 | ||
|
|
08be5e85e4 | ||
|
|
0ab15aa227 | ||
|
|
29c2fb8e06 | ||
|
|
4ebc465e8d | ||
|
|
4b132a21e9 | ||
|
|
b644971d45 | ||
|
|
df34533765 | ||
|
|
6f4efb36bb | ||
|
|
108d5b14d7 | ||
|
|
f633b86b35 | ||
|
|
1873e18f8e | ||
|
|
3cdcbb47bf | ||
|
|
aaa9c7987c | ||
|
|
048007a042 | ||
|
|
cb35b40b9d | ||
|
|
3b71fe03b4 | ||
|
|
471db64bcc | ||
|
|
5e2234763c | ||
|
|
1d140be715 | ||
|
|
ffb2a34ae5 | ||
|
|
ccf7de1a55 | ||
|
|
ccf3c80d29 | ||
|
|
3a3c89e0b4 | ||
|
|
17c629136a | ||
|
|
52a5c4141f | ||
|
|
c9ba27c333 | ||
|
|
46f6e2c58b | ||
|
|
d1f47e5a22 | ||
|
|
65de94bad3 | ||
|
|
a9fb092834 | ||
|
|
3a21a68792 |
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"
|
||||
@@ -6136,6 +6652,7 @@ dependencies = [
|
||||
"workdir",
|
||||
"worker",
|
||||
"workspace-api",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6259,11 +6776,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",
|
||||
@@ -6280,6 +6799,7 @@ dependencies = [
|
||||
"worker",
|
||||
"worker-runtime",
|
||||
"workspace-api",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
@@ -124,4 +125,5 @@ toml = "1.1"
|
||||
tracing = "0.1"
|
||||
url = "2.5"
|
||||
uuid = "1.23"
|
||||
zeroize = "1"
|
||||
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
|
||||
@@ -21,20 +21,21 @@ agen = { version = "0.2.1", features = ["codex"] }
|
||||
|
||||
## Quick start
|
||||
|
||||
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
|
||||
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), keep conversation history in your application, then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
|
||||
|
||||
```no_run
|
||||
use agen::{Engine, EngineError};
|
||||
use agen::{Engine, EngineError, History};
|
||||
use agen::llm_client::LlmClient;
|
||||
|
||||
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
|
||||
let mut history = History::new();
|
||||
let output = Engine::new(client)
|
||||
.system_prompt("You are a concise assistant.")
|
||||
.run("Explain typed state in one sentence.")
|
||||
.await?;
|
||||
.run(&mut history, "Explain typed state in one sentence.")
|
||||
.await;
|
||||
|
||||
let mut engine = output.engine;
|
||||
let _result = engine.run("Give a Rust example.").await?;
|
||||
let _result = engine.run(&mut history, "Give a Rust example.").await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use agen::{Engine, EngineResult};
|
||||
use agen::{Engine, EngineRunExit, StopReason};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
||||
let engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
println!("🚀 Starting Engine...");
|
||||
println!("💡 Will cancel after 2 seconds\n");
|
||||
@@ -45,16 +46,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
println!("📡 Sending request to LLM...");
|
||||
|
||||
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
|
||||
Ok(out) => match out.result {
|
||||
EngineResult::Finished => println!("✅ Task completed normally"),
|
||||
EngineResult::Paused => println!("⏸️ Task paused"),
|
||||
EngineResult::LimitReached => println!("🔒 Turn limit reached"),
|
||||
EngineResult::Yielded => println!("↩️ Task yielded"),
|
||||
},
|
||||
Err(e) => {
|
||||
println!("❌ Task error: {}", e);
|
||||
let output = engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await;
|
||||
match output.result {
|
||||
EngineRunExit::Finished => println!("✅ Task completed normally"),
|
||||
EngineRunExit::Paused => println!("⏸️ Task paused"),
|
||||
EngineRunExit::Yielded => println!("↩️ Task yielded"),
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
||||
println!("🔒 Turn limit reached")
|
||||
}
|
||||
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
|
||||
}
|
||||
|
||||
println!("\n✨ Demo complete!");
|
||||
|
||||
@@ -39,7 +39,7 @@ use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use agen::{
|
||||
Engine,
|
||||
Engine, EngineRunExit, StopReason,
|
||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||
llm_client::{
|
||||
LlmClient,
|
||||
@@ -451,6 +451,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Create Engine
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
@@ -476,12 +477,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// One-shot mode
|
||||
if let Some(prompt) = args.prompt {
|
||||
match engine.run(&prompt).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let output = engine.run(&mut history, &prompt).await;
|
||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
|
||||
eprintln!("\n❌ Error: {error}");
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
@@ -500,13 +498,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut locked = match engine.run(first_input).await {
|
||||
Ok(out) => out.engine,
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let output = engine.run(&mut history, first_input).await;
|
||||
let mut locked = output.engine;
|
||||
|
||||
loop {
|
||||
print!("\n👤 You: ");
|
||||
@@ -525,11 +518,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
break;
|
||||
}
|
||||
|
||||
match locked.run(input).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
}
|
||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) =
|
||||
locked.run(&mut history, input).await
|
||||
{
|
||||
eprintln!("\n❌ Error: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+857
-254
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
//! Typed conversation history containers.
|
||||
//!
|
||||
//! Agen keeps provider-visible [`Item`](crate::Item) values separate from any
|
||||
//! host-domain provenance. The host chooses the annotation type `A`, while Agen
|
||||
//! preserves each item and annotation as one entry for clone/truncate/restore
|
||||
//! style history operations.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Item;
|
||||
|
||||
/// One conversation-history entry with host-owned annotation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HistoryEntry<A = ()> {
|
||||
/// Provider/model-visible conversation item.
|
||||
pub item: Item,
|
||||
/// Host-domain metadata kept with the item and never projected to providers.
|
||||
pub annotation: A,
|
||||
}
|
||||
|
||||
impl<A> HistoryEntry<A> {
|
||||
/// Build an entry from an item and its annotation.
|
||||
pub fn new(item: Item, annotation: A) -> Self {
|
||||
Self { item, annotation }
|
||||
}
|
||||
|
||||
/// Split the entry into its item and annotation.
|
||||
pub fn into_parts(self) -> (Item, A) {
|
||||
(self.item, self.annotation)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryEntry<()> {
|
||||
/// Build a unit-annotated entry.
|
||||
pub fn from_item(item: Item) -> Self {
|
||||
Self {
|
||||
item,
|
||||
annotation: (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversation history with one annotation per item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct History<A = ()> {
|
||||
entries: Vec<HistoryEntry<A>>,
|
||||
}
|
||||
|
||||
impl<A> History<A> {
|
||||
/// Create an empty history.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build history from already annotated entries, preserving order.
|
||||
pub fn from_entries(entries: Vec<HistoryEntry<A>>) -> Self {
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
/// Replace all entries as one restore/rebuild operation and return the old entries.
|
||||
pub fn replace_entries(&mut self, entries: Vec<HistoryEntry<A>>) -> Vec<HistoryEntry<A>> {
|
||||
std::mem::replace(&mut self.entries, entries)
|
||||
}
|
||||
|
||||
/// Borrow annotated entries.
|
||||
pub fn entries(&self) -> &[HistoryEntry<A>] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Mutably borrow annotated entries for host-owned rebuild operations.
|
||||
pub fn entries_mut(&mut self) -> &mut [HistoryEntry<A>] {
|
||||
&mut self.entries
|
||||
}
|
||||
|
||||
/// Consume the history into annotated entries.
|
||||
pub fn into_entries(self) -> Vec<HistoryEntry<A>> {
|
||||
self.entries
|
||||
}
|
||||
|
||||
/// Number of entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Whether the history is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Iterate over annotated entries.
|
||||
pub fn iter(&self) -> impl ExactSizeIterator<Item = &HistoryEntry<A>> {
|
||||
self.entries.iter()
|
||||
}
|
||||
|
||||
/// Iterate over provider-visible items only.
|
||||
pub fn items(&self) -> impl ExactSizeIterator<Item = &Item> {
|
||||
self.entries.iter().map(|entry| &entry.item)
|
||||
}
|
||||
|
||||
/// Clone provider-visible items into a request-local projection.
|
||||
pub fn items_cloned(&self) -> Vec<Item> {
|
||||
self.items().cloned().collect()
|
||||
}
|
||||
|
||||
/// Append an already annotated entry.
|
||||
pub fn push_entry(&mut self, entry: HistoryEntry<A>) {
|
||||
self.entries.push(entry);
|
||||
}
|
||||
|
||||
/// Append many already annotated entries.
|
||||
pub fn extend_entries(&mut self, entries: impl IntoIterator<Item = HistoryEntry<A>>) {
|
||||
self.entries.extend(entries);
|
||||
}
|
||||
|
||||
/// Commit one item through a trusted annotation callback before it becomes live.
|
||||
///
|
||||
/// The callback may durably persist the item and returns the annotation that
|
||||
/// must be stored with it. If the callback fails, the history is left unchanged.
|
||||
pub fn append_with(
|
||||
&mut self,
|
||||
item: Item,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<(), String> {
|
||||
let annotation = annotate(&item)?;
|
||||
self.entries.push(HistoryEntry { item, annotation });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit items through a trusted annotation callback before they become live.
|
||||
///
|
||||
/// Items before a failure remain appended; the failing item and later items do
|
||||
/// not enter history. This mirrors append-only durable logs where each accepted
|
||||
/// item is already committed before the next item is attempted.
|
||||
pub fn extend_with(
|
||||
&mut self,
|
||||
items: impl IntoIterator<Item = Item>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<(), String> {
|
||||
for item in items {
|
||||
self.append_with(item, annotate)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Truncate entries, preserving item+annotation pairing for retained entries.
|
||||
pub fn truncate(&mut self, len: usize) {
|
||||
self.entries.truncate(len);
|
||||
}
|
||||
|
||||
/// Clear all entries.
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl History<()> {
|
||||
/// Build unit-annotated history from provider-visible items.
|
||||
pub fn from_items(items: Vec<Item>) -> Self {
|
||||
Self {
|
||||
entries: items.into_iter().map(HistoryEntry::from_item).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace history from provider-visible items using unit annotations.
|
||||
pub fn replace_items(&mut self, items: Vec<Item>) -> Vec<HistoryEntry<()>> {
|
||||
self.replace_entries(items.into_iter().map(HistoryEntry::from_item).collect())
|
||||
}
|
||||
|
||||
/// Append one item with unit annotation.
|
||||
pub fn push(&mut self, item: Item) {
|
||||
self.entries.push(HistoryEntry::from_item(item));
|
||||
}
|
||||
|
||||
/// Append items with unit annotations.
|
||||
pub fn extend_items(&mut self, items: impl IntoIterator<Item = Item>) {
|
||||
self.entries
|
||||
.extend(items.into_iter().map(HistoryEntry::from_item));
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> IntoIterator for History<A> {
|
||||
type Item = HistoryEntry<A>;
|
||||
type IntoIter = std::vec::IntoIter<HistoryEntry<A>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.entries.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, A> IntoIterator for &'a History<A> {
|
||||
type Item = &'a HistoryEntry<A>;
|
||||
type IntoIter = std::slice::Iter<'a, HistoryEntry<A>>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.entries.iter()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod engine;
|
||||
mod handler;
|
||||
mod history;
|
||||
mod message;
|
||||
|
||||
pub(crate) mod callback;
|
||||
@@ -20,13 +21,18 @@ pub mod usage_record;
|
||||
pub use agen_macros::{description, tool, tool_registry};
|
||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||
pub use engine::{
|
||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice,
|
||||
ToolRegistryError,
|
||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
||||
LlmRetryNotice, StopReason, ToolRegistryError,
|
||||
};
|
||||
pub use handler::ToolUseBlockStart;
|
||||
pub use history::{History, HistoryEntry};
|
||||
pub use interceptor::Interceptor;
|
||||
pub use message::{ContentPart, Item, Message, Role};
|
||||
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
|
||||
pub use tool::{
|
||||
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
|
||||
ToolExecutionTerminal, ToolExecutionTerminalFuture, ToolOutputLimits, ToolResult,
|
||||
ToolResultDisposition,
|
||||
};
|
||||
pub use usage_record::UsageRecord;
|
||||
|
||||
/// Implementation dependencies used by code generated from `agen` macros.
|
||||
|
||||
@@ -18,6 +18,9 @@ pub enum ClientError {
|
||||
message: String,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// The provider rejected the request because it exceeded the model context window.
|
||||
/// Classified only from a structured provider error code, never message text.
|
||||
ContextWindowExceeded,
|
||||
/// A request lifecycle phase exceeded its hard timeout.
|
||||
Timeout {
|
||||
phase: &'static str,
|
||||
@@ -48,6 +51,7 @@ impl fmt::Display for ClientError {
|
||||
}
|
||||
write!(f, ": {}", message)
|
||||
}
|
||||
ClientError::ContextWindowExceeded => write!(f, "Model context window reached"),
|
||||
ClientError::Timeout { phase, timeout } => {
|
||||
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
||||
}
|
||||
@@ -112,7 +116,10 @@ pub fn is_retryable(error: &ClientError) -> bool {
|
||||
ClientError::Api { status: None, .. } => false,
|
||||
ClientError::Timeout { .. } => true,
|
||||
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
||||
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
|
||||
ClientError::ContextWindowExceeded
|
||||
| ClientError::Json(_)
|
||||
| ClientError::Sse(_)
|
||||
| ClientError::Config(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -431,13 +431,7 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
|
||||
}
|
||||
|
||||
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api { code, message, .. } => {
|
||||
code.as_deref() == Some("context_length_exceeded")
|
||||
|| message.contains("context_length_exceeded")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
matches!(error, ClientError::ContextWindowExceeded)
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
@@ -487,6 +481,9 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&text)
|
||||
.to_string();
|
||||
if code.as_deref() == Some("context_length_exceeded") {
|
||||
return ClientError::ContextWindowExceeded;
|
||||
}
|
||||
ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use crate::tool::Attachment;
|
||||
use crate::tool::{Attachment, ToolResultDisposition};
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -121,6 +121,9 @@ pub enum Item {
|
||||
/// Detailed output (removed by pruning when old enough)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
/// Typed terminal state used for replay and recovery.
|
||||
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||
disposition: ToolResultDisposition,
|
||||
/// Whether the tool result represents an execution error.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
@@ -261,7 +264,17 @@ impl Item {
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
|
||||
Self::tool_result_item_with_disposition_and_attachments(
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
if is_error {
|
||||
ToolResultDisposition::Error
|
||||
} else {
|
||||
ToolResultDisposition::Success
|
||||
},
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a tool result item with durable, prunable structured attachments.
|
||||
@@ -272,11 +285,33 @@ impl Item {
|
||||
is_error: bool,
|
||||
attachments: Vec<Attachment>,
|
||||
) -> Self {
|
||||
Self::tool_result_item_with_disposition_and_attachments(
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
if is_error {
|
||||
ToolResultDisposition::Error
|
||||
} else {
|
||||
ToolResultDisposition::Success
|
||||
},
|
||||
attachments,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn tool_result_item_with_disposition_and_attachments(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: Option<String>,
|
||||
disposition: ToolResultDisposition,
|
||||
attachments: Vec<Attachment>,
|
||||
) -> Self {
|
||||
let is_error = !disposition.is_success();
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content,
|
||||
disposition,
|
||||
is_error,
|
||||
attachments,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ mod private {
|
||||
/// - Editing message history (add, delete, clear)
|
||||
/// - Registering tools and hooks
|
||||
///
|
||||
/// Can transition to [`Locked`] state via `Engine::lock()`.
|
||||
/// Can transition to [`Locked`] state via `Engine::lock(&history)`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
|
||||
+227
-2
@@ -3,7 +3,14 @@
|
||||
//! Traits for defining tools callable by LLM.
|
||||
//! Usually auto-implemented using the `#[tool]` macro.
|
||||
|
||||
use std::{collections::HashMap, fmt, sync::Arc};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
@@ -23,6 +30,12 @@ pub enum ToolError {
|
||||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
/// Cooperative cancellation completed with bounded terminal output.
|
||||
#[error("Tool execution cancelled")]
|
||||
Cancelled(ToolOutput),
|
||||
/// Execution was interrupted with a confirmed bounded terminal output.
|
||||
#[error("Tool execution interrupted")]
|
||||
Interrupted(ToolOutput),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -158,6 +171,28 @@ pub enum Attachment {
|
||||
Image(ImageAttachment),
|
||||
}
|
||||
|
||||
/// Terminal disposition of one started tool call.
|
||||
///
|
||||
/// `Cancelled` means the tool confirmed cancellation. `OutcomeUnknown` means
|
||||
/// execution stopped without confirmation, so neither completion nor side
|
||||
/// effects may be inferred.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolResultDisposition {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
Interrupted,
|
||||
Cancelled,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
impl ToolResultDisposition {
|
||||
pub const fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Success)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool execution result.
|
||||
///
|
||||
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
||||
@@ -322,6 +357,12 @@ impl ToolExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies one live execution attempt without making the batch id a durable
|
||||
/// replay or idempotency authority.
|
||||
pub fn execution_id(&self) -> String {
|
||||
format!("{}:{}", self.batch_id, self.call_id)
|
||||
}
|
||||
|
||||
/// Context for direct, non-engine calls in unit tests and low-level callers.
|
||||
pub fn direct() -> Self {
|
||||
Self::new("direct", "direct", 0)
|
||||
@@ -334,6 +375,142 @@ impl Default for ToolExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider-confirmed terminal result of one started tool execution.
|
||||
///
|
||||
/// `OutcomeUnknown` is reserved for an execution task that had to be force-closed
|
||||
/// or failed before the provider could confirm its terminal result.
|
||||
#[derive(Debug)]
|
||||
pub enum ToolExecutionTerminal {
|
||||
Confirmed(Result<ToolOutput, ToolError>),
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
/// The completion future paired with a [`ToolExecutionHandle`]. Dropping this
|
||||
/// future does not drop the provider execution: the spawned execution remains
|
||||
/// owned by its handle until it completes or is explicitly force-closed.
|
||||
pub struct ToolExecutionTerminalFuture {
|
||||
task: tokio::task::JoinHandle<Result<ToolOutput, ToolError>>,
|
||||
}
|
||||
|
||||
impl Future for ToolExecutionTerminalFuture {
|
||||
type Output = ToolExecutionTerminal;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
match Pin::new(&mut self.task).poll(cx) {
|
||||
Poll::Ready(Ok(result)) => Poll::Ready(ToolExecutionTerminal::Confirmed(result)),
|
||||
Poll::Ready(Err(_)) => Poll::Ready(ToolExecutionTerminal::OutcomeUnknown),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live ownership and control for one started tool execution.
|
||||
///
|
||||
/// Execution, cancellation, and terminal confirmation remain provider-owned:
|
||||
/// this handle starts `Tool::execute`, delegates cooperative cancellation to
|
||||
/// `Tool::cancel_execution`, and treats execution-future completion as the
|
||||
/// provider's terminal confirmation. Agen may force-close only after its caller's
|
||||
/// deadline expires, at which point the outcome is necessarily unknown.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolExecutionHandle {
|
||||
inner: Arc<ToolExecutionHandleInner>,
|
||||
}
|
||||
|
||||
struct ToolExecutionHandleInner {
|
||||
tool: Arc<dyn Tool>,
|
||||
context: ToolExecutionContext,
|
||||
abort: tokio::task::AbortHandle,
|
||||
}
|
||||
|
||||
impl Drop for ToolExecutionHandleInner {
|
||||
fn drop(&mut self) {
|
||||
// Losing the final live owner is an explicit forced close, never a
|
||||
// best-effort detached provider future.
|
||||
self.abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ToolExecutionHandle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ToolExecutionHandle")
|
||||
.field("call_id", &self.inner.context.call_id)
|
||||
.field("batch_id", &self.inner.context.batch_id)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutionHandle {
|
||||
pub fn start(
|
||||
tool: Arc<dyn Tool>,
|
||||
input_json: String,
|
||||
context: ToolExecutionContext,
|
||||
) -> (Self, ToolExecutionTerminalFuture) {
|
||||
let execution_tool = Arc::clone(&tool);
|
||||
let execution_context = context.clone();
|
||||
let task =
|
||||
tokio::spawn(
|
||||
async move { execution_tool.execute(&input_json, execution_context).await },
|
||||
);
|
||||
let abort = task.abort_handle();
|
||||
(
|
||||
Self {
|
||||
inner: Arc::new(ToolExecutionHandleInner {
|
||||
tool,
|
||||
context,
|
||||
abort,
|
||||
}),
|
||||
},
|
||||
ToolExecutionTerminalFuture { task },
|
||||
)
|
||||
}
|
||||
|
||||
pub fn context(&self) -> &ToolExecutionContext {
|
||||
&self.inner.context
|
||||
}
|
||||
|
||||
pub async fn cancel_before(&self, deadline: tokio::time::Instant) -> Result<(), ToolError> {
|
||||
match tokio::time::timeout_at(
|
||||
deadline,
|
||||
self.inner.tool.cancel_execution(&self.inner.context),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(ToolError::Internal(format!(
|
||||
"tool cancellation request exceeded its deadline for call {}",
|
||||
self.inner.context.call_id
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn force_close(&self) {
|
||||
self.inner.abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ToolExecutionPolicy {
|
||||
/// Time a pause waits for already-started providers to reach a natural safe
|
||||
/// boundary before escalating to explicit cooperative cancellation.
|
||||
pub pause_safe_boundary_timeout: std::time::Duration,
|
||||
/// Maximum time allowed for a provider to accept one cooperative
|
||||
/// cancellation request.
|
||||
pub cancellation_request_timeout: std::time::Duration,
|
||||
/// Maximum time allowed for all providers to confirm terminal results after
|
||||
/// cancellation has been requested.
|
||||
pub terminal_confirmation_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for ToolExecutionPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pause_safe_boundary_timeout: std::time::Duration::from_millis(100),
|
||||
cancellation_request_timeout: std::time::Duration::from_millis(100),
|
||||
terminal_confirmation_timeout: std::time::Duration::from_millis(500),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool trait
|
||||
// =============================================================================
|
||||
@@ -402,6 +579,26 @@ pub trait Tool: Send + Sync {
|
||||
input_json: &str,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError>;
|
||||
|
||||
/// Request cooperative cancellation for one started call.
|
||||
///
|
||||
/// Implementations that own cancellable provider operations should signal
|
||||
/// every live execution identified by `call_id`, then let `execute` return
|
||||
/// the confirmed bounded terminal output. Direct callers may use this
|
||||
/// compatibility surface; Agen uses [`Tool::cancel_execution`] so providers
|
||||
/// can bind cancellation to one exact live attempt.
|
||||
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Request cooperative cancellation for one exact started execution.
|
||||
///
|
||||
/// The default preserves existing tools by delegating to `cancel(call_id)`.
|
||||
/// Providers with their own execution registry should override this method
|
||||
/// and key cancellation by [`ToolExecutionContext::execution_id`].
|
||||
async fn cancel_execution(&self, ctx: &ToolExecutionContext) -> Result<(), ToolError> {
|
||||
self.cancel(&ctx.call_id).await
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -429,6 +626,9 @@ pub struct ToolCall {
|
||||
pub struct ToolResult {
|
||||
/// Corresponding tool call ID
|
||||
pub tool_use_id: String,
|
||||
/// Typed terminal state.
|
||||
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||
pub disposition: ToolResultDisposition,
|
||||
/// Short summary (always kept in history)
|
||||
pub summary: String,
|
||||
/// Detailed output (prunable)
|
||||
@@ -445,11 +645,20 @@ pub struct ToolResult {
|
||||
impl ToolResult {
|
||||
/// Create a success result from a [`ToolOutput`].
|
||||
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
||||
Self::from_output_with_disposition(tool_use_id, output, ToolResultDisposition::Success)
|
||||
}
|
||||
|
||||
pub fn from_output_with_disposition(
|
||||
tool_use_id: impl Into<String>,
|
||||
output: ToolOutput,
|
||||
disposition: ToolResultDisposition,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
disposition,
|
||||
summary: output.summary,
|
||||
content: output.content,
|
||||
is_error: false,
|
||||
is_error: !disposition.is_success(),
|
||||
attachments: output.attachments,
|
||||
}
|
||||
}
|
||||
@@ -458,12 +667,28 @@ impl ToolResult {
|
||||
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
disposition: ToolResultDisposition::Error,
|
||||
summary: message.into(),
|
||||
content: None,
|
||||
is_error: true,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close an execution whose completion and side effects cannot be confirmed.
|
||||
pub fn outcome_unknown(tool_use_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||
summary: "Tool execution outcome unknown".to_string(),
|
||||
content: Some(
|
||||
"Execution was interrupted before completion could be confirmed. Completion and side effects are unknown."
|
||||
.to_string(),
|
||||
),
|
||||
is_error: true,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
mod common;
|
||||
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
|
||||
use common::MockLlmClient;
|
||||
|
||||
fn completed_text_events(text: &str) -> Vec<Event> {
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, text),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_preserves_item_annotations_without_projecting_them() {
|
||||
let client = MockLlmClient::new(completed_text_events("assistant reply"));
|
||||
let engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
|
||||
let mut history = History::<String>::new();
|
||||
let mut next = 0usize;
|
||||
let mut annotate = |item: &Item| {
|
||||
next += 1;
|
||||
let kind = match item {
|
||||
Item::Message { role, .. } => match role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
},
|
||||
Item::ToolCall { .. } => "tool_call",
|
||||
Item::ToolResult { .. } => "tool_result",
|
||||
Item::Reasoning { .. } => "reasoning",
|
||||
};
|
||||
Ok(format!("{next}:{kind}"))
|
||||
};
|
||||
|
||||
let output = engine
|
||||
.run_with_annotation(&mut history, "hello", &mut annotate)
|
||||
.await;
|
||||
|
||||
assert!(matches!(output.result, agen::EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history.entries()[0].annotation, "1:user");
|
||||
assert_eq!(history.entries()[1].annotation, "2:assistant");
|
||||
assert_eq!(history.items_cloned().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_failure_does_not_make_item_live() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::<_, agen::state::Mutable, usize>::new_annotated(client);
|
||||
let mut history = History::<usize>::new();
|
||||
let mut fail = |_item: &Item| Err("commit failed".to_string());
|
||||
|
||||
let err = engine
|
||||
.append_history_with(&mut history, [Item::user_message("uncommitted")], &mut fail)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, EngineError::HistoryAppend(message) if message == "commit failed"));
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_keeps_items_and_annotations_together() {
|
||||
let mut history = History::from_entries(vec![
|
||||
HistoryEntry::new(Item::user_message("old"), "old-ann".to_string()),
|
||||
HistoryEntry::new(Item::user_message("second"), "second-ann".to_string()),
|
||||
]);
|
||||
|
||||
history.truncate(1);
|
||||
assert_eq!(history.entries()[0].item.as_text(), Some("old"));
|
||||
assert_eq!(history.entries()[0].annotation, "old-ann");
|
||||
|
||||
let previous = history.replace_entries(vec![HistoryEntry::new(
|
||||
Item::user_message("restored"),
|
||||
"restored-ann".to_string(),
|
||||
)]);
|
||||
|
||||
assert_eq!(previous.len(), 1);
|
||||
assert_eq!(history.entries()[0].item.as_text(), Some("restored"));
|
||||
assert_eq!(history.entries()[0].annotation, "restored-ann");
|
||||
}
|
||||
@@ -58,6 +58,7 @@ async fn test_callback_llm_retry_event() {
|
||||
max_attempts: 2,
|
||||
total_timeout: Duration::from_secs(1),
|
||||
});
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let notices = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = notices.clone();
|
||||
@@ -65,8 +66,11 @@ async fn test_callback_llm_retry_event() {
|
||||
sink.lock().unwrap().push((llm_call, notice.clone()));
|
||||
});
|
||||
|
||||
let result = engine.run("retry once").await;
|
||||
assert!(result.is_ok(), "engine should succeed after one retry");
|
||||
let result = engine.run(&mut history, "retry once").await;
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"engine should succeed after one retry"
|
||||
);
|
||||
|
||||
let notices = notices.lock().unwrap();
|
||||
assert_eq!(notices.len(), 1);
|
||||
@@ -91,6 +95,7 @@ async fn test_callback_text_block_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
||||
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -108,9 +113,12 @@ async fn test_callback_text_block_events() {
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run("Greet me").await;
|
||||
assert!(result.is_ok(), "Engine should complete");
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let result = engine.run(&mut history, "Greet me").await;
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete"
|
||||
);
|
||||
|
||||
let deltas = text_deltas.lock().unwrap();
|
||||
assert_eq!(deltas.len(), 2);
|
||||
@@ -137,6 +145,7 @@ async fn test_callback_tool_call_complete() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
||||
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -154,8 +163,8 @@ async fn test_callback_tool_call_complete() {
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _ = engine.run("Weather please").await;
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let _ = engine.run(&mut history, "Weather please").await;
|
||||
|
||||
let starts = tool_starts.lock().unwrap();
|
||||
assert_eq!(starts.len(), 1);
|
||||
@@ -183,6 +192,7 @@ async fn test_callback_turn_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
||||
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -197,9 +207,9 @@ async fn test_callback_turn_events() {
|
||||
ends.lock().unwrap().push(turn);
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run("Do something").await;
|
||||
assert!(result.is_ok());
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let result = engine.run(&mut history, "Do something").await;
|
||||
assert!(matches!(result.result, agen::EngineRunExit::Finished));
|
||||
|
||||
let starts = turn_starts.lock().unwrap();
|
||||
let ends = turn_ends.lock().unwrap();
|
||||
@@ -254,6 +264,7 @@ async fn test_callback_tool_result_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
engine.register_tool(fixed_tool(
|
||||
"fixed",
|
||||
@@ -276,7 +287,7 @@ async fn test_callback_tool_result_events() {
|
||||
));
|
||||
});
|
||||
|
||||
let _ = engine.run("call it").await;
|
||||
let _ = engine.run(&mut history, "call it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
@@ -330,6 +341,7 @@ async fn test_callback_tool_result_error_path() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
engine.register_tool(erroring_tool("erroring", "boom"));
|
||||
|
||||
@@ -345,7 +357,7 @@ async fn test_callback_tool_result_error_path() {
|
||||
));
|
||||
});
|
||||
|
||||
let _ = engine.run("fail it").await;
|
||||
let _ = engine.run(&mut history, "fail it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
@@ -374,6 +386,7 @@ async fn test_callback_usage_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
@@ -382,8 +395,8 @@ async fn test_callback_usage_events() {
|
||||
usages.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _ = engine.run("Hello").await;
|
||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||
let _ = engine.run(&mut history, "Hello").await;
|
||||
|
||||
let usages = usage_events.lock().unwrap();
|
||||
assert_eq!(usages.len(), 1);
|
||||
|
||||
@@ -19,6 +19,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
pub struct MockLlmClient {
|
||||
responses: Arc<Vec<Vec<Event>>>,
|
||||
call_count: Arc<AtomicUsize>,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
}
|
||||
|
||||
impl MockLlmClient {
|
||||
@@ -30,6 +31,7 @@ impl MockLlmClient {
|
||||
Self {
|
||||
responses: Arc::new(responses),
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +43,10 @@ impl MockLlmClient {
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.responses.iter().map(|v| v.len()).sum()
|
||||
}
|
||||
|
||||
pub fn requests(&self) -> Vec<Request> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -51,8 +57,9 @@ impl LlmClient for MockLlmClient {
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
if count >= self.responses.len() {
|
||||
return Err(ClientError::Api {
|
||||
|
||||
@@ -134,11 +134,15 @@ async fn test_engine_simple_text_response() {
|
||||
|
||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||
let engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
// Send a simple message (Mutable::run consumes self, returns tuple)
|
||||
let result = engine.run("Hello").await;
|
||||
let result = engine.run(&mut history, "Hello").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete successfully");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that Engine can correctly process responses containing tool calls
|
||||
@@ -156,6 +160,7 @@ async fn test_engine_tool_call() {
|
||||
|
||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
// Register tool
|
||||
let weather_tool = MockWeatherTool::new();
|
||||
@@ -163,7 +168,9 @@ async fn test_engine_tool_call() {
|
||||
engine.register_tool(weather_tool.definition());
|
||||
|
||||
// Send message (Mutable::run consumes self, returns tuple)
|
||||
let _result = engine.run("What's the weather in Tokyo?").await;
|
||||
let _result = engine
|
||||
.run(&mut history, "What's the weather in Tokyo?")
|
||||
.await;
|
||||
|
||||
// Verify tool was called
|
||||
// Note: max_turns=1 so no request is sent after tool result
|
||||
@@ -195,11 +202,15 @@ async fn test_engine_with_programmatic_events() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let mut history = agen::History::new();
|
||||
|
||||
// Mutable::run consumes self, returns tuple
|
||||
let result = engine.run("Greet me").await;
|
||||
let result = engine.run(&mut history, "Greet me").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete successfully");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
|
||||
|
||||
@@ -9,9 +9,12 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::Item;
|
||||
use agen::interceptor::{
|
||||
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
|
||||
};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, EngineError};
|
||||
use agen::{Engine, EngineError, EngineRunExit, History, StopReason};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
@@ -39,36 +42,37 @@ fn test_mutable_set_system_prompt() {
|
||||
fn test_mutable_history_manipulation() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Initial state is empty
|
||||
assert!(engine.history().is_empty());
|
||||
assert!(history.is_empty());
|
||||
|
||||
// Add to history
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.append_history(&mut history, vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi there!")])
|
||||
.append_history(&mut history, vec![Item::assistant_message("Hi there!")])
|
||||
.unwrap();
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
assert_eq!(history.len(), 2);
|
||||
|
||||
// Append to history via the callback-aware API.
|
||||
engine
|
||||
.append_history(vec![Item::user_message("How are you?")])
|
||||
.append_history(&mut history, vec![Item::user_message("How are you?")])
|
||||
.unwrap();
|
||||
assert_eq!(engine.history().len(), 3);
|
||||
assert_eq!(history.len(), 3);
|
||||
|
||||
// Clear history
|
||||
engine.clear_history();
|
||||
assert!(engine.history().is_empty());
|
||||
engine.clear_history(&mut history);
|
||||
assert!(history.is_empty());
|
||||
|
||||
// Set history
|
||||
let items = vec![
|
||||
Item::user_message("Test"),
|
||||
Item::assistant_message("Response"),
|
||||
];
|
||||
engine.set_history(items);
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
engine.set_history(&mut history, items);
|
||||
assert_eq!(history.len(), 2);
|
||||
}
|
||||
|
||||
/// Verify that Engine can be constructed using builder pattern
|
||||
@@ -76,9 +80,10 @@ fn test_mutable_history_manipulation() {
|
||||
fn test_mutable_builder_pattern() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let engine = Engine::new(client).system_prompt("System prompt");
|
||||
let history: History = History::new();
|
||||
|
||||
assert_eq!(engine.get_system_prompt(), Some("System prompt"));
|
||||
assert!(engine.history().is_empty());
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
/// Verify that multiple items can be added with append_history and callbacks fire.
|
||||
@@ -88,6 +93,7 @@ fn test_mutable_append_history() {
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
let observed_for_callback = Arc::clone(&observed);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
engine.on_history_append(move |item| {
|
||||
if let Some(text) = item.as_text() {
|
||||
observed_for_callback.lock().unwrap().push(text.to_string());
|
||||
@@ -96,18 +102,21 @@ fn test_mutable_append_history() {
|
||||
});
|
||||
|
||||
engine
|
||||
.append_history(vec![Item::user_message("First")])
|
||||
.append_history(&mut history, vec![Item::user_message("First")])
|
||||
.unwrap();
|
||||
|
||||
engine
|
||||
.append_history(vec![
|
||||
.append_history(
|
||||
&mut history,
|
||||
vec![
|
||||
Item::assistant_message("Response 1"),
|
||||
Item::user_message("Second"),
|
||||
Item::assistant_message("Response 2"),
|
||||
])
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(engine.history().len(), 4);
|
||||
assert_eq!(history.len(), 4);
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
["First", "Response 1", "Second", "Response 2"]
|
||||
@@ -182,6 +191,7 @@ async fn history_append_failure_stops_before_tool_execution() {
|
||||
]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
engine.register_tool(tool.definition());
|
||||
engine.on_history_append(|item| {
|
||||
if item.is_tool_call() {
|
||||
@@ -191,15 +201,15 @@ async fn history_append_failure_stops_before_tool_execution() {
|
||||
}
|
||||
});
|
||||
|
||||
let mut engine = engine.lock();
|
||||
let error = engine.run("use the tool").await.unwrap_err();
|
||||
let mut engine = engine.lock(&history);
|
||||
let exit = engine.run(&mut history, "use the tool").await;
|
||||
|
||||
assert!(
|
||||
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC")
|
||||
matches!(exit, EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
|
||||
);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(engine.history().len(), 1);
|
||||
assert_eq!(engine.history()[0].as_text(), Some("use the tool"));
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history.entries()[0].item.as_text(), Some("use the tool"));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -211,21 +221,22 @@ async fn history_append_failure_stops_before_tool_execution() {
|
||||
fn test_lock_transition() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
engine.set_system_prompt("System");
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.append_history(&mut history, vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi")])
|
||||
.append_history(&mut history, vec![Item::assistant_message("Hi")])
|
||||
.unwrap();
|
||||
|
||||
// Lock
|
||||
let locked_engine = engine.lock();
|
||||
let locked_engine = engine.lock(&history);
|
||||
|
||||
// History and system prompt are still accessible in Locked state
|
||||
assert_eq!(locked_engine.get_system_prompt(), Some("System"));
|
||||
assert_eq!(locked_engine.history().len(), 2);
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2);
|
||||
}
|
||||
|
||||
@@ -234,21 +245,22 @@ fn test_lock_transition() {
|
||||
fn test_unlock_transition() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.append_history(&mut history, vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
let locked_engine = engine.lock();
|
||||
let locked_engine = engine.lock(&history);
|
||||
|
||||
// Unlock
|
||||
let mut engine = locked_engine.unlock();
|
||||
|
||||
// History operations are available again in Mutable state
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi")])
|
||||
.append_history(&mut history, vec![Item::assistant_message("Hi")])
|
||||
.unwrap();
|
||||
engine.clear_history();
|
||||
assert!(engine.history().is_empty());
|
||||
engine.clear_history(&mut history);
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -269,20 +281,20 @@ async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Execute (Mutable::run consumes self, returns EngineRunOutput)
|
||||
let out = engine.run("Hi there").await?;
|
||||
let engine = out.engine;
|
||||
let _out = engine.run(&mut history, "Hi there").await;
|
||||
|
||||
// History is updated
|
||||
let history = engine.history();
|
||||
let entries = history.entries();
|
||||
assert_eq!(history.len(), 2); // user + assistant
|
||||
|
||||
// User message
|
||||
assert_eq!(history[0].as_text(), Some("Hi there"));
|
||||
assert_eq!(entries[0].item.as_text(), Some("Hi there"));
|
||||
|
||||
// Assistant message
|
||||
assert_eq!(history[1].as_text(), Some("Hello, I'm an assistant!"));
|
||||
assert_eq!(entries[1].item.as_text(), Some("Hello, I'm an assistant!"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -313,35 +325,36 @@ async fn test_locked_multi_turn_history_accumulation() {
|
||||
]);
|
||||
|
||||
let engine = Engine::new(client).system_prompt("You are helpful.");
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Lock (after setting system prompt)
|
||||
let mut locked_engine = engine.lock();
|
||||
let mut locked_engine = engine.lock(&history);
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 0); // No items yet
|
||||
|
||||
// Turn 1
|
||||
let result1 = locked_engine.run("Hello!").await;
|
||||
assert!(result1.is_ok());
|
||||
assert_eq!(locked_engine.history().len(), 2); // user + assistant
|
||||
let result1 = locked_engine.run(&mut history, "Hello!").await;
|
||||
assert!(matches!(result1, EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 2); // user + assistant
|
||||
|
||||
// Turn 2
|
||||
let result2 = locked_engine.run("Can you help me?").await;
|
||||
assert!(result2.is_ok());
|
||||
assert_eq!(locked_engine.history().len(), 4); // 2 * (user + assistant)
|
||||
let result2 = locked_engine.run(&mut history, "Can you help me?").await;
|
||||
assert!(matches!(result2, EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 4); // 2 * (user + assistant)
|
||||
|
||||
// Verify history contents
|
||||
let history = locked_engine.history();
|
||||
let entries = history.entries();
|
||||
|
||||
// Turn 1 user message
|
||||
assert_eq!(history[0].as_text(), Some("Hello!"));
|
||||
assert_eq!(entries[0].item.as_text(), Some("Hello!"));
|
||||
|
||||
// Turn 1 assistant message
|
||||
assert_eq!(history[1].as_text(), Some("Nice to meet you!"));
|
||||
assert_eq!(entries[1].item.as_text(), Some("Nice to meet you!"));
|
||||
|
||||
// Turn 2 user message
|
||||
assert_eq!(history[2].as_text(), Some("Can you help me?"));
|
||||
assert_eq!(entries[2].item.as_text(), Some("Can you help me?"));
|
||||
|
||||
// Turn 2 assistant message
|
||||
assert_eq!(history[3].as_text(), Some("I can help with that."));
|
||||
assert_eq!(entries[3].item.as_text(), Some("I can help with that."));
|
||||
}
|
||||
|
||||
/// Verify that locked_prefix_len correctly records history length at lock time
|
||||
@@ -367,26 +380,33 @@ async fn test_locked_prefix_len_tracking() {
|
||||
]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Add items beforehand
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Pre-existing message 1")])
|
||||
.append_history(
|
||||
&mut history,
|
||||
vec![Item::user_message("Pre-existing message 1")],
|
||||
)
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Pre-existing response 1")])
|
||||
.append_history(
|
||||
&mut history,
|
||||
vec![Item::assistant_message("Pre-existing response 1")],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
assert_eq!(history.len(), 2);
|
||||
|
||||
// Lock
|
||||
let mut locked_engine = engine.lock();
|
||||
let mut locked_engine = engine.lock(&history);
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time
|
||||
|
||||
// Execute turn
|
||||
locked_engine.run("New message").await.unwrap();
|
||||
locked_engine.run(&mut history, "New message").await;
|
||||
|
||||
// History grows but locked_prefix_len remains unchanged
|
||||
assert_eq!(locked_engine.history().len(), 4); // 2 + 2
|
||||
assert_eq!(history.len(), 4); // 2 + 2
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2); // Unchanged
|
||||
}
|
||||
|
||||
@@ -413,18 +433,22 @@ async fn test_turn_count_increment() -> Result<(), EngineError> {
|
||||
]);
|
||||
|
||||
let engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
assert_eq!(engine.turn_count(), 0);
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
|
||||
// First run consumes Mutable, returns EngineRunOutput
|
||||
let mut engine = engine.run("First").await?.engine;
|
||||
let mut engine = engine.run(&mut history, "First").await.engine;
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
// Retry not yet implemented → AgentTurn:LlmCall is 1:1.
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
|
||||
// Subsequent runs on Locked take &mut self
|
||||
engine.run("Second").await?;
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "Second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.llm_call_count(), 2);
|
||||
|
||||
@@ -444,28 +468,29 @@ async fn test_unlock_edit_relock() {
|
||||
]]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
engine
|
||||
.append_history(vec![
|
||||
Item::user_message("Hello"),
|
||||
Item::assistant_message("Hi"),
|
||||
])
|
||||
.append_history(
|
||||
&mut history,
|
||||
vec![Item::user_message("Hello"), Item::assistant_message("Hi")],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Lock -> Unlock
|
||||
let locked = engine.lock();
|
||||
let locked = engine.lock(&history);
|
||||
assert_eq!(locked.locked_prefix_len(), 2);
|
||||
|
||||
let mut unlocked = locked.unlock();
|
||||
|
||||
// Edit history
|
||||
unlocked.clear_history();
|
||||
unlocked.clear_history(&mut history);
|
||||
unlocked
|
||||
.append_history(vec![Item::user_message("Fresh start")])
|
||||
.append_history(&mut history, vec![Item::user_message("Fresh start")])
|
||||
.unwrap();
|
||||
|
||||
// Re-lock
|
||||
let relocked = unlocked.lock();
|
||||
assert_eq!(relocked.history().len(), 1);
|
||||
let relocked = unlocked.lock(&history);
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(relocked.locked_prefix_len(), 1);
|
||||
}
|
||||
|
||||
@@ -508,19 +533,26 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
||||
]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let tool_a = CountingTool::new("tool_a");
|
||||
engine.register_tool(tool_a.definition());
|
||||
|
||||
let mut locked = engine.lock();
|
||||
locked.run("first").await.expect("first run");
|
||||
let mut locked = engine.lock(&history);
|
||||
assert!(matches!(
|
||||
locked.run(&mut history, "first").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
|
||||
|
||||
let mut unlocked = locked.unlock();
|
||||
let tool_b = CountingTool::new("tool_b");
|
||||
unlocked.register_tool(tool_b.definition());
|
||||
|
||||
let mut relocked = unlocked.lock();
|
||||
relocked.run("second").await.expect("second run");
|
||||
let mut relocked = unlocked.lock(&history);
|
||||
assert!(matches!(
|
||||
relocked.run(&mut history, "second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
|
||||
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
|
||||
@@ -535,8 +567,9 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
||||
fn test_system_prompt_preserved_in_locked_state() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let engine = Engine::new(client).system_prompt("Important system prompt");
|
||||
let history: History = History::new();
|
||||
|
||||
let locked = engine.lock();
|
||||
let locked = engine.lock(&history);
|
||||
assert_eq!(locked.get_system_prompt(), Some("Important system prompt"));
|
||||
|
||||
let unlocked = locked.unlock();
|
||||
@@ -551,13 +584,228 @@ fn test_system_prompt_preserved_in_locked_state() {
|
||||
fn test_system_prompt_change_after_unlock() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let engine = Engine::new(client).system_prompt("Original prompt");
|
||||
let history: History = History::new();
|
||||
|
||||
let locked = engine.lock();
|
||||
let locked = engine.lock(&history);
|
||||
let mut unlocked = locked.unlock();
|
||||
|
||||
unlocked.set_system_prompt("New prompt");
|
||||
assert_eq!(unlocked.get_system_prompt(), Some("New prompt"));
|
||||
|
||||
let relocked = unlocked.lock();
|
||||
let relocked = unlocked.lock(&history);
|
||||
assert_eq!(relocked.get_system_prompt(), Some("New prompt"));
|
||||
}
|
||||
|
||||
fn completed_text_events() -> Vec<Event> {
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "done"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
struct YieldOnce {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for YieldOnce {
|
||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
PreRequestAction::Yield
|
||||
} else {
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PauseToolOnce {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PauseToolOnce {
|
||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
PreToolAction::Pause
|
||||
} else {
|
||||
PreToolAction::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContinueTurnOnce {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ContinueTurnOnce {
|
||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
|
||||
} else {
|
||||
TurnEndAction::Finish
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn max_turns_is_scoped_to_each_fresh_run() {
|
||||
let mut history: History = History::new();
|
||||
let responses = vec![completed_text_events(), completed_text_events()];
|
||||
let mut engine = Engine::new(MockLlmClient::with_responses(responses));
|
||||
engine.set_max_turns(Some(1));
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "first").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yielded_resume_keeps_the_same_unspent_turn_budget() {
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(YieldOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "start").await,
|
||||
EngineRunExit::Yielded
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 0);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(0));
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
let mut history: History = History::new();
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(MockLlmClient::new(events));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "call it").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
assert_eq!(tool.call_count(), 1, "the consumed turn's tool still runs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
let mut history: History = History::new();
|
||||
let tool_events = vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
let client = MockLlmClient::with_responses(vec![tool_events, completed_text_events()]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "pause").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "replace").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
assert_eq!(tool.call_count(), 1, "pending-tool semantics are unchanged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interceptor_continuation_consumes_the_logical_run_budget() {
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(ContinueTurnOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "start").await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_turn_count(7);
|
||||
engine.set_active_run_turn_count(Some(1));
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 7);
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use agen::Engine;
|
||||
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
||||
ToolResultDisposition,
|
||||
};
|
||||
use agen::{Engine, History, Item, ToolExecutionPolicy};
|
||||
use async_trait::async_trait;
|
||||
|
||||
mod common;
|
||||
@@ -70,6 +71,144 @@ impl Tool for SlowTool {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FirstAttemptHangsTool {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl FirstAttemptHangsTool {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new("hang_once")
|
||||
.description("Hangs on the first execution attempt")
|
||||
.input_schema(serde_json::json!({"type": "object"}));
|
||||
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FirstAttemptHangsTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let attempt = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if attempt == 0 {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
Ok("completed on retry".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CooperativeCancelTool {
|
||||
calls: Arc<AtomicUsize>,
|
||||
cancelled: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl CooperativeCancelTool {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
cancelled: Arc::new(tokio::sync::Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new("cooperative")
|
||||
.description("Returns bounded progress after cancellation")
|
||||
.input_schema(serde_json::json!({"type": "object"}));
|
||||
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CooperativeCancelTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.cancelled.notified().await;
|
||||
Err(ToolError::Cancelled(ToolOutput {
|
||||
summary: "cooperative command cancelled".to_string(),
|
||||
content: Some("stdout before cancellation\nstderr before cancellation".to_string()),
|
||||
attachments: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||
self.cancelled.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SafePauseTool {
|
||||
calls: Arc<AtomicUsize>,
|
||||
cancellations: Arc<AtomicUsize>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl SafePauseTool {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
cancellations: Arc::new(AtomicUsize::new(0)),
|
||||
release: Arc::new(tokio::sync::Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let tool = self.clone();
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new("safe_pause")
|
||||
.description("Waits for a safe-boundary release")
|
||||
.input_schema(serde_json::json!({"type": "object"}));
|
||||
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SafePauseTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.release.notified().await;
|
||||
Ok(ToolOutput {
|
||||
summary: "safe-boundary complete".to_string(),
|
||||
content: Some("safe-boundary complete".to_string()),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||
self.cancellations.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ContextRecordingTool {
|
||||
name: String,
|
||||
@@ -145,6 +284,7 @@ async fn test_parallel_tool_execution() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let tool1 = SlowTool::new("slow_tool_1", 100);
|
||||
let tool2 = SlowTool::new("slow_tool_2", 100);
|
||||
let tool3 = SlowTool::new("slow_tool_3", 100);
|
||||
@@ -159,7 +299,7 @@ async fn test_parallel_tool_execution() {
|
||||
|
||||
let start = Instant::now();
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _result = engine.run("Run all tools").await;
|
||||
let _result = engine.run(&mut history, "Run all tools").await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// Verify all tools were called
|
||||
@@ -178,6 +318,450 @@ async fn test_parallel_tool_execution() {
|
||||
println!("Parallel execution completed in {:?}", elapsed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_results_commit_before_publish_without_waiting_for_siblings() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_slow", "slow_first"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "call_fast", "fast_second"),
|
||||
Event::tool_input_delta(1, r#"{}"#),
|
||||
Event::tool_use_stop(1),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Done"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let client_probe = client.clone();
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(SlowTool::new("slow_first", 100).definition());
|
||||
engine.register_tool(SlowTool::new("fast_second", 5).definition());
|
||||
|
||||
let observed = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let published = observed.clone();
|
||||
engine.on_tool_result(move |result| {
|
||||
published
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("publish:{}", result.tool_use_id));
|
||||
});
|
||||
|
||||
let committed = observed.clone();
|
||||
let mut annotate = move |item: &Item| {
|
||||
if let Item::ToolResult { call_id, .. } = item {
|
||||
committed.lock().unwrap().push(format!("commit:{call_id}"));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut history = History::new();
|
||||
let _ = engine
|
||||
.run_with_annotation(&mut history, "run both", &mut annotate)
|
||||
.await;
|
||||
observed.lock().unwrap().push("run-returned".to_string());
|
||||
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
[
|
||||
"commit:call_fast",
|
||||
"publish:call_fast",
|
||||
"commit:call_slow",
|
||||
"publish:call_slow",
|
||||
"run-returned",
|
||||
]
|
||||
);
|
||||
|
||||
let committed_order: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match &entry.item {
|
||||
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(committed_order, ["call_fast", "call_slow"]);
|
||||
|
||||
let requests = client_probe.requests();
|
||||
let projected_order: Vec<_> = requests[1]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(projected_order, ["call_slow", "call_fast"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_preserves_completed_results_and_resume_skips_them() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_hang", "hang_once"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "call_fast_a", "fast_a"),
|
||||
Event::tool_input_delta(1, r#"{}"#),
|
||||
Event::tool_use_stop(1),
|
||||
Event::tool_use_start(2, "call_fast_b", "fast_b"),
|
||||
Event::tool_input_delta(2, r#"{}"#),
|
||||
Event::tool_use_stop(2),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Recovered"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let hanging = FirstAttemptHangsTool::new();
|
||||
let fast_a = SlowTool::new("fast_a", 1);
|
||||
let fast_b = SlowTool::new("fast_b", 2);
|
||||
engine.register_tool(hanging.definition());
|
||||
engine.register_tool(fast_a.definition());
|
||||
engine.register_tool(fast_b.definition());
|
||||
|
||||
let cancel = engine.cancel_sender();
|
||||
let cancel_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
cancel.send(()).await.unwrap();
|
||||
});
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "start").await;
|
||||
let mut engine = output.engine;
|
||||
cancel_task.await.unwrap();
|
||||
|
||||
let completed_before_resume = history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult { call_id, .. }
|
||||
if call_id == "call_fast_a" || call_id == "call_fast_b"
|
||||
)
|
||||
})
|
||||
.count();
|
||||
let unknown_before_resume = history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||
..
|
||||
} if call_id == "call_hang"
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(completed_before_resume, 2);
|
||||
assert_eq!(unknown_before_resume, 1);
|
||||
assert_eq!(fast_a.call_count(), 1);
|
||||
assert_eq!(fast_b.call_count(), 1);
|
||||
assert_eq!(hanging.call_count(), 1);
|
||||
|
||||
let _ = engine.resume(&mut history).await;
|
||||
|
||||
assert_eq!(
|
||||
fast_a.call_count(),
|
||||
1,
|
||||
"completed call must not be re-executed"
|
||||
);
|
||||
assert_eq!(
|
||||
fast_b.call_count(),
|
||||
1,
|
||||
"completed call must not be re-executed"
|
||||
);
|
||||
assert_eq!(
|
||||
hanging.call_count(),
|
||||
1,
|
||||
"OutcomeUnknown is terminal and must not be re-executed"
|
||||
);
|
||||
let completed_after_resume = history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult { call_id, .. }
|
||||
if call_id == "call_fast_a" || call_id == "call_fast_b"
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(completed_after_resume, 2);
|
||||
assert_eq!(
|
||||
history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||
..
|
||||
} if call_id == "call_hang"
|
||||
)
|
||||
})
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cooperative_cancellation_commits_bounded_terminal_output() {
|
||||
let client = MockLlmClient::with_responses(vec![vec![
|
||||
Event::tool_use_start(0, "call_cooperative", "cooperative"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]]);
|
||||
let mut engine = Engine::new(client);
|
||||
let tool = CooperativeCancelTool::new();
|
||||
engine.register_tool(tool.definition());
|
||||
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||
let published = observed.clone();
|
||||
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
|
||||
let committed = observed.clone();
|
||||
let mut annotate = move |item: &Item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
committed.lock().unwrap().push("committed");
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let cancel = engine.cancel_sender();
|
||||
let cancel_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
cancel.send(()).await.unwrap();
|
||||
});
|
||||
let mut history = History::new();
|
||||
let output = engine
|
||||
.run_with_annotation(&mut history, "start", &mut annotate)
|
||||
.await;
|
||||
observed.lock().unwrap().push("run-returned");
|
||||
cancel_task.await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
["committed", "published", "run-returned"]
|
||||
);
|
||||
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||
let terminal: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match &entry.item {
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition,
|
||||
content,
|
||||
..
|
||||
} if call_id == "call_cooperative" => Some((*disposition, content.as_deref())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(terminal.len(), 1);
|
||||
assert_eq!(terminal[0].0, ToolResultDisposition::Cancelled);
|
||||
assert_eq!(
|
||||
terminal[0].1,
|
||||
Some("stdout before cancellation\nstderr before cancellation")
|
||||
);
|
||||
assert!(matches!(
|
||||
output.result,
|
||||
agen::EngineRunExit::Interrupted(agen::StopReason::Cancelled)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_waits_for_started_tool_terminal_without_cancelling_provider() {
|
||||
let client = MockLlmClient::with_responses(vec![vec![
|
||||
Event::tool_use_start(0, "call_safe_pause", "safe_pause"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]]);
|
||||
let mut engine = Engine::new(client);
|
||||
let tool = SafePauseTool::new();
|
||||
engine.register_tool(tool.definition());
|
||||
|
||||
let pause = engine.pause_sender();
|
||||
let calls = Arc::clone(&tool.calls);
|
||||
let release = Arc::clone(&tool.release);
|
||||
let control = tokio::spawn(async move {
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while calls.load(Ordering::SeqCst) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("tool execution starts");
|
||||
pause.send(()).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
release.notify_one();
|
||||
});
|
||||
|
||||
let started_at = std::time::Instant::now();
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "pause safely").await;
|
||||
control.await.unwrap();
|
||||
|
||||
assert!(started_at.elapsed() >= Duration::from_millis(50));
|
||||
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0);
|
||||
assert!(matches!(output.result, agen::EngineRunExit::Paused));
|
||||
assert!(history.iter().any(|entry| matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::Success,
|
||||
..
|
||||
} if call_id == "call_safe_pause"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_escalates_to_explicit_cancel_and_confirm_after_safe_boundary_deadline() {
|
||||
let client = MockLlmClient::with_responses(vec![vec![
|
||||
Event::tool_use_start(0, "call_pause_cancel", "cooperative"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]]);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_millis(20),
|
||||
cancellation_request_timeout: Duration::from_millis(50),
|
||||
terminal_confirmation_timeout: Duration::from_millis(100),
|
||||
});
|
||||
let tool = CooperativeCancelTool::new();
|
||||
engine.register_tool(tool.definition());
|
||||
|
||||
let pause = engine.pause_sender();
|
||||
let calls = Arc::clone(&tool.calls);
|
||||
let control = tokio::spawn(async move {
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while calls.load(Ordering::SeqCst) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("tool execution starts");
|
||||
pause.send(()).await.unwrap();
|
||||
});
|
||||
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "pause with escalation").await;
|
||||
control.await.unwrap();
|
||||
|
||||
assert!(matches!(output.result, agen::EngineRunExit::Paused));
|
||||
assert!(history.iter().any(|entry| matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::Cancelled,
|
||||
..
|
||||
} if call_id == "call_pause_cancel"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_completion_race_commits_one_terminal_output() {
|
||||
for iteration in 0..24u64 {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
vec![
|
||||
Event::tool_use_start(0, "call_racy", "racy"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
],
|
||||
vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let delay = 2 + iteration % 3;
|
||||
let tool = SlowTool::new("racy", delay);
|
||||
engine.register_tool(tool.definition());
|
||||
let cancel = engine.cancel_sender();
|
||||
let cancel_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(delay)).await;
|
||||
let _ = cancel.send(()).await;
|
||||
});
|
||||
|
||||
let mut history = History::new();
|
||||
let _ = engine.run(&mut history, "race").await;
|
||||
cancel_task.await.unwrap();
|
||||
let terminal_count = history
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult { call_id, .. } if call_id == "call_racy"
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(terminal_count, 1, "iteration {iteration}");
|
||||
assert_eq!(tool.call_count(), 1, "iteration {iteration}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_result_commit_failure_prevents_publication() {
|
||||
let client = MockLlmClient::with_responses(vec![vec![
|
||||
Event::tool_use_start(0, "call_fast", "fast"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]]);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(SlowTool::new("fast", 1).definition());
|
||||
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let published_probe = published.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
published_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let mut history = History::new();
|
||||
let mut reject_tool_result = |item: &Item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
Err("session log unavailable".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let _ = engine
|
||||
.run_with_annotation(&mut history, "start", &mut reject_tool_result)
|
||||
.await;
|
||||
|
||||
assert_eq!(published.load(Ordering::SeqCst), 0);
|
||||
assert!(
|
||||
history
|
||||
.iter()
|
||||
.all(|entry| !matches!(entry.item, Item::ToolResult { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_execution_context_order_and_batch_id() {
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
@@ -205,13 +789,14 @@ async fn test_tool_execution_context_order_and_batch_id() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
engine.register_tool(ContextRecordingTool::new("record_a", contexts.clone()).definition());
|
||||
engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition());
|
||||
engine.register_tool(ContextRecordingTool::new("record_c", contexts.clone()).definition());
|
||||
|
||||
let _ = engine.run("record contexts").await;
|
||||
let _ = engine.run(&mut history, "record contexts").await;
|
||||
|
||||
let mut contexts = contexts.lock().unwrap().clone();
|
||||
contexts.sort_by_key(|ctx| ctx.call_index);
|
||||
@@ -256,11 +841,12 @@ async fn test_tool_execution_context_batch_id_changes_between_batches() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
engine.register_tool(ContextRecordingTool::new("record", contexts.clone()).definition());
|
||||
|
||||
let _ = engine.run("record batches").await;
|
||||
let _ = engine.run(&mut history, "record batches").await;
|
||||
|
||||
let contexts = contexts.lock().unwrap().clone();
|
||||
assert_eq!(contexts.len(), 2);
|
||||
@@ -298,6 +884,7 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let executed_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
let pre_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
let post_contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -344,7 +931,9 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
|
||||
post_contexts: post_contexts.clone(),
|
||||
});
|
||||
|
||||
let _ = engine.run("record skipped and synthetic contexts").await;
|
||||
let _ = engine
|
||||
.run(&mut history, "record skipped and synthetic contexts")
|
||||
.await;
|
||||
|
||||
let mut pre_contexts = pre_contexts.lock().unwrap().clone();
|
||||
pre_contexts.sort_by_key(|ctx| ctx.call_index);
|
||||
@@ -389,6 +978,7 @@ async fn test_before_tool_call_skip() {
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
let allowed_tool = SlowTool::new("allowed_tool", 10);
|
||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||
@@ -416,7 +1006,7 @@ async fn test_before_tool_call_skip() {
|
||||
engine.set_interceptor(BlockingPolicy);
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let _result = engine.run("Test hook").await;
|
||||
let _result = engine.run(&mut history, "Test hook").await;
|
||||
|
||||
// allowed_tool is called, but blocked_tool is not
|
||||
assert_eq!(
|
||||
@@ -457,6 +1047,7 @@ async fn test_post_tool_call_modification() {
|
||||
]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SimpleTool;
|
||||
@@ -503,9 +1094,12 @@ async fn test_post_tool_call_modification() {
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run("Test modification").await;
|
||||
let result = engine.run(&mut history, "Test modification").await;
|
||||
|
||||
assert!(result.is_ok(), "Engine should complete");
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete"
|
||||
);
|
||||
|
||||
// Verify hook was called and content was modified
|
||||
let content = modified_content.lock().unwrap().clone();
|
||||
@@ -540,6 +1134,7 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
||||
],
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
let blocked_tool = SlowTool::new("blocked_tool", 10);
|
||||
let blocked_clone = blocked_tool.clone();
|
||||
engine.register_tool(blocked_tool.definition());
|
||||
@@ -558,10 +1153,10 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
||||
|
||||
engine.set_interceptor(SyntheticPolicy);
|
||||
|
||||
let result = engine.run("Test synthetic result").await.unwrap();
|
||||
let _result = engine.run(&mut history, "Test synthetic result").await;
|
||||
|
||||
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
|
||||
assert!(result.engine.history().iter().any(|item| matches!(
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
agen::Item::ToolResult {
|
||||
call_id,
|
||||
@@ -571,3 +1166,76 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
||||
} if call_id == "call_1" && summary == "permission denied"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
||||
let client = MockLlmClient::new(vec![
|
||||
Event::tool_use_start(0, "call_confirmed", "confirmed"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
let tool = SlowTool::new("confirmed", 1);
|
||||
engine.register_tool(tool.definition());
|
||||
|
||||
struct AbortAfterResult;
|
||||
#[async_trait]
|
||||
impl Interceptor for AbortAfterResult {
|
||||
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||
PostToolAction::Abort("policy stopped the run".to_string())
|
||||
}
|
||||
}
|
||||
engine.set_interceptor(AbortAfterResult);
|
||||
|
||||
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||
let published = observed.clone();
|
||||
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
|
||||
let committed = observed.clone();
|
||||
let mut annotate = move |item: &Item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
committed.lock().unwrap().push("committed");
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let mut history = History::new();
|
||||
let output = engine
|
||||
.run_with_annotation(&mut history, "run confirmed tool", &mut annotate)
|
||||
.await;
|
||||
observed.lock().unwrap().push("run-returned");
|
||||
|
||||
assert_eq!(tool.call_count(), 1);
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
["committed", "published", "run-returned"]
|
||||
);
|
||||
assert!(matches!(
|
||||
output.result,
|
||||
agen::EngineRunExit::Interrupted(agen::StopReason::Unexpected(
|
||||
agen::EngineError::Aborted(ref reason)
|
||||
)) if reason == "policy stopped the run"
|
||||
));
|
||||
let terminal: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match &entry.item {
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition,
|
||||
..
|
||||
} if call_id == "call_confirmed" => Some(*disposition),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(terminal, [ToolResultDisposition::Success]);
|
||||
assert!(!history.iter().any(|entry| matches!(
|
||||
&entry.item,
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||
..
|
||||
} if call_id == "call_confirmed"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
|
||||
mod common;
|
||||
|
||||
use agen::Engine;
|
||||
use agen::Item;
|
||||
use agen::llm_client::event::{
|
||||
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
|
||||
StatusEvent,
|
||||
};
|
||||
use agen::{Engine, History};
|
||||
use common::MockLlmClient;
|
||||
|
||||
fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> {
|
||||
@@ -65,15 +65,15 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
|
||||
]);
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("question?").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
let mut history: History = History::new();
|
||||
let _out = engine.run(&mut history, "question?").await;
|
||||
|
||||
let history = engine.history();
|
||||
let entries = history.entries();
|
||||
// user / reasoning / assistant_message
|
||||
assert_eq!(history.len(), 3, "history: {history:?}");
|
||||
|
||||
assert!(matches!(history[0], Item::Message { .. }));
|
||||
match &history[1] {
|
||||
assert!(matches!(entries[0].item, Item::Message { .. }));
|
||||
match &entries[1].item {
|
||||
Item::Reasoning {
|
||||
text, signature, ..
|
||||
} => {
|
||||
@@ -82,7 +82,7 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
|
||||
}
|
||||
other => panic!("expected Reasoning, got {other:?}"),
|
||||
}
|
||||
assert_eq!(history[2].as_text(), Some("Here's the answer"));
|
||||
assert_eq!(entries[2].item.as_text(), Some("Here's the answer"));
|
||||
}
|
||||
|
||||
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
|
||||
@@ -109,11 +109,11 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
|
||||
]);
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("q").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
let mut history: History = History::new();
|
||||
let _out = engine.run(&mut history, "q").await;
|
||||
|
||||
let history = engine.history();
|
||||
match &history[1] {
|
||||
let entries = history.entries();
|
||||
match &entries[1].item {
|
||||
Item::Reasoning {
|
||||
text,
|
||||
summary,
|
||||
@@ -155,13 +155,13 @@ async fn reasoning_precedes_text_in_assistant_burst() {
|
||||
}));
|
||||
let client = MockLlmClient::new(events);
|
||||
let engine = Engine::new(client);
|
||||
let out = engine.run("q").await.expect("run ok");
|
||||
let engine = out.engine;
|
||||
let mut history: History = History::new();
|
||||
let _out = engine.run(&mut history, "q").await;
|
||||
|
||||
let history = engine.history();
|
||||
let entries = history.entries();
|
||||
// user / reasoning(先頭) / assistant_message
|
||||
assert!(matches!(history[1], Item::Reasoning { .. }));
|
||||
assert_eq!(history[2].as_text(), Some("intermediate"));
|
||||
assert!(matches!(entries[1].item, Item::Reasoning { .. }));
|
||||
assert_eq!(entries[2].item.as_text(), Some("intermediate"));
|
||||
}
|
||||
|
||||
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
|
||||
@@ -207,14 +207,18 @@ async fn injected_reasoning_survives_into_outgoing_request() {
|
||||
};
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
// resume: 既存 history を流し込む
|
||||
engine.set_history(vec![
|
||||
engine.set_history(
|
||||
&mut history,
|
||||
vec![
|
||||
Item::user_message("prior question"),
|
||||
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
||||
Item::assistant_message("prior answer"),
|
||||
]);
|
||||
],
|
||||
);
|
||||
|
||||
let _ = engine.run("follow up").await.expect("run ok");
|
||||
let _ = engine.run(&mut history, "follow up").await;
|
||||
|
||||
let req = captured
|
||||
.lock()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use agen::Engine;
|
||||
use agen::{Engine, History};
|
||||
use agen::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
@@ -22,7 +22,8 @@ fn main() {
|
||||
cap,
|
||||
);
|
||||
let engine = Engine::new(client);
|
||||
let mut locked = engine.lock();
|
||||
let history = History::new();
|
||||
let mut locked = engine.lock(&history);
|
||||
let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused"));
|
||||
let _ = locked.register_tool(def);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
error[E0599]: no method named `register_tool` found for struct `Engine<HttpTransport<AnthropicScheme>, Locked>` in the current scope
|
||||
--> tests/ui/locked_register_tool.rs:27:20
|
||||
--> tests/ui/locked_register_tool.rs:28:20
|
||||
|
|
||||
27 | let _ = locked.register_tool(def);
|
||||
28 | let _ = locked.register_tool(def);
|
||||
| ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>`
|
||||
|
|
||||
= note: the method was found for
|
||||
- `Engine<C>`
|
||||
- `Engine<C, Mutable, A>`
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
match slug {
|
||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 3,
|
||||
revision: 4,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}),
|
||||
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 3,
|
||||
revision: 4,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}];
|
||||
@@ -46,6 +46,30 @@ pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coder_review_flow_uses_current_selector_ref_review_contract() {
|
||||
let source = builtin_flow_source(CODER_REVIEW_FLOW_SLUG).expect("coder review Flow");
|
||||
for required in [
|
||||
"OpenMergeRequest",
|
||||
"ShowMergeRequest",
|
||||
"ReviewMergeRequest",
|
||||
"CompleteMergeRequest",
|
||||
"existing Merge Request `selector_from`",
|
||||
"Target-only movement does not invalidate",
|
||||
] {
|
||||
assert!(source.content.contains(required), "missing {required}");
|
||||
}
|
||||
for stale in [
|
||||
"MergeRequestOpen",
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReview",
|
||||
"MergeRequestComplete",
|
||||
"new immutable revision",
|
||||
] {
|
||||
assert!(!source.content.contains(stale), "stale contract {stale}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
||||
assert!(!builtin_flow_sources().is_empty());
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
|
||||
use crate::schema::{EvidenceKind, EvidenceOrigin, SourceEvidenceRef, SourceRef};
|
||||
|
||||
/// Current flat staging schema version.
|
||||
pub const STAGING_SCHEMA_VERSION: u32 = 2;
|
||||
@@ -80,6 +80,8 @@ pub struct StagingEvidence {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_range: Option<[u64; 2]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<EvidenceOrigin>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub excerpt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
@@ -159,6 +161,7 @@ mod tests {
|
||||
id: "E001".into(),
|
||||
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
|
||||
entry_range: Some([10, 12]),
|
||||
origin: None,
|
||||
excerpt: Some("extract candidate taxonomy".into()),
|
||||
summary: Some("User and assistant discussed staging kinds".into()),
|
||||
};
|
||||
|
||||
@@ -67,6 +67,40 @@ impl EvidenceKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EvidenceOriginKind {
|
||||
HumanInput,
|
||||
WorkerInput,
|
||||
FlowInstruction,
|
||||
BackendInstruction,
|
||||
ModelOutput,
|
||||
ToolOutput,
|
||||
DerivedSummary,
|
||||
LegacyUnknown,
|
||||
}
|
||||
|
||||
/// Bounded origin snapshot attached to extraction evidence. This is audit
|
||||
/// metadata only and cannot authorize Workspace operations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct EvidenceOrigin {
|
||||
pub kind: EvidenceOriginKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_definition_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_definition_revision: Option<u64>,
|
||||
}
|
||||
|
||||
/// Host-resolved source/evidence metadata for an individual staging claim.
|
||||
///
|
||||
/// This deliberately stores only bounded anchor metadata: stable ids, entry
|
||||
@@ -86,6 +120,9 @@ pub struct SourceEvidenceRef {
|
||||
/// Host-assigned evidence id within the referenced evidence set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub evidence_id: Option<String>,
|
||||
/// Trusted typed origin snapshot for this logical evidence entry.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<EvidenceOrigin>,
|
||||
/// Extensible evidence kind tag.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub evidence_kind: Option<EvidenceKind>,
|
||||
|
||||
@@ -10,7 +10,10 @@ mod decision;
|
||||
mod request;
|
||||
mod summary;
|
||||
|
||||
pub use common::{EvidenceKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter};
|
||||
pub use common::{
|
||||
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, Frontmatter, SourceEvidenceRef, SourceRef,
|
||||
split_frontmatter,
|
||||
};
|
||||
pub use decision::{DecisionFrontmatter, DecisionStatus};
|
||||
pub use request::RequestFrontmatter;
|
||||
pub use summary::SummaryFrontmatter;
|
||||
|
||||
@@ -416,7 +416,7 @@ impl MergeRequestStore {
|
||||
let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?;
|
||||
if conflict {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket already has an open Merge Request".into(),
|
||||
"Ticket already has an open Merge Request; use ShowMergeRequest and advance the existing selector_from with a normal non-force push instead of opening a replacement Merge Request or adding a revision".into(),
|
||||
));
|
||||
}
|
||||
let now = i.now.to_rfc3339();
|
||||
@@ -575,12 +575,16 @@ impl MergeRequestStore {
|
||||
));
|
||||
};
|
||||
if subject != i.current_subject_ref {
|
||||
let reason = format!(
|
||||
"selector_from moved from requested subject {subject} to current subject {}; fresh review of the exact current source ref is required",
|
||||
i.current_subject_ref
|
||||
);
|
||||
let e = ReviewCancelledEvent {
|
||||
event_id: Uuid::now_v7().to_string(),
|
||||
sequence: next_seq(&t, &ws, &mr)?,
|
||||
request_event_id: req,
|
||||
subject_ref: subject,
|
||||
reason: "selector_from moved before submission".into(),
|
||||
reason,
|
||||
created_at: i.now,
|
||||
};
|
||||
insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?;
|
||||
@@ -667,9 +671,27 @@ impl MergeRequestStore {
|
||||
}
|
||||
match (&i.current_subject_ref, &review) {
|
||||
(None, _) => b.push("selector_from could not be resolved".into()),
|
||||
(Some(_), None) => b.push("current source ref has no valid review".into()),
|
||||
(_, Some(r)) if r.decision == ReviewDecision::RequestChanges => {
|
||||
b.push("current source ref requests changes".into())
|
||||
(Some(subject_ref), None) => {
|
||||
let previous_review_subject = mr.thread.iter().rev().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(value) => {
|
||||
Some(value.subject_ref.as_str())
|
||||
}
|
||||
MergeRequestThreadEvent::Review(value) => Some(value.subject_ref.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
match previous_review_subject.filter(|previous| *previous != subject_ref) {
|
||||
Some(previous) => b.push(format!(
|
||||
"selector_from moved from reviewed/requested subject {previous} to current subject {subject_ref}; request a fresh review for this exact source ref (selector_to movement alone does not invalidate source approval)"
|
||||
)),
|
||||
None => b.push(format!(
|
||||
"current source ref {subject_ref} has no valid review; request a fresh review for this exact source ref"
|
||||
)),
|
||||
}
|
||||
}
|
||||
(Some(subject_ref), Some(r)) if r.decision == ReviewDecision::RequestChanges => {
|
||||
b.push(format!(
|
||||
"current source ref {subject_ref} requests changes; advance the existing selector_from with a normal non-force push, then request a fresh review for the exact new source ref"
|
||||
))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -181,16 +181,85 @@ fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_retur
|
||||
.is_err()
|
||||
);
|
||||
let mr = s.get("W", "T").unwrap();
|
||||
let cancellation = mr.thread.iter().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewCancelled(value) => Some(value),
|
||||
_ => None,
|
||||
});
|
||||
assert!(
|
||||
mr.thread
|
||||
.iter()
|
||||
.any(|e| matches!(e, MergeRequestThreadEvent::ReviewCancelled(_)))
|
||||
cancellation
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.reason.contains("selector_from moved")
|
||||
&& value.reason.contains("fresh review"))
|
||||
);
|
||||
assert_eq!(
|
||||
mr.effective_review("source-a").map(|r| &r.event_id),
|
||||
Some(&approved.event_id)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_only_approval() {
|
||||
let (_d, s) = fixture();
|
||||
open(&s);
|
||||
let first = approve(&s, "source-1", "one");
|
||||
|
||||
let stale = s
|
||||
.readiness(ReadinessCheck {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("source-2".into()),
|
||||
auth: auth(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!stale.ready);
|
||||
assert!(stale.review.is_none());
|
||||
assert!(stale.blockers.iter().any(|blocker| {
|
||||
blocker.contains("selector_from moved from reviewed/requested subject source-1")
|
||||
&& blocker.contains("current subject source-2")
|
||||
&& blocker.contains("fresh review")
|
||||
}));
|
||||
assert_eq!(
|
||||
s.get("W", "T")
|
||||
.unwrap()
|
||||
.effective_review("source-1")
|
||||
.map(|review| &review.event_id),
|
||||
Some(&first.event_id)
|
||||
);
|
||||
|
||||
let second = approve(&s, "source-2", "two");
|
||||
let ready = s
|
||||
.readiness(ReadinessCheck {
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: Some("source-2".into()),
|
||||
auth: auth(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(ready.ready);
|
||||
assert_eq!(
|
||||
ready.review.as_ref().map(|review| &review.event_id),
|
||||
Some(&second.event_id)
|
||||
);
|
||||
|
||||
// The target can move from target-1 to target-2 without changing selector_from
|
||||
// or invalidating the exact-source approval. Completion consumes refreshed
|
||||
// integration evidence for the current target pair.
|
||||
let merged = s
|
||||
.complete(CompleteMergeRequest {
|
||||
operation_id: "target-moved".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "source-2".into(),
|
||||
target_ref_before: "target-2".into(),
|
||||
target_ref_after: "integrated-target-2".into(),
|
||||
approval_event_id: second.event_id,
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(5),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(merged.approved_source_ref, "source-2");
|
||||
assert_eq!(merged.target_ref_before, "target-2");
|
||||
assert_eq!(merged.target_ref_after, "integrated-target-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_revocation_invalidates_readiness() {
|
||||
let (_d, s) = fixture();
|
||||
|
||||
+104
-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.
|
||||
@@ -319,6 +352,18 @@ pub struct InternalWorkerSnapshot {
|
||||
pub internal_workers: Vec<InternalWorkerSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolResultDisposition {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
Interrupted,
|
||||
Cancelled,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||
@@ -468,6 +513,8 @@ pub enum Event {
|
||||
/// summary-only, or when the result was pruned.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
output: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
disposition: Option<ToolResultDisposition>,
|
||||
#[serde(default)]
|
||||
is_error: bool,
|
||||
},
|
||||
@@ -607,23 +654,18 @@ pub enum Event {
|
||||
/// This is not part of LLM history or prompt context; clients may display it
|
||||
/// briefly as operational status.
|
||||
MemoryWorker(MemoryWorkerEvent),
|
||||
/// Worker has started compacting the current session.
|
||||
///
|
||||
/// Fired immediately before a compaction run. Success is signalled by
|
||||
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
|
||||
/// Broadcast to all clients; not replayed to late subscribers.
|
||||
CompactStart,
|
||||
/// Compaction completed and the session was rotated.
|
||||
///
|
||||
/// `new_segment_id` is the UUID of the freshly created session that
|
||||
/// replaced the old history.
|
||||
CompactDone {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "string"))]
|
||||
new_segment_id: uuid::Uuid,
|
||||
/// Worker has started compacting the current session, or bound the run to its
|
||||
/// observable Internal Worker. Revisions upsert one stable lifecycle item.
|
||||
CompactStart {
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
/// Compaction failed. The session is unchanged.
|
||||
/// Compaction completed and the session was rotated.
|
||||
CompactDone {
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
/// Compaction failed or was cancelled. The session is unchanged.
|
||||
CompactFailed {
|
||||
error: String,
|
||||
lifecycle: CompactionLifecycle,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
@@ -895,6 +937,7 @@ pub enum WorkerStatus {
|
||||
Idle,
|
||||
Running,
|
||||
Paused,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -1732,45 +1775,74 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64);
|
||||
}
|
||||
|
||||
fn test_compaction_lifecycle(state: CompactionLifecycleState) -> CompactionLifecycle {
|
||||
CompactionLifecycle {
|
||||
schema_version: 2,
|
||||
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
|
||||
revision: 1,
|
||||
internal_worker: None,
|
||||
state,
|
||||
started_at_ms: 1_700_000_000_000,
|
||||
ended_at_ms: None,
|
||||
summary: None,
|
||||
error: None,
|
||||
new_segment_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_start_roundtrip() {
|
||||
let event = Event::CompactStart;
|
||||
let event = Event::CompactStart {
|
||||
lifecycle: test_compaction_lifecycle(CompactionLifecycleState::Running),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(json, r#"{"event":"compact_start"}"#);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_start");
|
||||
assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(decoded, Event::CompactStart));
|
||||
assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_done_roundtrip() {
|
||||
let id = uuid::Uuid::parse_str("0192f0e8-4d84-7d6e-a000-000000000001").unwrap();
|
||||
let event = Event::CompactDone { new_segment_id: id };
|
||||
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Done);
|
||||
lifecycle.new_segment_id = Some(id.to_string());
|
||||
lifecycle.summary = Some("accepted summary".into());
|
||||
let event = Event::CompactDone { lifecycle };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_done");
|
||||
assert_eq!(
|
||||
parsed["data"]["new_segment_id"],
|
||||
parsed["data"]["lifecycle"]["new_segment_id"],
|
||||
"0192f0e8-4d84-7d6e-a000-000000000001"
|
||||
);
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::CompactDone { new_segment_id } => assert_eq!(new_segment_id, id),
|
||||
Event::CompactDone { lifecycle } => {
|
||||
assert_eq!(
|
||||
lifecycle.new_segment_id.as_deref(),
|
||||
Some(id.to_string().as_str())
|
||||
)
|
||||
}
|
||||
other => panic!("expected CompactDone, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_compact_failed_roundtrip() {
|
||||
let event = Event::CompactFailed {
|
||||
error: "provider 429".into(),
|
||||
};
|
||||
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Failed);
|
||||
lifecycle.error = Some("provider 429".into());
|
||||
let event = Event::CompactFailed { lifecycle };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "compact_failed");
|
||||
assert_eq!(parsed["data"]["error"], "provider 429");
|
||||
assert_eq!(parsed["data"]["lifecycle"]["error"], "provider 429");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::CompactFailed { error } => assert_eq!(error, "provider 429"),
|
||||
Event::CompactFailed { lifecycle } => {
|
||||
assert_eq!(lifecycle.error.as_deref(), Some("provider 429"))
|
||||
}
|
||||
other => panic!("expected CompactFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -1781,6 +1853,7 @@ mod tests {
|
||||
id: "call_1".into(),
|
||||
summary: "Read 128 bytes".into(),
|
||||
output: Some("hello world".into()),
|
||||
disposition: Some(ToolResultDisposition::Success),
|
||||
is_error: false,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
@@ -1797,11 +1870,13 @@ mod tests {
|
||||
id,
|
||||
summary,
|
||||
output,
|
||||
disposition,
|
||||
is_error,
|
||||
} => {
|
||||
assert_eq!(id, "call_1");
|
||||
assert_eq!(summary, "Read 128 bytes");
|
||||
assert_eq!(output.as_deref(), Some("hello world"));
|
||||
assert_eq!(disposition, Some(ToolResultDisposition::Success));
|
||||
assert!(!is_error);
|
||||
}
|
||||
other => panic!("expected ToolResult, got {other:?}"),
|
||||
@@ -1814,6 +1889,7 @@ mod tests {
|
||||
id: "call_2".into(),
|
||||
summary: "ok".into(),
|
||||
output: None,
|
||||
disposition: Some(ToolResultDisposition::Success),
|
||||
is_error: false,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
@@ -1829,6 +1905,7 @@ mod tests {
|
||||
id: "call_3".into(),
|
||||
summary: "invalid argument".into(),
|
||||
output: None,
|
||||
disposition: Some(ToolResultDisposition::Error),
|
||||
is_error: true,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
|
||||
@@ -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, ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
@@ -45,6 +45,7 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<TurnResult>(&cfg, &mut output);
|
||||
push_decl::<InvokeKind>(&cfg, &mut output);
|
||||
push_decl::<RunResult>(&cfg, &mut output);
|
||||
push_decl::<ToolResultDisposition>(&cfg, &mut output);
|
||||
push_decl::<ErrorCode>(&cfg, &mut output);
|
||||
push_decl::<Permission>(&cfg, &mut output);
|
||||
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
||||
@@ -53,6 +54,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);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Serializable history entries with restore-authoritative logical identity and origin.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{LoggedItem, SessionId};
|
||||
|
||||
/// Stable logical identity of one model-visible history entry.
|
||||
///
|
||||
/// This value is generated at the trusted Worker session boundary and copied
|
||||
/// unchanged across fork, rewind, compaction retention, restore, and reboot.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct LoggedSessionHistoryEntryId(pub String);
|
||||
|
||||
impl LoggedSessionHistoryEntryId {
|
||||
pub fn new() -> Self {
|
||||
Self(uuid::Uuid::now_v7().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LoggedSessionHistoryEntryId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded subject snapshot. It is evidence, not a live authorization handle.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoggedWorkerSubject {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_id: Option<String>,
|
||||
pub worker_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LoggedSessionHistoryOrigin {
|
||||
HumanInput {
|
||||
account_id: String,
|
||||
},
|
||||
WorkerInput {
|
||||
actor: LoggedWorkerSubject,
|
||||
},
|
||||
FlowInstruction {
|
||||
selector: String,
|
||||
definition_id: String,
|
||||
definition_revision: u64,
|
||||
instance_id: String,
|
||||
state_id: String,
|
||||
},
|
||||
BackendInstruction {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
operation_id: Option<String>,
|
||||
},
|
||||
ModelOutput {
|
||||
worker: LoggedWorkerSubject,
|
||||
},
|
||||
ToolOutput {
|
||||
worker: LoggedWorkerSubject,
|
||||
},
|
||||
DerivedSummary,
|
||||
LegacyUnknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoggedHistoryDerivation {
|
||||
pub sources: Vec<LoggedSessionHistoryEntryId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoggedSessionHistoryMetadata {
|
||||
pub entry_id: LoggedSessionHistoryEntryId,
|
||||
pub origin: LoggedSessionHistoryOrigin,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub derivation: Option<LoggedHistoryDerivation>,
|
||||
}
|
||||
|
||||
impl LoggedSessionHistoryMetadata {
|
||||
pub fn legacy_unknown() -> Self {
|
||||
Self {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted item and metadata are one value so transforms cannot reorder or
|
||||
/// truncate one without the other.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LoggedHistoryEntry {
|
||||
pub item: LoggedItem,
|
||||
pub metadata: LoggedSessionHistoryMetadata,
|
||||
}
|
||||
|
||||
/// Typed system-item history record. The typed system event remains available
|
||||
/// to client replay while its model-visible projection carries the same stable
|
||||
/// metadata used by live history.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LoggedSystemHistoryEntry {
|
||||
pub item: crate::SystemItem,
|
||||
pub metadata: LoggedSessionHistoryMetadata,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::LoggedRole;
|
||||
use agen::llm_client::RequestConfig;
|
||||
|
||||
#[test]
|
||||
fn logged_history_entry_round_trip_preserves_id_origin_and_derivation() {
|
||||
let source_id = LoggedSessionHistoryEntryId::new();
|
||||
let entry = LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::User,
|
||||
content: vec![crate::LoggedContentPart::Text {
|
||||
text: "preference".into(),
|
||||
}],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::HumanInput {
|
||||
account_id: "account-1".into(),
|
||||
},
|
||||
derivation: Some(LoggedHistoryDerivation {
|
||||
sources: vec![source_id.clone()],
|
||||
}),
|
||||
},
|
||||
};
|
||||
let encoded = serde_json::to_vec(&entry).unwrap();
|
||||
let decoded: LoggedHistoryEntry = serde_json::from_slice(&encoded).unwrap();
|
||||
assert_eq!(decoded, entry);
|
||||
assert_eq!(
|
||||
decoded.metadata.derivation.unwrap().sources,
|
||||
vec![source_id]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
|
||||
let session_id = uuid::Uuid::now_v7();
|
||||
let history_entry = legacy_logged_history(LoggedItem::Message {
|
||||
role: LoggedRole::Assistant,
|
||||
content: vec![crate::LoggedContentPart::Text {
|
||||
text: "answer".into(),
|
||||
}],
|
||||
});
|
||||
let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![history_entry],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}]);
|
||||
assert_eq!(state.history[0].as_text(), Some("answer"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Session Logs did not persist annotations. Decode helpers explicitly
|
||||
/// create `LegacyUnknown`; they never infer Human/System authority from role or
|
||||
/// plaintext.
|
||||
pub fn legacy_logged_history(item: LoggedItem) -> LoggedHistoryEntry {
|
||||
LoggedHistoryEntry {
|
||||
item,
|
||||
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn legacy_segment_history(
|
||||
session_id: SessionId,
|
||||
items: impl IntoIterator<Item = LoggedItem>,
|
||||
) -> Vec<LoggedHistoryEntry> {
|
||||
let _ = session_id;
|
||||
items.into_iter().map(legacy_logged_history).collect()
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
pub mod event_trace;
|
||||
pub mod fs_store;
|
||||
pub mod history;
|
||||
pub mod logged_item;
|
||||
pub mod segment;
|
||||
pub mod segment_log;
|
||||
@@ -44,6 +45,11 @@ pub use agen::UsageRecord;
|
||||
pub use agen::llm_client::types::{ContentPart, Item, Role};
|
||||
pub use event_trace::{TraceEntry, TracePayload};
|
||||
pub use fs_store::FsStore;
|
||||
pub use history::{
|
||||
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
|
||||
LoggedWorkerSubject, legacy_logged_history, legacy_segment_history,
|
||||
};
|
||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||
pub use segment::{
|
||||
SegmentStartState, append_entry, append_system_item, classify_history_item,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use agen::{
|
||||
llm_client::types::{ContentPart, Item, Role},
|
||||
tool::{Attachment, ImageAttachment},
|
||||
tool::{Attachment, ImageAttachment, ToolResultDisposition},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
@@ -61,6 +61,8 @@ pub enum LoggedItem {
|
||||
content: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<LoggedAttachment>,
|
||||
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||
disposition: ToolResultDisposition,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
},
|
||||
@@ -128,6 +130,7 @@ impl From<&Item> for LoggedItem {
|
||||
summary,
|
||||
content,
|
||||
attachments,
|
||||
disposition,
|
||||
is_error,
|
||||
..
|
||||
} => Self::ToolResult {
|
||||
@@ -135,6 +138,7 @@ impl From<&Item> for LoggedItem {
|
||||
summary: summary.clone(),
|
||||
content: content.clone(),
|
||||
attachments: attachments.iter().map(LoggedAttachment::from).collect(),
|
||||
disposition: *disposition,
|
||||
is_error: *is_error,
|
||||
},
|
||||
Item::Reasoning {
|
||||
@@ -184,15 +188,24 @@ impl From<LoggedItem> for Item {
|
||||
summary,
|
||||
content,
|
||||
attachments,
|
||||
disposition,
|
||||
is_error,
|
||||
} => Item::ToolResult {
|
||||
} => {
|
||||
let disposition = if is_error && disposition.is_success() {
|
||||
ToolResultDisposition::Error
|
||||
} else {
|
||||
disposition
|
||||
};
|
||||
Item::ToolResult {
|
||||
id: None,
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
disposition,
|
||||
is_error,
|
||||
attachments: attachments.into_iter().map(Attachment::from).collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
LoggedItem::Reasoning {
|
||||
text,
|
||||
summary,
|
||||
@@ -430,6 +443,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_unknown_tool_result_round_trips_as_terminal() {
|
||||
let original = Item::tool_result_item_with_disposition_and_attachments(
|
||||
"call_unknown",
|
||||
"outcome unknown",
|
||||
Some("bounded progress".to_string()),
|
||||
ToolResultDisposition::OutcomeUnknown,
|
||||
Vec::new(),
|
||||
);
|
||||
let logged: LoggedItem = (&original).into();
|
||||
let json = serde_json::to_string(&logged).unwrap();
|
||||
assert!(json.contains(r#""disposition":"outcome_unknown""#));
|
||||
match Item::from(serde_json::from_str::<LoggedItem>(&json).unwrap()) {
|
||||
Item::ToolResult {
|
||||
disposition,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(disposition, ToolResultDisposition::OutcomeUnknown);
|
||||
assert!(is_error);
|
||||
}
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_error_tool_result_infers_error_disposition() {
|
||||
let legacy = r#"{"kind":"tool_result","call_id":"call_old","summary":"failed","content":null,"is_error":true}"#;
|
||||
match Item::from(serde_json::from_str::<LoggedItem>(legacy).unwrap()) {
|
||||
Item::ToolResult { disposition, .. } => {
|
||||
assert_eq!(disposition, ToolResultDisposition::Error)
|
||||
}
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_persistence_round_trips_binary_attachments() {
|
||||
let original = Item::tool_result_item_with_attachments(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use agen::{EngineResult, UsageRecord};
|
||||
use protocol::{InvokeKind, Segment};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
|
||||
use crate::logged_item::LoggedItem;
|
||||
use crate::system_item::SystemItem;
|
||||
|
||||
@@ -70,6 +71,20 @@ pub enum LogEntry {
|
||||
compacted_from: Option<SegmentOrigin>,
|
||||
},
|
||||
|
||||
/// Schema-v2 segment seed. Retained entries keep their stable logical
|
||||
/// identity and origin across fork/compaction/restore.
|
||||
AnnotatedSegmentStart {
|
||||
ts: u64,
|
||||
session_id: crate::SessionId,
|
||||
system_prompt: Option<String>,
|
||||
config: RequestConfig,
|
||||
history: Vec<LoggedHistoryEntry>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
forked_from: Option<SegmentOrigin>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
compacted_from: Option<SegmentOrigin>,
|
||||
},
|
||||
|
||||
/// IDLE → active marker. Records the start of a new self-driving
|
||||
/// cycle (Invoke range). The range extends implicitly until the
|
||||
/// next `Invoke` entry; this entry carries the trigger only — the
|
||||
@@ -105,14 +120,37 @@ pub enum LogEntry {
|
||||
extensions: Vec<SessionExtension>,
|
||||
},
|
||||
|
||||
/// Schema-v2 user submission with its exact model-visible entries. Typed
|
||||
/// Flow instructions and caller-attributed input remain separate entries.
|
||||
AnnotatedUserInput {
|
||||
ts: u64,
|
||||
segments: Vec<Segment>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
extensions: Vec<SessionExtension>,
|
||||
history: Vec<LoggedHistoryEntry>,
|
||||
},
|
||||
|
||||
/// Schema-v2 model output and metadata committed as one journal record.
|
||||
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
|
||||
|
||||
/// One assistant-side item appended to history — assistant message,
|
||||
/// reasoning, or tool call. Singular: one entry per history item so
|
||||
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
|
||||
AssistantItem { ts: u64, item: LoggedItem },
|
||||
|
||||
/// Schema-v2 tool output and metadata committed as one journal record.
|
||||
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
|
||||
|
||||
/// One tool-execution result appended to history.
|
||||
ToolResult { ts: u64, item: LoggedItem },
|
||||
|
||||
/// Schema-v2 typed system event and model-visible metadata committed
|
||||
/// together.
|
||||
AnnotatedSystemItem {
|
||||
ts: u64,
|
||||
entry: LoggedSystemHistoryEntry,
|
||||
},
|
||||
|
||||
/// One typed agent-injected system item: notification, child-Worker
|
||||
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
|
||||
/// `SystemItem` carries kind metadata that the LLM
|
||||
@@ -125,11 +163,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 +184,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 +261,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 +292,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(),
|
||||
@@ -261,10 +316,27 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
state.config = config.clone();
|
||||
state.history = history.iter().cloned().map(Item::from).collect();
|
||||
}
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
session_id,
|
||||
system_prompt,
|
||||
config,
|
||||
history,
|
||||
..
|
||||
} => {
|
||||
state.session_id = Some(*session_id);
|
||||
state.system_prompt = system_prompt.clone();
|
||||
state.config = config.clone();
|
||||
state.history = history
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|entry| Item::from(entry.item))
|
||||
.collect();
|
||||
}
|
||||
LogEntry::Invoke { .. } => {
|
||||
// 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,
|
||||
@@ -280,6 +352,29 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
||||
);
|
||||
}
|
||||
LogEntry::AnnotatedUserInput {
|
||||
segments,
|
||||
extensions,
|
||||
history,
|
||||
..
|
||||
} => {
|
||||
state
|
||||
.history
|
||||
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
|
||||
state.user_segments.push(segments.clone());
|
||||
state.extensions.extend(
|
||||
extensions
|
||||
.iter()
|
||||
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
||||
);
|
||||
}
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
||||
state.history.push(Item::from(entry.item.clone()));
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
state.history.push(entry.item.to_history_item());
|
||||
}
|
||||
LogEntry::AssistantItem { item, .. } => {
|
||||
state.history.push(Item::from(item.clone()));
|
||||
}
|
||||
@@ -290,16 +385,44 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
state.history.push(item.to_history_item());
|
||||
}
|
||||
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 +520,7 @@ mod tests {
|
||||
ts: 3200,
|
||||
interrupted: false,
|
||||
result: EngineResult::Finished,
|
||||
active_run_turn_count: None,
|
||||
},
|
||||
]);
|
||||
assert_eq!(state.history.len(), 2);
|
||||
@@ -695,10 +819,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]
|
||||
|
||||
@@ -20,7 +20,8 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const SESSION_SCHEMA_VERSION: u32 = 1;
|
||||
const SESSION_SCHEMA_VERSION: u32 = 2;
|
||||
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
|
||||
const SESSION_FILE: &str = "session.json";
|
||||
const SEGMENTS_DIR: &str = "segments";
|
||||
|
||||
@@ -44,16 +45,23 @@ impl WorkerSessionStore {
|
||||
fs::create_dir_all(root.join(SEGMENTS_DIR))?;
|
||||
let session_id = match fs::read(root.join(SESSION_FILE)) {
|
||||
Ok(bytes) => {
|
||||
let manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||
if manifest.schema_version != SESSION_SCHEMA_VERSION {
|
||||
let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||
match manifest.schema_version {
|
||||
SESSION_SCHEMA_VERSION => {}
|
||||
LEGACY_SESSION_SCHEMA_VERSION => {
|
||||
validate_legacy_segment_logs(&root)?;
|
||||
manifest.schema_version = SESSION_SCHEMA_VERSION;
|
||||
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
|
||||
}
|
||||
version => {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: format!(
|
||||
"unsupported Worker Session schema version {}, expected {}",
|
||||
manifest.schema_version, SESSION_SCHEMA_VERSION
|
||||
"unsupported Worker Session schema version {version}, expected {SESSION_SCHEMA_VERSION}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(manifest.session_id)
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
@@ -278,6 +286,37 @@ impl Store for WorkerSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_legacy_segment_logs(root: &Path) -> Result<(), StoreError> {
|
||||
let segments = root.join(SEGMENTS_DIR);
|
||||
if !segments.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(&segments)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !name.ends_with(".jsonl") || name.ends_with(".trace.jsonl") {
|
||||
continue;
|
||||
}
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
for (line_index, line) in contents.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
serde_json::from_str::<LogEntry>(line).map_err(|error| StoreError::Corrupt {
|
||||
line: line_index + 1,
|
||||
message: format!(
|
||||
"cannot migrate legacy Worker Session log {}: {error}",
|
||||
path.display()
|
||||
),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
|
||||
let mut bytes = serde_json::to_vec_pretty(value)?;
|
||||
bytes.push(b'\n');
|
||||
@@ -405,6 +444,54 @@ mod tests {
|
||||
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v1_logs_are_validated_and_promoted_to_v2() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let segment_id = new_segment_id();
|
||||
WorkerSessionStore::new(root.path())
|
||||
.unwrap()
|
||||
.create_segment(session_id, segment_id, &[])
|
||||
.unwrap();
|
||||
let manifest_path = root.path().join(SESSION_FILE);
|
||||
let mut manifest: SessionManifest =
|
||||
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
|
||||
manifest.schema_version = LEGACY_SESSION_SCHEMA_VERSION;
|
||||
atomic_write_json(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let reopened = WorkerSessionStore::new(root.path()).unwrap();
|
||||
assert_eq!(reopened.session_id().unwrap(), Some(session_id));
|
||||
let migrated: SessionManifest =
|
||||
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
|
||||
assert_eq!(migrated.schema_version, SESSION_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v1_migration_rejects_corrupt_log_before_manifest_update() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let manifest = SessionManifest {
|
||||
schema_version: LEGACY_SESSION_SCHEMA_VERSION,
|
||||
session_id,
|
||||
};
|
||||
atomic_write_json(&root.path().join(SESSION_FILE), &manifest).unwrap();
|
||||
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
|
||||
fs::write(
|
||||
root.path().join(SEGMENTS_DIR).join("broken.jsonl"),
|
||||
"{not-json}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = match WorkerSessionStore::new(root.path()) {
|
||||
Ok(_) => panic!("corrupt legacy Session log must reject migration"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(matches!(error, StoreError::Corrupt { .. }));
|
||||
let persisted: SessionManifest =
|
||||
serde_json::from_slice(&fs::read(root.path().join(SESSION_FILE)).unwrap()).unwrap();
|
||||
assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_preserves_session_and_segment_ids() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -51,6 +51,7 @@ fn round_trip_write_and_read() {
|
||||
ts: 3200,
|
||||
interrupted: false,
|
||||
result: EngineResult::Finished,
|
||||
active_run_turn_count: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
mod common;
|
||||
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::Engine;
|
||||
use agen::interceptor::{Interceptor, TurnEndAction};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::types::{Item, RequestConfig};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, History};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
||||
@@ -94,15 +95,47 @@ fn make_store() -> (tempfile::TempDir, FsStore) {
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
struct TestWorker {
|
||||
engine: Engine<MockLlmClient>,
|
||||
history: History,
|
||||
}
|
||||
|
||||
impl TestWorker {
|
||||
fn new(engine: Engine<MockLlmClient>) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
history: History::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn history(&self) -> Vec<Item> {
|
||||
self.history.items_cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TestWorker {
|
||||
type Target = Engine<MockLlmClient>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.engine
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for TestWorker {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.engine
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a worker turn and persist via session-store functions.
|
||||
/// Takes ownership of the worker (needed for lock/unlock) and returns it.
|
||||
async fn run_and_persist(
|
||||
worker: Engine<MockLlmClient>,
|
||||
mut worker: TestWorker,
|
||||
store: &FsStore,
|
||||
session_id: session_store::SessionId,
|
||||
segment_id: session_store::SegmentId,
|
||||
input: &str,
|
||||
) -> (Engine<MockLlmClient>, agen::EngineResult) {
|
||||
) -> (TestWorker, agen::EngineRunExit) {
|
||||
// Mirror Worker's run-entry contract: log the user input as segments
|
||||
// before the worker pushes its flattened user_message; save_delta
|
||||
// skips the resulting user_message item to avoid double-write.
|
||||
@@ -114,41 +147,61 @@ async fn run_and_persist(
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let history_before = worker.history().len();
|
||||
let history_before = worker.history.len();
|
||||
|
||||
let mut locked = worker.lock();
|
||||
let result = locked.run(input).await;
|
||||
let worker = locked.unlock();
|
||||
let mut locked = worker.engine.lock(&worker.history);
|
||||
let result = locked.run(&mut worker.history, input).await;
|
||||
worker.engine = locked.unlock();
|
||||
|
||||
let new_items = &worker.history()[history_before..];
|
||||
let projected = worker.history();
|
||||
let new_items = &projected[history_before..];
|
||||
session_store::save_delta(store, session_id, segment_id, new_items).unwrap();
|
||||
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
|
||||
|
||||
match &result {
|
||||
Ok(r) => {
|
||||
agen::EngineRunExit::Finished
|
||||
| agen::EngineRunExit::Paused
|
||||
| agen::EngineRunExit::Yielded => {
|
||||
let (legacy_result, interrupted) = match &result {
|
||||
agen::EngineRunExit::Finished => (agen::EngineResult::Finished, false),
|
||||
agen::EngineRunExit::Paused => (agen::EngineResult::Paused, true),
|
||||
agen::EngineRunExit::Yielded => (agen::EngineResult::Yielded, true),
|
||||
agen::EngineRunExit::Interrupted(_) => unreachable!(),
|
||||
};
|
||||
session_store::save_run_completed(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
r.clone(),
|
||||
worker.last_run_interrupted(),
|
||||
legacy_result,
|
||||
interrupted,
|
||||
worker.active_run_turn_count(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
agen::EngineRunExit::Interrupted(agen::StopReason::LimitReached) => {
|
||||
session_store::save_run_completed(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
agen::EngineResult::LimitReached,
|
||||
false,
|
||||
worker.active_run_turn_count(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
agen::EngineRunExit::Interrupted(reason) => {
|
||||
session_store::save_run_errored(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
e.to_string(),
|
||||
worker.last_run_interrupted(),
|
||||
format!("{reason:?}"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let r = result.unwrap();
|
||||
(worker, r)
|
||||
(worker, result)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -159,14 +212,14 @@ async fn run_and_persist(
|
||||
async fn session_run_logs_entries() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let worker = Engine::new(client);
|
||||
let worker = TestWorker::new(Engine::new(client));
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: &worker.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -203,7 +256,7 @@ async fn session_run_logs_entries() {
|
||||
async fn session_restore_round_trip() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
worker.set_system_prompt("You are helpful.");
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
@@ -211,7 +264,7 @@ async fn session_restore_round_trip() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: &worker.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -242,7 +295,7 @@ async fn session_restore_round_trip() {
|
||||
async fn session_run_with_tool_call() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::with_responses(tool_call_events());
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
worker.register_tool(weather_tool_definition());
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
@@ -250,7 +303,7 @@ async fn session_run_with_tool_call() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: &worker.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -276,7 +329,7 @@ async fn session_resume_after_pause() {
|
||||
|
||||
// First run: tool call with pause policy → Paused
|
||||
let client = MockLlmClient::with_responses(tool_call_events());
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
worker.register_tool(weather_tool_definition());
|
||||
worker.set_interceptor(PausePolicy);
|
||||
|
||||
@@ -285,13 +338,13 @@ async fn session_resume_after_pause() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: &worker.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (_worker, result) = run_and_persist(worker, &store, sid, segid, "Weather?").await;
|
||||
assert!(matches!(result, agen::EngineResult::Paused));
|
||||
assert!(matches!(result, agen::EngineRunExit::Paused));
|
||||
|
||||
// Check RunCompleted is Paused
|
||||
let entries = store.read_all(sid, segid).unwrap();
|
||||
@@ -309,13 +362,14 @@ async fn session_resume_after_pause() {
|
||||
// Restore state and verify
|
||||
let state = session_store::restore(&store, sid, segid).unwrap();
|
||||
assert!(state.last_run_interrupted);
|
||||
assert_eq!(state.active_run_turn_count, Some(2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_fork_creates_new_session() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
worker.set_system_prompt("System prompt");
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
@@ -323,7 +377,7 @@ async fn session_fork_creates_new_session() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: &worker.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -336,7 +390,7 @@ async fn session_fork_creates_new_session() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: &worker.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -357,14 +411,14 @@ async fn session_fork_creates_new_session() {
|
||||
async fn session_fork_at_truncates_within_session() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let worker = Engine::new(client);
|
||||
let worker = TestWorker::new(Engine::new(client));
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: &worker.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -402,14 +456,14 @@ async fn session_fork_at_truncates_within_session() {
|
||||
async fn session_config_changed_logged() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: &worker.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -435,14 +489,14 @@ async fn session_auto_forks_on_conflict() {
|
||||
|
||||
// Create a segment
|
||||
let client_a = MockLlmClient::new(simple_text_events());
|
||||
let worker_a = Engine::new(client_a);
|
||||
let worker_a = TestWorker::new(Engine::new(client_a));
|
||||
|
||||
let (sid, original_segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker_a.get_system_prompt(),
|
||||
config: worker_a.request_config(),
|
||||
history: worker_a.history(),
|
||||
history: &worker_a.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -468,7 +522,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker_a.get_system_prompt(),
|
||||
config: worker_a.request_config(),
|
||||
history: worker_a.history(),
|
||||
history: &worker_a.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -520,14 +574,14 @@ async fn session_auto_forks_on_conflict() {
|
||||
async fn nested_past_fork_leaves_ancestors_immutable() {
|
||||
let (_dir, store) = make_store();
|
||||
let client = MockLlmClient::new(simple_text_events());
|
||||
let worker = Engine::new(client);
|
||||
let worker = TestWorker::new(Engine::new(client));
|
||||
|
||||
let (sid, root_segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: worker.history(),
|
||||
history: &worker.history(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
+150
-16
@@ -410,7 +410,7 @@ struct TicketCreateParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketEditItemParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Optional replacement title.
|
||||
#[serde(default)]
|
||||
@@ -539,7 +539,7 @@ impl QueryTicketParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct ShowTicketParams {
|
||||
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
/// Exact ticket id query. Exactly one of `id` or `query` must be provided.
|
||||
@@ -558,7 +558,7 @@ struct ShowTicketParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketThreadEventParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Markdown event body.
|
||||
body: String,
|
||||
@@ -566,7 +566,7 @@ struct TicketThreadEventParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketMarkReadyParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Optional reason attached to the state_changed event.
|
||||
#[serde(default)]
|
||||
@@ -575,7 +575,7 @@ struct TicketMarkReadyParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketIntakeReadyParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Concise bounded intake summary appended before the ready transition.
|
||||
intake_summary: String,
|
||||
@@ -586,13 +586,13 @@ struct TicketIntakeReadyParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketQueueParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketWorkflowStateParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Expected current state. The backend rejects stale transitions.
|
||||
from: TicketWorkflowStateParam,
|
||||
@@ -606,7 +606,7 @@ struct TicketWorkflowStateParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketCloseParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
/// Markdown resolution written to resolution.md and thread.md.
|
||||
resolution: String,
|
||||
@@ -614,7 +614,7 @@ struct TicketCloseParams {
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketDependencyCheckParams {
|
||||
/// Ticket id.
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
@@ -646,7 +646,7 @@ struct TicketRelationRecordParams {
|
||||
ticket: String,
|
||||
/// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of.
|
||||
kind: TicketRelationKindParam,
|
||||
/// Target canonical Ticket id. Title/slug words are not accepted as relation authority.
|
||||
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
target: String,
|
||||
/// Optional bounded rationale/note.
|
||||
#[serde(default)]
|
||||
@@ -659,7 +659,7 @@ struct TicketRelationRemoveParams {
|
||||
ticket: String,
|
||||
/// Forward relation kind to remove.
|
||||
kind: TicketRelationKindParam,
|
||||
/// Target canonical Ticket id.
|
||||
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
target: String,
|
||||
}
|
||||
|
||||
@@ -1223,10 +1223,17 @@ impl Tool for TicketQueueTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||
let queued_by = default_author();
|
||||
let outcome = self
|
||||
let mut outcome = self
|
||||
.backend
|
||||
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||
outcome.requested_ticket =
|
||||
model_ticket_reference(&self.backend, &outcome.requested_ticket, "TicketQueue")?;
|
||||
outcome.queued_tickets = outcome
|
||||
.queued_tickets
|
||||
.into_iter()
|
||||
.map(|ticket| model_ticket_reference(&self.backend, &ticket, "TicketQueue"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Queued {} ticket(s) for Orchestrator",
|
||||
@@ -1264,15 +1271,17 @@ impl Tool for TicketWorkflowStateTool {
|
||||
self.backend
|
||||
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
|
||||
.map_err(|error| backend_error("TicketWorkflowState", error))?;
|
||||
let ticket_ref =
|
||||
model_ticket_reference(&self.backend, ¶ms.ticket, "TicketWorkflowState")?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Transitioned ticket {} state {} -> {}",
|
||||
params.ticket,
|
||||
ticket_ref,
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
),
|
||||
json!({
|
||||
"ticket": params.ticket,
|
||||
"ticket": ticket_ref,
|
||||
"from": from.as_str(),
|
||||
"to": to.as_str(),
|
||||
"state": to.as_str(),
|
||||
@@ -1296,9 +1305,10 @@ impl Tool for TicketCloseTool {
|
||||
MarkdownText::new(params.resolution),
|
||||
)
|
||||
.map_err(|error| backend_error("TicketClose", error))?;
|
||||
let ticket_ref = model_ticket_reference(&self.backend, ¶ms.ticket, "TicketClose")?;
|
||||
Ok(json_output(
|
||||
format!("Closed ticket {}", params.ticket),
|
||||
json!({ "ticket": params.ticket, "state": "closed", "ok": true }),
|
||||
format!("Closed ticket {ticket_ref}"),
|
||||
json!({ "ticket": ticket_ref, "state": "closed", "ok": true }),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1525,6 +1535,29 @@ impl Tool for TicketDependencyCheckTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn model_ticket_reference(
|
||||
backend: &TicketToolBackend,
|
||||
reference: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<String, ToolError> {
|
||||
let ticket = backend
|
||||
.show(TicketIdOrSlug::Id(reference.to_string()))
|
||||
.map_err(|error| backend_error(tool_name, error))?;
|
||||
match ticket.meta.resource_key {
|
||||
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
||||
Some(_) => Err(ToolError::ExecutionFailed(format!(
|
||||
"{tool_name} failed: required Ticket human key is unavailable"
|
||||
))),
|
||||
None => Ok(ticket.meta.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
|
||||
resource_key.strip_prefix("T-").is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||
@@ -1922,6 +1955,12 @@ mod tests {
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
|
||||
crate::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace")
|
||||
.unwrap()
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
|
||||
let (_, tool) = definition();
|
||||
tool
|
||||
@@ -2549,6 +2588,101 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_workflow_and_close_project_internal_inputs_to_ticket_keys() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let inner = sqlite_backend(&temp);
|
||||
let mut dependency_input = NewTicket::new("Dependency");
|
||||
dependency_input.repository_id = Some("main".to_string());
|
||||
let dependency = inner.create(dependency_input).unwrap();
|
||||
let mut target_input = NewTicket::new("Target");
|
||||
target_input.repository_id = Some("main".to_string());
|
||||
let target = inner.create(target_input).unwrap();
|
||||
inner
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id(target.id.clone()),
|
||||
NewTicketRelation {
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: dependency.id.clone(),
|
||||
note: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for id in [&dependency.id, &target.id] {
|
||||
inner
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(id.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ready-{id}"),
|
||||
reason: None,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let target_key = target.resource_key.clone().unwrap();
|
||||
let dependency_key = dependency.resource_key.clone().unwrap();
|
||||
let backend = inner;
|
||||
let queue = tool_by_name(TicketToolBackend::new(backend.clone()), "TicketQueue");
|
||||
let workflow = tool_by_name(
|
||||
TicketToolBackend::new(backend.clone()),
|
||||
"TicketWorkflowState",
|
||||
);
|
||||
let close = tool_by_name(TicketToolBackend::new(backend), "TicketClose");
|
||||
|
||||
let queued = queue
|
||||
.execute(
|
||||
&json!({"ticket": target.id.clone()}).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(queued.summary.contains("2 ticket(s)"));
|
||||
let queued_content = queued.content.unwrap();
|
||||
assert!(queued_content.contains(&target_key));
|
||||
assert!(queued_content.contains(&dependency_key));
|
||||
assert!(!queued_content.contains(&target.id));
|
||||
assert!(!queued_content.contains(&dependency.id));
|
||||
|
||||
for (from, to) in [("queued", "inprogress"), ("inprogress", "done")] {
|
||||
let transitioned = workflow
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": target.id.clone(),
|
||||
"from": from,
|
||||
"to": to,
|
||||
"reason": "test_transition",
|
||||
"body": "transitioned",
|
||||
"author": "tester"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(transitioned.summary.contains(&target_key));
|
||||
assert!(!transitioned.summary.contains(&target.id));
|
||||
let content = transitioned.content.unwrap();
|
||||
assert!(content.contains(&target_key));
|
||||
assert!(!content.contains(&target.id));
|
||||
}
|
||||
|
||||
let closed = close
|
||||
.execute(
|
||||
&json!({"ticket": target.id.clone(), "resolution": "Done"}).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(closed.summary.contains(&target_key));
|
||||
assert!(!closed.summary.contains(&target.id));
|
||||
let content = closed.content.unwrap();
|
||||
assert!(content.contains(&target_key));
|
||||
assert!(!content.contains(&target.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
+156
-11
@@ -1,5 +1,6 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
@@ -20,21 +21,65 @@ struct BashParams {
|
||||
|
||||
pub(crate) struct BashTool {
|
||||
session: WorkdirSessionHandle,
|
||||
state: Arc<Mutex<BashExecutionState>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ActiveCommand {
|
||||
call_id: String,
|
||||
execution_nonce: u64,
|
||||
handle: CommandHandle,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BashExecutionState {
|
||||
active: HashMap<String, ActiveCommand>,
|
||||
cancellation_requested: HashSet<String>,
|
||||
legacy_cancellation_requested: HashSet<String>,
|
||||
next_execution_nonce: u64,
|
||||
}
|
||||
|
||||
struct CommandGuard {
|
||||
session: WorkdirSessionHandle,
|
||||
state: Arc<Mutex<BashExecutionState>>,
|
||||
execution_id: String,
|
||||
execution_nonce: u64,
|
||||
handle: Option<CommandHandle>,
|
||||
}
|
||||
|
||||
impl Drop for CommandGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let Some(handle) = self.handle.take() else {
|
||||
return;
|
||||
};
|
||||
let workdir = self.session.clone();
|
||||
let state = Arc::clone(&self.state);
|
||||
let execution_id = self.execution_id.clone();
|
||||
let execution_nonce = self.execution_nonce;
|
||||
// A dropped provider future is not terminal confirmation. Keep the live
|
||||
// execution registered until cleanup has both requested cancellation and
|
||||
// observed terminal command output, so cancellation/session teardown
|
||||
// cannot race with an apparently empty registry.
|
||||
tokio::spawn(async move {
|
||||
let _ = workdir.cancel_command(handle).await;
|
||||
});
|
||||
let _ = workdir.cancel_command(handle.clone()).await;
|
||||
let _ = workdir
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: INLINE_BYTE_BUDGET,
|
||||
wait: true,
|
||||
})
|
||||
.await;
|
||||
let mut state = state.lock().unwrap();
|
||||
if state
|
||||
.active
|
||||
.get(&execution_id)
|
||||
.is_some_and(|active| active.execution_nonce == execution_nonce)
|
||||
{
|
||||
state.active.remove(&execution_id);
|
||||
state.cancellation_requested.remove(&execution_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,20 +97,50 @@ impl Tool for BashTool {
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||
.clamp(1, MAX_TIMEOUT_SECS);
|
||||
let cmd_summary = truncate_for_summary(¶ms.command);
|
||||
let execution_id = ctx.execution_id();
|
||||
let call_id = ctx.call_id;
|
||||
let execution_nonce = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.next_execution_nonce = state.next_execution_nonce.wrapping_add(1);
|
||||
state.next_execution_nonce
|
||||
};
|
||||
let mut guard = CommandGuard {
|
||||
session: self.session.clone(),
|
||||
state: self.state.clone(),
|
||||
execution_id: execution_id.clone(),
|
||||
execution_nonce,
|
||||
handle: None,
|
||||
};
|
||||
let handle = self
|
||||
.session
|
||||
.start_command(CommandRequest {
|
||||
command: params.command,
|
||||
timeout_secs,
|
||||
output_limit: INLINE_BYTE_BUDGET,
|
||||
tool_call_id: Some(ctx.call_id),
|
||||
tool_call_id: Some(call_id.clone()),
|
||||
})
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
let mut guard = CommandGuard {
|
||||
session: self.session.clone(),
|
||||
handle: Some(handle.clone()),
|
||||
let cancel_after_start = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.active.insert(
|
||||
execution_id.clone(),
|
||||
ActiveCommand {
|
||||
call_id: call_id.clone(),
|
||||
execution_nonce,
|
||||
handle: handle.clone(),
|
||||
},
|
||||
);
|
||||
state.cancellation_requested.contains(&execution_id)
|
||||
|| state.legacy_cancellation_requested.contains(&call_id)
|
||||
};
|
||||
guard.handle = Some(handle.clone());
|
||||
if cancel_after_start {
|
||||
self.session
|
||||
.cancel_command(handle.clone())
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
}
|
||||
let output = self
|
||||
.session
|
||||
.command_output(CommandOutputRequest {
|
||||
@@ -76,9 +151,27 @@ impl Tool for BashTool {
|
||||
})
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
let cancellation_requested = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let owns_registration = state
|
||||
.active
|
||||
.get(&execution_id)
|
||||
.is_some_and(|active| active.execution_nonce == execution_nonce);
|
||||
let exact = if owns_registration {
|
||||
state.active.remove(&execution_id);
|
||||
state.cancellation_requested.remove(&execution_id)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let legacy = state.legacy_cancellation_requested.remove(&call_id);
|
||||
exact || legacy
|
||||
};
|
||||
guard.handle = None;
|
||||
|
||||
let summary = if output.timed_out {
|
||||
let timed_out = output.timed_out;
|
||||
let summary = if cancellation_requested {
|
||||
format!("$ {cmd_summary} (cancelled)")
|
||||
} else if output.timed_out {
|
||||
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
|
||||
} else {
|
||||
match output.exit_code {
|
||||
@@ -97,11 +190,62 @@ impl Tool for BashTool {
|
||||
} else {
|
||||
Some(output.content)
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
let output = ToolOutput {
|
||||
summary,
|
||||
content,
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
};
|
||||
if cancellation_requested {
|
||||
Err(ToolError::Cancelled(output))
|
||||
} else if timed_out {
|
||||
Err(ToolError::Interrupted(output))
|
||||
} else {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel(&self, call_id: &str) -> Result<(), ToolError> {
|
||||
let handles = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state
|
||||
.legacy_cancellation_requested
|
||||
.insert(call_id.to_string());
|
||||
state
|
||||
.active
|
||||
.values()
|
||||
.filter(|active| active.call_id == call_id)
|
||||
.map(|active| active.handle.clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for handle in handles {
|
||||
self.session
|
||||
.cancel_command(handle)
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel_execution(
|
||||
&self,
|
||||
ctx: &agen::tool::ToolExecutionContext,
|
||||
) -> Result<(), ToolError> {
|
||||
let execution_id = ctx.execution_id();
|
||||
let handle = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.cancellation_requested.insert(execution_id.clone());
|
||||
state
|
||||
.active
|
||||
.get(&execution_id)
|
||||
.map(|active| active.handle.clone())
|
||||
};
|
||||
if let Some(handle) = handle {
|
||||
self.session
|
||||
.cancel_command(handle)
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +267,7 @@ pub fn bash_tool(session: WorkdirSessionHandle, _output_dir: PathBuf) -> ToolDef
|
||||
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
||||
session: session.clone(),
|
||||
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolMeta};
|
||||
use agen::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle,
|
||||
ToolExecutionTerminal, ToolMeta,
|
||||
};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
@@ -401,5 +404,84 @@ async fn bash_provider_output_does_not_expose_internal_paths() {
|
||||
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let marker = dir.path().join("must-not-run-after-cancel");
|
||||
let command = format!(
|
||||
"printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'",
|
||||
marker.display()
|
||||
);
|
||||
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
|
||||
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
|
||||
let bash = reg.get("Bash");
|
||||
let executing = bash.clone();
|
||||
let execution_context = context.clone();
|
||||
let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await });
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
bash.cancel_execution(&context)
|
||||
.await
|
||||
.expect("signal exact execution cancellation");
|
||||
let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution)
|
||||
.await
|
||||
.expect("cancelled Bash should terminate inside the Engine grace budget")
|
||||
.expect("Bash task join");
|
||||
|
||||
let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else {
|
||||
panic!("expected typed cancellation result");
|
||||
};
|
||||
let content = output.content.expect("bounded progress output");
|
||||
assert!(
|
||||
content.contains("before"),
|
||||
"missing pre-cancel stdout: {content}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("err-before"),
|
||||
"missing pre-cancel stderr: {content}"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("after"),
|
||||
"post-cancel output leaked: {content}"
|
||||
);
|
||||
assert!(content.len() <= 16 * 1024, "output must remain bounded");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"the cancelled command continued executing after terminal confirmation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let marker = dir.path().join("must-not-survive-force-close");
|
||||
let command = format!("sleep 1; touch {}", marker.display());
|
||||
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
|
||||
let bash = reg.get("Bash");
|
||||
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
|
||||
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
handle.force_close();
|
||||
assert!(matches!(
|
||||
terminal.await,
|
||||
ToolExecutionTerminal::OutcomeUnknown
|
||||
));
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"CommandGuard cleanup allowed a force-closed command to continue"
|
||||
);
|
||||
|
||||
let output = bash
|
||||
.execute(r#"{"command":"printf 'reused'"}"#, Default::default())
|
||||
.await
|
||||
.expect("workdir session remains reusable after cleanup");
|
||||
assert_eq!(output.content.as_deref(), Some("reused"));
|
||||
}
|
||||
|
||||
// Sanity: unused Path import guard
|
||||
const _: fn() -> &'static Path = || Path::new("/");
|
||||
|
||||
+49
-13
@@ -1244,6 +1244,7 @@ impl App {
|
||||
id,
|
||||
summary,
|
||||
output,
|
||||
disposition: _,
|
||||
is_error,
|
||||
} => {
|
||||
self.latest_llm_wait_event = None;
|
||||
@@ -1342,13 +1343,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::CompactStart => {
|
||||
Event::CompactStart { .. } => {
|
||||
if self.last_streaming_compact_mut().is_none() {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
}));
|
||||
}
|
||||
Event::CompactDone { new_segment_id } => {
|
||||
}
|
||||
Event::CompactDone { lifecycle } => {
|
||||
self.session_context_tokens = 0;
|
||||
let new_segment_id = lifecycle
|
||||
.new_segment_id
|
||||
.as_deref()
|
||||
.and_then(|value| uuid::Uuid::parse_str(value).ok())
|
||||
.unwrap_or_default();
|
||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
||||
let elapsed_secs = match evt {
|
||||
CompactEvent::Streaming { started_at } => {
|
||||
@@ -1367,7 +1375,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 +2497,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 +4087,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 +4130,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 +4151,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 +4244,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);
|
||||
}
|
||||
|
||||
@@ -1016,7 +1016,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||
app.clear_queued_inputs();
|
||||
Some(Method::Cancel)
|
||||
}
|
||||
WorkerStatus::Idle => Some(Method::Shutdown),
|
||||
WorkerStatus::Idle | WorkerStatus::Stopped => Some(Method::Shutdown),
|
||||
}),
|
||||
KeyCode::Char('d') if ctrl => {
|
||||
app.quit = true;
|
||||
|
||||
@@ -29,7 +29,6 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
|
||||
use serde::Serialize;
|
||||
use session_store::FsStore;
|
||||
use session_store::FsWorkerStore;
|
||||
use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig};
|
||||
@@ -70,10 +69,6 @@ use render::{PanelListRow, row_hit_boxes};
|
||||
|
||||
const MAX_ENTRIES: usize = 50;
|
||||
const CLOSED_VISIBLE_ROWS: usize = 3;
|
||||
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT: &str = "panel.orchestrator_idle_queue_notice";
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120;
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400;
|
||||
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
|
||||
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
@@ -581,6 +576,7 @@ pub(crate) enum IntakeRegistryUpdate {
|
||||
pub(crate) struct ReadyTicketPlanningReturnRequest {
|
||||
workspace_root: PathBuf,
|
||||
ticket_id: String,
|
||||
ticket_key: String,
|
||||
user_instruction: String,
|
||||
followup: ReadyTicketPlanningReturnFollowup,
|
||||
}
|
||||
@@ -910,6 +906,7 @@ struct OrchestratorActiveWorkItem {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct OrchestratorQueuedWorkItem {
|
||||
id: String,
|
||||
resource_key: Option<String>,
|
||||
title: String,
|
||||
classification: OrchestratorQueuedClassification,
|
||||
waiting_reason: Option<String>,
|
||||
@@ -974,22 +971,6 @@ impl OrchestratorQueueAttentionNoticeResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OrchestratorQueueTemplateContext {
|
||||
workspace: String,
|
||||
actionable_tickets: Vec<OrchestratorQueueTemplateTicket>,
|
||||
waiting_tickets: Vec<OrchestratorQueueTemplateTicket>,
|
||||
omitted_ticket_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OrchestratorQueueTemplateTicket {
|
||||
id: String,
|
||||
title: String,
|
||||
classification: &'static str,
|
||||
waiting_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PanelRowHitBox {
|
||||
rect: Rect,
|
||||
@@ -1325,7 +1306,16 @@ impl DashboardApp {
|
||||
if self.orchestrator_work_set.is_empty() {
|
||||
self.refresh_orchestrator_work_set();
|
||||
}
|
||||
let notice = orchestrator_queue_attention_notice(&self.panel, &self.orchestrator_work_set)?;
|
||||
let notice = match orchestrator_queue_attention_notice(&self.orchestrator_work_set) {
|
||||
Ok(Some(notice)) => notice,
|
||||
Ok(None) => return None,
|
||||
Err(error) => {
|
||||
self.notice = Some(format!(
|
||||
"Orchestrator queued-work attention not delivered: {error}"
|
||||
));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if self
|
||||
.orchestrator_queue_attention
|
||||
.as_ref()
|
||||
@@ -2042,11 +2032,18 @@ impl DashboardApp {
|
||||
return None;
|
||||
};
|
||||
let ticket_id = ticket.id.clone();
|
||||
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
|
||||
Ok(ticket_key) => ticket_key.to_string(),
|
||||
Err(error) => {
|
||||
self.notice = Some(error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut context =
|
||||
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
|
||||
context.ticket = Some(TicketRef::id(ticket_id.clone()));
|
||||
context.user_instruction = Some(format!(
|
||||
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
|
||||
"Continue Intake for existing Ticket {ticket_key}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
|
||||
));
|
||||
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
|
||||
Ok(store) => store,
|
||||
@@ -2059,7 +2056,7 @@ impl DashboardApp {
|
||||
Ok(Some(claim)) => {
|
||||
let status = local_claim_status_for_pod(&claim.worker_name, &self.list);
|
||||
self.notice = Some(existing_ticket_claim_notice(
|
||||
&ticket_id,
|
||||
&ticket_key,
|
||||
&claim.worker_name,
|
||||
status,
|
||||
));
|
||||
@@ -2087,7 +2084,7 @@ impl DashboardApp {
|
||||
self.sending = true;
|
||||
self.notice = Some(format!(
|
||||
"Launching Ticket Intake for {} as {}…",
|
||||
ticket_id, planned.worker_name
|
||||
ticket_key, planned.worker_name
|
||||
));
|
||||
Some(IntakeLaunchRequest {
|
||||
context,
|
||||
@@ -2158,10 +2155,17 @@ impl DashboardApp {
|
||||
return None;
|
||||
};
|
||||
let ticket_id = ticket.id.clone();
|
||||
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
|
||||
Ok(ticket_key) => ticket_key.to_string(),
|
||||
Err(error) => {
|
||||
self.notice = Some(error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if ticket.workflow_state != TicketWorkflowState::Ready {
|
||||
self.notice = Some(format!(
|
||||
"Ticket {} is {}; expected ready before returning to planning.",
|
||||
ticket_id,
|
||||
ticket_key,
|
||||
ticket.workflow_state.as_str()
|
||||
));
|
||||
return None;
|
||||
@@ -2213,7 +2217,7 @@ impl DashboardApp {
|
||||
TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake);
|
||||
context.ticket = Some(TicketRef::id(ticket_id.clone()));
|
||||
context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction(
|
||||
&ticket_id,
|
||||
&ticket_key,
|
||||
&user_instruction,
|
||||
));
|
||||
let peer_registration = self.prepare_intake_peer_registration(&mut context);
|
||||
@@ -2237,11 +2241,12 @@ impl DashboardApp {
|
||||
self.sending = true;
|
||||
self.notice = Some(format!(
|
||||
"Returning ready Ticket {} to planning for refinement…",
|
||||
ticket_id
|
||||
ticket_key
|
||||
));
|
||||
Some(ReadyTicketPlanningReturnRequest {
|
||||
workspace_root,
|
||||
ticket_id,
|
||||
ticket_key,
|
||||
user_instruction,
|
||||
followup,
|
||||
})
|
||||
@@ -3645,6 +3650,7 @@ fn derive_orchestrator_work_set(
|
||||
};
|
||||
Some(OrchestratorQueuedWorkItem {
|
||||
id: ticket.id.clone(),
|
||||
resource_key: ticket.resource_key.clone(),
|
||||
title: ticket.title.clone(),
|
||||
classification,
|
||||
waiting_reason,
|
||||
@@ -3728,72 +3734,46 @@ fn orchestrator_work_set_fingerprint(
|
||||
}
|
||||
|
||||
fn orchestrator_queue_attention_notice(
|
||||
panel: &WorkspacePanelViewModel,
|
||||
work_set: &OrchestratorWorkSet,
|
||||
) -> Option<OrchestratorQueueAttentionNotice> {
|
||||
) -> Result<Option<OrchestratorQueueAttentionNotice>, &'static str> {
|
||||
if work_set.has_active_inprogress() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let actionable = work_set.actionable_queued();
|
||||
if actionable.is_empty() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let waiting = work_set
|
||||
.queued
|
||||
.iter()
|
||||
.filter(|item| item.waiting_reason.is_some())
|
||||
.collect::<Vec<_>>();
|
||||
let ticket_count = actionable.len() + waiting.len();
|
||||
let actionable_tickets = actionable
|
||||
.iter()
|
||||
.take(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS)
|
||||
.map(|item| orchestrator_queue_template_ticket(item))
|
||||
.collect::<Vec<_>>();
|
||||
let remaining_capacity =
|
||||
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS.saturating_sub(actionable_tickets.len());
|
||||
let waiting_tickets = waiting
|
||||
.iter()
|
||||
.take(remaining_capacity)
|
||||
.map(|item| orchestrator_queue_template_ticket(item))
|
||||
.collect::<Vec<_>>();
|
||||
let rendered =
|
||||
render_orchestrator_queue_attention_template(&OrchestratorQueueTemplateContext {
|
||||
workspace: bounded_progress_text(
|
||||
&panel.header.workspace_label,
|
||||
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS,
|
||||
),
|
||||
actionable_tickets,
|
||||
waiting_tickets,
|
||||
omitted_ticket_count: ticket_count
|
||||
.saturating_sub(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS),
|
||||
.filter(|item| item.waiting_reason.is_some());
|
||||
let tickets = actionable
|
||||
.into_iter()
|
||||
.chain(waiting)
|
||||
.map(|item| {
|
||||
let resource_key = item
|
||||
.resource_key
|
||||
.clone()
|
||||
.ok_or("queued Ticket is missing its required resource key")?;
|
||||
worker::OrchestratorQueueAttentionTicket::new(resource_key, item.title.clone())
|
||||
.map_err(|_| "queued Ticket has an invalid resource key")
|
||||
})
|
||||
.ok()?;
|
||||
let message = bounded_progress_text(&rendered, ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS);
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let context = worker::OrchestratorQueueAttentionContext::new(tickets);
|
||||
let message = render_orchestrator_queue_attention_template(&context)
|
||||
.map_err(|_| "queued-work attention prompt rendering failed")?;
|
||||
let fingerprint = format!("idle-queue:{}", work_set.fingerprint);
|
||||
Some(OrchestratorQueueAttentionNotice {
|
||||
Ok(Some(OrchestratorQueueAttentionNotice {
|
||||
message,
|
||||
fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
fn orchestrator_queue_template_ticket(
|
||||
item: &&OrchestratorQueuedWorkItem,
|
||||
) -> OrchestratorQueueTemplateTicket {
|
||||
OrchestratorQueueTemplateTicket {
|
||||
id: bounded_progress_text(&item.id, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
|
||||
title: bounded_progress_text(&item.title, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
|
||||
classification: item.classification.as_str(),
|
||||
waiting_reason: item.waiting_reason.as_ref().map(|reason| {
|
||||
bounded_progress_text(reason, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS)
|
||||
}),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_orchestrator_queue_attention_template(
|
||||
context: &OrchestratorQueueTemplateContext,
|
||||
context: &worker::OrchestratorQueueAttentionContext,
|
||||
) -> Result<String, worker::CatalogError> {
|
||||
worker::PromptCatalog::builtins_only()?
|
||||
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context)
|
||||
.orchestrator_queue_attention(worker::OrchestratorQueueAttentionPrompt::Tui, context)
|
||||
}
|
||||
|
||||
fn orchestrator_work_set_detail(
|
||||
@@ -3918,21 +3898,35 @@ fn bounded_refinement_instruction(input: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str) -> String {
|
||||
fn required_ticket_handoff_key(resource_key: Option<&str>) -> Result<&str, String> {
|
||||
let resource_key = resource_key.ok_or_else(|| {
|
||||
"Ticket handoff is unavailable because the canonical T-* resource key is missing. Refresh the panel and retry."
|
||||
.to_string()
|
||||
})?;
|
||||
let sequence = resource_key.strip_prefix("T-").filter(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
});
|
||||
sequence.map(|_| resource_key).ok_or_else(|| {
|
||||
"Ticket handoff is unavailable because the canonical T-* resource key is invalid. Refresh the panel and retry."
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_ready_ticket_refinement_thread_body(ticket_key: &str, instruction: &str) -> String {
|
||||
format!(
|
||||
"Panel returned ready Ticket {ticket_id} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
|
||||
"Panel returned ready Ticket {ticket_key} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String {
|
||||
fn build_ready_ticket_refinement_launch_instruction(ticket_key: &str, instruction: &str) -> String {
|
||||
format!(
|
||||
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_key}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ready_ticket_refinement_notify(ticket_id: &str, instruction: &str) -> String {
|
||||
fn build_ready_ticket_refinement_notify(ticket_key: &str, instruction: &str) -> String {
|
||||
format!(
|
||||
"Ticket {ticket_id} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
"Ticket {ticket_key} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3961,10 +3955,12 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
let ticket = backend
|
||||
.show(id.clone())
|
||||
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
|
||||
let ticket_key =
|
||||
required_ticket_handoff_key(Some(&request.ticket_key)).map_err(TicketActionError::Stale)?;
|
||||
if ticket.meta.workflow_state != TicketWorkflowState::Ready {
|
||||
return Err(TicketActionError::Stale(format!(
|
||||
"Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.",
|
||||
ticket.meta.id,
|
||||
ticket_key,
|
||||
ticket.meta.workflow_state.as_str()
|
||||
)));
|
||||
}
|
||||
@@ -3973,7 +3969,7 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
TicketWorkflowState::Planning.as_str(),
|
||||
"panel_return_to_planning",
|
||||
MarkdownText::from(build_ready_ticket_refinement_thread_body(
|
||||
&ticket.meta.id,
|
||||
ticket_key,
|
||||
&request.user_instruction,
|
||||
)),
|
||||
);
|
||||
@@ -3987,7 +3983,7 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
ReadyTicketPlanningReturnOutcome {
|
||||
notice: format!(
|
||||
"Ticket {} returned to planning for refinement; launching Ticket Intake…",
|
||||
ticket.meta.id
|
||||
ticket_key
|
||||
),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request),
|
||||
}
|
||||
@@ -3997,19 +3993,19 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
socket_path,
|
||||
} => {
|
||||
let message =
|
||||
build_ready_ticket_refinement_notify(&ticket.meta.id, &request.user_instruction);
|
||||
build_ready_ticket_refinement_notify(ticket_key, &request.user_instruction);
|
||||
match send_notify_only(&socket_path, message, true).await {
|
||||
Ok(()) => ReadyTicketPlanningReturnOutcome {
|
||||
notice: format!(
|
||||
"Ticket {} returned to planning for refinement; notified live Intake Worker {}.",
|
||||
ticket.meta.id, worker_name
|
||||
ticket_key, worker_name
|
||||
),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
||||
},
|
||||
Err(error) => ReadyTicketPlanningReturnOutcome {
|
||||
notice: bounded_panel_diagnostic(format!(
|
||||
"Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}",
|
||||
ticket.meta.id, worker_name, error
|
||||
ticket_key, worker_name, error
|
||||
)),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
||||
},
|
||||
@@ -4020,7 +4016,7 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
ReadyTicketPlanningReturnOutcome {
|
||||
notice: format!(
|
||||
"Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…",
|
||||
ticket.meta.id, worker_name
|
||||
ticket_key, worker_name
|
||||
),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request),
|
||||
}
|
||||
@@ -4029,7 +4025,7 @@ async fn dispatch_ready_ticket_planning_return(
|
||||
ReadyTicketPlanningReturnOutcome {
|
||||
notice: bounded_panel_diagnostic(format!(
|
||||
"Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.",
|
||||
ticket.meta.id, worker_name
|
||||
ticket_key, worker_name
|
||||
)),
|
||||
followup: ReadyTicketPlanningReturnAfterMutation::None,
|
||||
}
|
||||
@@ -5204,6 +5200,7 @@ fn row_status_label(entry: &WorkerListEntry) -> (&'static str, Style) {
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Some(WorkerStatus::Stopped) => ("live stopped", Style::default().fg(Color::DarkGray)),
|
||||
None => ("live", Style::default().fg(Color::DarkGray)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,6 +390,7 @@ fn planning_return_request(
|
||||
ReadyTicketPlanningReturnRequest {
|
||||
workspace_root: temp.path().to_path_buf(),
|
||||
ticket_id,
|
||||
ticket_key: "T-482".to_string(),
|
||||
user_instruction: instruction.to_string(),
|
||||
followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
|
||||
worker_name: "stale-intake".to_string(),
|
||||
@@ -494,6 +495,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
|
||||
};
|
||||
|
||||
assert_eq!(request.ticket_id, "20260608-000123-ready");
|
||||
assert_eq!(request.ticket_key, "T-1");
|
||||
assert_eq!(request.user_instruction, "clarify expected behavior");
|
||||
assert!(matches!(
|
||||
request.followup,
|
||||
@@ -515,6 +517,7 @@ async fn planning_return_with_launch_followup_changes_state_before_launch_follow
|
||||
let request = ReadyTicketPlanningReturnRequest {
|
||||
workspace_root: temp.path().to_path_buf(),
|
||||
ticket_id: ticket_id.clone(),
|
||||
ticket_key: "T-482".to_string(),
|
||||
user_instruction: "launch intake after state change".to_string(),
|
||||
followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
|
||||
context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake),
|
||||
@@ -2969,7 +2972,7 @@ fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
|
||||
fn idle_orchestrator_gets_sanitized_attention_for_new_queued_work() {
|
||||
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
|
||||
app.panel.rows = vec![panel_test_ticket_row(
|
||||
"00001QUEUE",
|
||||
@@ -2989,11 +2992,87 @@ fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
|
||||
request
|
||||
.notice
|
||||
.message
|
||||
.starts_with("Workspace Dashboard observed")
|
||||
.starts_with("Queued Tickets require attention:")
|
||||
);
|
||||
assert!(request.notice.message.contains("00001QUEUE"));
|
||||
assert!(request.notice.message.contains("new_queued"));
|
||||
assert!(request.notice.message.contains("queued -> inprogress"));
|
||||
assert!(request.notice.message.contains("- T-1 — Queued work"));
|
||||
assert!(
|
||||
request
|
||||
.notice
|
||||
.message
|
||||
.contains("Reread the current Ticket state before acting")
|
||||
);
|
||||
assert!(
|
||||
!request
|
||||
.notice
|
||||
.message
|
||||
.contains(&app.panel.header.workspace_label)
|
||||
);
|
||||
for hidden in [
|
||||
"00001QUEUE",
|
||||
"Workspace:",
|
||||
"workspace_id",
|
||||
"new_queued",
|
||||
"bounded",
|
||||
"queued -> inprogress",
|
||||
] {
|
||||
assert!(!request.notice.message.contains(hidden), "leaked {hidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_attention_missing_resource_key_fails_closed_with_panel_notice() {
|
||||
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
|
||||
let mut row = panel_test_ticket_row(
|
||||
"00001QUEUE",
|
||||
"Queued work",
|
||||
ActionPriority::Background,
|
||||
NextUserAction::Wait,
|
||||
"queued",
|
||||
);
|
||||
row.ticket.as_mut().unwrap().resource_key = None;
|
||||
app.panel.rows = vec![row];
|
||||
app.refresh_orchestrator_work_set();
|
||||
|
||||
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
|
||||
assert_eq!(
|
||||
app.notice.as_deref(),
|
||||
Some(
|
||||
"Orchestrator queued-work attention not delivered: queued Ticket is missing its required resource key"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_attention_truncates_only_when_tickets_are_omitted() {
|
||||
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
|
||||
app.panel.rows = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
|
||||
.map(|index| {
|
||||
let mut row = panel_test_ticket_row(
|
||||
&format!("opaque-{index}"),
|
||||
&format!("Queued work {index}"),
|
||||
ActionPriority::Background,
|
||||
NextUserAction::Wait,
|
||||
"queued",
|
||||
);
|
||||
row.ticket.as_mut().unwrap().resource_key = Some(format!("T-{index}"));
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
app.refresh_orchestrator_work_set();
|
||||
|
||||
let request = app
|
||||
.prepare_orchestrator_queue_attention_notice()
|
||||
.expect("bounded queued-work attention");
|
||||
|
||||
assert!(request.notice.message.contains("- T-20 — Queued work 20"));
|
||||
assert!(!request.notice.message.contains("T-21"));
|
||||
assert!(
|
||||
request
|
||||
.notice
|
||||
.message
|
||||
.contains("were omitted from this notice: 1")
|
||||
);
|
||||
assert!(!request.notice.message.contains("opaque-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3083,7 +3162,9 @@ fn planned_queued_prompts_when_active_work_clears() {
|
||||
.prepare_orchestrator_queue_attention_notice()
|
||||
.expect("planned queued work should prompt after active work clears");
|
||||
|
||||
assert!(request.notice.message.contains("planned_queued"));
|
||||
assert!(request.notice.message.contains("- T-1 — Queued work"));
|
||||
assert!(!request.notice.message.contains("planned_queued"));
|
||||
assert!(!request.notice.message.contains("00001QUEUE"));
|
||||
assert!(
|
||||
!request
|
||||
.notice
|
||||
@@ -3138,8 +3219,9 @@ fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() {
|
||||
.prepare_orchestrator_queue_attention_notice()
|
||||
.expect("queued ticket state should be rediscovered safely");
|
||||
|
||||
assert!(request.notice.message.contains("new_queued"));
|
||||
assert!(request.notice.message.contains("00001QUEUE"));
|
||||
assert!(request.notice.message.contains("- T-1 — Queued work"));
|
||||
assert!(!request.notice.message.contains("new_queued"));
|
||||
assert!(!request.notice.message.contains("00001QUEUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3425,6 +3507,27 @@ fn ticket_action_error_records_f2_diagnostic_details() {
|
||||
assert!(!app.panel_diagnostic_open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_ticket_refinement_projection_uses_only_canonical_resource_key() {
|
||||
const INTERNAL_ID: &str = "00001KZVNXFNK";
|
||||
let thread = build_ready_ticket_refinement_thread_body("T-482", "Clarify rollback.");
|
||||
let launch = build_ready_ticket_refinement_launch_instruction("T-482", "Clarify rollback.");
|
||||
let notify = build_ready_ticket_refinement_notify("T-482", "Clarify rollback.");
|
||||
|
||||
for projection in [&thread, &launch, ¬ify] {
|
||||
assert!(projection.contains("T-482"));
|
||||
assert!(!projection.contains(INTERNAL_ID));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_handoff_fails_closed_without_canonical_resource_key() {
|
||||
assert_eq!(required_ticket_handoff_key(Some("T-482")), Ok("T-482"));
|
||||
for invalid in [None, Some(""), Some("00001KZVNXFNK"), Some("T-key")] {
|
||||
assert!(required_ticket_handoff_key(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn plain_line(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
|
||||
@@ -1530,6 +1530,7 @@ fn worker_status_label(entry: &WorkerListEntry) -> &'static str {
|
||||
Some(WorkerStatus::Idle) => "live idle",
|
||||
Some(WorkerStatus::Running) => "live running",
|
||||
Some(WorkerStatus::Paused) => "live paused",
|
||||
Some(WorkerStatus::Stopped) => "live stopped",
|
||||
None => "live",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,14 +251,8 @@ impl DelegatingWorkdirSession {
|
||||
self.ensure_path(path, WorkdirDelegationPermission::Write)
|
||||
}
|
||||
|
||||
fn ensure_command(&self, starting: bool) -> Result<(), WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")?;
|
||||
if starting && self.has_active_write_lease() {
|
||||
return Err(WorkdirError::Denied(
|
||||
"command execution is denied while a child holds a write delegation".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
fn ensure_command(&self) -> Result<(), WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")
|
||||
}
|
||||
|
||||
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
||||
@@ -281,20 +275,6 @@ impl DelegatingWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_active_write_lease(&self) -> bool {
|
||||
let mut leases = self
|
||||
.child_write_leases
|
||||
.lock()
|
||||
.expect("workdir delegation lease mutex poisoned");
|
||||
leases.retain(|_, lease| lease.validity.upgrade().is_some_and(|v| v.is_active()));
|
||||
leases.values().any(|lease| {
|
||||
lease
|
||||
.rules
|
||||
.iter()
|
||||
.any(|rule| rule.permission == WorkdirDelegationPermission::Write)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_delegation_rules(
|
||||
&self,
|
||||
rules: &[WorkdirDelegationRule],
|
||||
@@ -503,12 +483,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
||||
}
|
||||
|
||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||
self.ensure_command(true)?;
|
||||
self.ensure_command()?;
|
||||
self.source.start_command(request).await
|
||||
}
|
||||
|
||||
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||
self.ensure_command(false)?;
|
||||
self.ensure_command()?;
|
||||
self.source.command_status(handle).await
|
||||
}
|
||||
|
||||
@@ -516,12 +496,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
||||
&self,
|
||||
request: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
self.ensure_command(false)?;
|
||||
self.ensure_command()?;
|
||||
self.source.command_output(request).await
|
||||
}
|
||||
|
||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||
self.ensure_command(false)?;
|
||||
self.ensure_command()?;
|
||||
self.source.cancel_command(handle).await
|
||||
}
|
||||
|
||||
@@ -753,6 +733,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
session: &WorkdirSessionHandle,
|
||||
command: impl Into<String>,
|
||||
tool_call_id: impl Into<String>,
|
||||
) -> CommandOutput {
|
||||
let handle = session
|
||||
.start_command(CommandRequest {
|
||||
command: command.into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
tool_call_id: Some(tool_call_id.into()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegation_capable_session_forwards_command_telemetry() {
|
||||
let root = TempDir::new().unwrap();
|
||||
@@ -846,6 +851,18 @@ mod tests {
|
||||
);
|
||||
assert!(child.scoped_session.subscribe_command_events().is_none());
|
||||
assert!(child.scoped_session.command_snapshot().is_empty());
|
||||
assert!(matches!(
|
||||
child
|
||||
.scoped_session
|
||||
.start_command(CommandRequest {
|
||||
command: "printf denied".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
tool_call_id: Some("read-only-command".into()),
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -924,7 +941,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_lease_blocks_parent_region_until_release() {
|
||||
async fn write_lease_keeps_typed_parent_writes_exclusive_without_blocking_commands() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir_all(root.path().join("leased")).unwrap();
|
||||
fs::create_dir_all(root.path().join("other")).unwrap();
|
||||
@@ -938,37 +955,24 @@ mod tests {
|
||||
.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()
|
||||
let child_output = run_command(
|
||||
&child.scoped_session,
|
||||
"printf child-command",
|
||||
"delegated-child-command",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(child_output.content, "child-command");
|
||||
let parent_output = run_command(
|
||||
&parent,
|
||||
"printf parent-write > leased/from-command; printf parent-command",
|
||||
"parent-command-during-child-write",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(parent_output.status, CommandStatus::Completed);
|
||||
assert_eq!(parent_output.content, "parent-command");
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.path().join("leased/from-command")).unwrap(),
|
||||
"parent-write"
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
@@ -982,6 +986,18 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
child.release();
|
||||
assert!(matches!(
|
||||
child
|
||||
.scoped_session
|
||||
.start_command(CommandRequest {
|
||||
command: "printf revoked".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
tool_call_id: Some("revoked-child-command".into()),
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::SessionClosed)
|
||||
));
|
||||
parent
|
||||
.write(write("leased/parent", "parent"))
|
||||
.await
|
||||
@@ -1033,6 +1049,78 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nested_write_leases_do_not_block_command_capable_ancestors() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir_all(root.path().join("docs/sub")).unwrap();
|
||||
let root_session = session(root.path());
|
||||
let child = root_session
|
||||
.delegate(request("docs", WorkdirDelegationPermission::Write))
|
||||
.await
|
||||
.unwrap();
|
||||
let nested = child
|
||||
.scoped_session
|
||||
.delegate(request("docs/sub", WorkdirDelegationPermission::Write))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (session, label) in [
|
||||
(&root_session, "root"),
|
||||
(&child.scoped_session, "child"),
|
||||
(&nested.scoped_session, "nested"),
|
||||
] {
|
||||
let output = run_command(
|
||||
session,
|
||||
format!("printf {label}"),
|
||||
format!("{label}-command-during-nested-write"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(output.status, CommandStatus::Completed);
|
||||
assert_eq!(output.content, label);
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
root_session.write(write("docs/root", "blocked")).await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
child
|
||||
.scoped_session
|
||||
.write(write("sub/child", "blocked"))
|
||||
.await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
));
|
||||
nested
|
||||
.scoped_session
|
||||
.write(write("nested", "allowed"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
nested.release();
|
||||
child.release();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reapplied_write_delegation_chain_forwards_command_lifecycle() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir_all(root.path().join("delegated")).unwrap();
|
||||
let applied = apply_delegation_chain(
|
||||
session(root.path()),
|
||||
[request("delegated", WorkdirDelegationPermission::Write)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let output = run_command(
|
||||
&applied.scoped_session,
|
||||
"printf reapplied",
|
||||
"reapplied-command",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(output.status, CommandStatus::Completed);
|
||||
assert_eq!(output.content, "reapplied");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applied_chain_cannot_replace_outer_provider_attenuation() {
|
||||
let root = TempDir::new().unwrap();
|
||||
@@ -1077,6 +1165,17 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
parent.close().await.unwrap();
|
||||
assert!(matches!(
|
||||
parent
|
||||
.start_command(CommandRequest {
|
||||
command: "printf closed".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
tool_call_id: Some("closed-parent-command".into()),
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::SessionClosed)
|
||||
));
|
||||
assert!(matches!(
|
||||
child.scoped_session.read(read("a")).await,
|
||||
Err(WorkdirError::SessionClosed)
|
||||
|
||||
@@ -28,7 +28,9 @@ pub use fs_operation::{
|
||||
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
pub use local::{LocalWorkdirSession, SymlinkInfo, direct_symlink, first_symlink};
|
||||
pub use local::{
|
||||
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
|
||||
};
|
||||
pub use operation::*;
|
||||
|
||||
/// Persistent, opaque identity of one materialized Workdir.
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! `LocalWorkdirSession` is cheap to clone (`Arc` inside). Tool-specific session
|
||||
//! state, such as read-before-edit tracking, remains owned by the tool layer.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt::Debug;
|
||||
#[cfg(test)]
|
||||
use std::io::Write as _;
|
||||
use std::io::{Read as _, Seek as _, SeekFrom};
|
||||
@@ -228,6 +229,8 @@ struct LocalWorkdirSessionInner {
|
||||
next_command_id: AtomicU64,
|
||||
commands: Mutex<HashMap<String, LocalCommand>>,
|
||||
command_telemetry: CommandTelemetry,
|
||||
command_environment: BTreeMap<String, String>,
|
||||
resources: StdMutex<Vec<Arc<dyn WorkdirSessionResource>>>,
|
||||
}
|
||||
|
||||
impl Drop for LocalWorkdirSessionInner {
|
||||
@@ -242,6 +245,9 @@ impl Drop for LocalWorkdirSessionInner {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait WorkdirSessionResource: Debug + Send + Sync {}
|
||||
impl<T> WorkdirSessionResource for T where T: Debug + Send + Sync {}
|
||||
|
||||
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
||||
///
|
||||
/// The wrapped [`SharedScope`] is shared with every clone of this
|
||||
@@ -318,6 +324,26 @@ impl LocalWorkdirSession {
|
||||
cwd: PathBuf,
|
||||
scope: SharedScope,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
) -> Self {
|
||||
Self::materialized_bound_with_environment(
|
||||
workdir,
|
||||
root,
|
||||
cwd,
|
||||
scope,
|
||||
capabilities,
|
||||
BTreeMap::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn materialized_bound_with_environment(
|
||||
workdir: Workdir,
|
||||
root: PathBuf,
|
||||
cwd: PathBuf,
|
||||
scope: SharedScope,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
command_environment: BTreeMap<String, String>,
|
||||
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(LocalWorkdirSessionInner {
|
||||
@@ -331,6 +357,8 @@ impl LocalWorkdirSession {
|
||||
next_command_id: AtomicU64::new(1),
|
||||
commands: Mutex::new(HashMap::new()),
|
||||
command_telemetry: CommandTelemetry::new(),
|
||||
command_environment,
|
||||
resources: StdMutex::new(resources),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -669,9 +697,18 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
let (completion_tx, completion) = watch::channel(false);
|
||||
let command_id = handle.0.clone();
|
||||
let telemetry = self.inner.command_telemetry.clone();
|
||||
let command_environment = self.inner.command_environment.clone();
|
||||
let (cancel, cancel_rx) = watch::channel(false);
|
||||
let task = tokio::spawn(async move {
|
||||
let output = run_command(cwd, request, command_id, telemetry, cancel_rx).await;
|
||||
let output = run_command(
|
||||
cwd,
|
||||
request,
|
||||
command_id,
|
||||
telemetry,
|
||||
command_environment,
|
||||
cancel_rx,
|
||||
)
|
||||
.await;
|
||||
let _ = completion_tx.send(true);
|
||||
output
|
||||
});
|
||||
@@ -840,6 +877,9 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
LocalCommand::Completed(_) => {}
|
||||
}
|
||||
}
|
||||
if let Ok(mut resources) = self.inner.resources.lock() {
|
||||
resources.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -909,6 +949,7 @@ async fn run_command(
|
||||
request: CommandRequest,
|
||||
command_id: String,
|
||||
telemetry: CommandTelemetry,
|
||||
command_environment: BTreeMap<String, String>,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
|
||||
@@ -925,6 +966,7 @@ async fn run_command(
|
||||
.arg("-c")
|
||||
.arg(&request.command)
|
||||
.current_dir(&cwd)
|
||||
.envs(command_environment)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout_file))
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
@@ -2319,6 +2361,31 @@ mod tests {
|
||||
assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_session_releases_runtime_resources() {
|
||||
#[derive(Debug)]
|
||||
struct Resource(Arc<AtomicBool>);
|
||||
impl Drop for Resource {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let released = Arc::new(AtomicBool::new(false));
|
||||
let session = LocalWorkdirSession::materialized_bound_with_environment(
|
||||
Workdir::new("resource-session"),
|
||||
dir.path().to_path_buf(),
|
||||
dir.path().to_path_buf(),
|
||||
SharedScope::new(Scope::writable(dir.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
BTreeMap::from([("SSH_AUTH_SOCK".to_string(), "test-socket".to_string())]),
|
||||
vec![Arc::new(Resource(released.clone()))],
|
||||
);
|
||||
WorkdirSession::close(&session).await.unwrap();
|
||||
assert!(released.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_cancels_active_command() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -30,6 +30,8 @@ impl RuntimeWorkerRef {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaterializerKind {
|
||||
#[default]
|
||||
RuntimeGitCache,
|
||||
/// Legacy persisted value from the pre-cache local `git worktree` materializer.
|
||||
LocalGitWorktree,
|
||||
}
|
||||
|
||||
@@ -109,6 +111,8 @@ pub struct WorkingDirectoryProvenance {
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_tree: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
@@ -122,6 +126,10 @@ pub struct WorkingDirectoryCurrentObservation {
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub observed_at_epoch_seconds: Option<u64>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
@@ -141,9 +149,15 @@ pub struct WorkingDirectorySummary {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub observed_at_epoch_seconds: Option<u64>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
@@ -166,6 +180,7 @@ impl WorkingDirectorySummary {
|
||||
WorkingDirectoryProvenance {
|
||||
creation_selector: self.creation_selector.clone(),
|
||||
creation_ref: self.creation_ref.clone(),
|
||||
creation_tree: self.creation_tree.clone(),
|
||||
materializer_kind: self.materializer_kind.clone(),
|
||||
cleanup_target: self.cleanup_target.clone(),
|
||||
}
|
||||
@@ -175,6 +190,8 @@ impl WorkingDirectorySummary {
|
||||
WorkingDirectoryCurrentObservation {
|
||||
current_selector: self.current_selector.clone(),
|
||||
current_ref: self.current_ref.clone(),
|
||||
current_tree: self.current_tree.clone(),
|
||||
observed_at_epoch_seconds: self.observed_at_epoch_seconds,
|
||||
status: self.status.clone(),
|
||||
cleanliness: self.cleanliness.clone(),
|
||||
primary_worker_id: self.primary_worker_id.clone(),
|
||||
@@ -247,8 +264,11 @@ mod tests {
|
||||
repository_id: "repo".to_string(),
|
||||
creation_selector: Some("develop".to_string()),
|
||||
creation_ref: Some("abc123".to_string()),
|
||||
creation_tree: Some("tree123".to_string()),
|
||||
current_selector: Some("work/ticket".to_string()),
|
||||
current_ref: Some("def456".to_string()),
|
||||
current_tree: Some("tree456".to_string()),
|
||||
observed_at_epoch_seconds: Some(1_777_777_777),
|
||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||
cleanup_target: Some(WorkingDirectoryCleanupTarget {
|
||||
kind: "git_worktree".to_string(),
|
||||
@@ -269,8 +289,11 @@ mod tests {
|
||||
repository_id: "repo".to_string(),
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
creation_tree: None,
|
||||
current_selector: None,
|
||||
current_ref: Some("987fed".to_string()),
|
||||
current_tree: None,
|
||||
observed_at_epoch_seconds: None,
|
||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||
cleanup_target: None,
|
||||
status: WorkingDirectoryStatusKind::Active,
|
||||
|
||||
@@ -43,6 +43,7 @@ tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
||||
toml.workspace = true
|
||||
url.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
zeroize.workspace = true
|
||||
tower = { workspace = true, features = ["util"], optional = true }
|
||||
worker.workspace = true
|
||||
workspace-api = { path = "../workspace-api" }
|
||||
|
||||
@@ -97,6 +97,74 @@ pub use workdir::workspace::{
|
||||
WorkingDirectorySummary,
|
||||
};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SensitiveString(String);
|
||||
|
||||
impl SensitiveString {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SensitiveString {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SensitiveString {
|
||||
fn default() -> Self {
|
||||
Self(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SensitiveString {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySshMaterializationAccess {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub host_trust_id: String,
|
||||
pub host_trust_revision: u64,
|
||||
pub access: workspace_api::RepositoryAccessMode,
|
||||
pub expires_at_epoch_seconds: u64,
|
||||
pub repository_id: String,
|
||||
pub repository_source_fingerprint: String,
|
||||
pub repository_uri: String,
|
||||
pub secret_resource: crate::resource::BackendResourceHandle,
|
||||
#[serde(skip, default)]
|
||||
pub private_key: SensitiveString,
|
||||
#[serde(skip, default)]
|
||||
pub known_hosts_entry: SensitiveString,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositoryMaterializationContext {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub operation_id: String,
|
||||
pub config_revision: u64,
|
||||
pub config_projection_digest: String,
|
||||
#[serde(default)]
|
||||
pub cache_generation: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkingDirectoryRepositoryAccessRequest {
|
||||
pub working_directory_id: String,
|
||||
pub materialization: RepositoryMaterializationContext,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkingDirectoryRequest {
|
||||
pub repository: WorkingDirectoryRepository,
|
||||
@@ -106,6 +174,9 @@ pub struct WorkingDirectoryRequest {
|
||||
/// Backend can create canonical registry rows before materialization.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backend_workdir_id: Option<String>,
|
||||
/// Backend-authored, operation-scoped repository access and cache identity.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub materialization: Option<RepositoryMaterializationContext>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus};
|
||||
use crate::catalog::{
|
||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
};
|
||||
use crate::config_bundle::ConfigBundle;
|
||||
use crate::error::RuntimeError;
|
||||
use crate::identity::WorkerRef;
|
||||
@@ -319,6 +321,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
))
|
||||
}
|
||||
|
||||
fn authorize_working_directory_repository_access(
|
||||
&self,
|
||||
_request: &WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
Err(WorkingDirectoryDiagnostic::rejected(
|
||||
"working_directory_repository_access_unsupported",
|
||||
"Worker execution backend does not support Repository access authorization",
|
||||
))
|
||||
}
|
||||
|
||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -454,6 +466,14 @@ impl WorkerExecutionBackendRef {
|
||||
self.backend.create_working_directory(request)
|
||||
}
|
||||
|
||||
pub(crate) fn authorize_working_directory_repository_access(
|
||||
&self,
|
||||
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
self.backend
|
||||
.authorize_working_directory_repository_access(request)
|
||||
}
|
||||
|
||||
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||
self.backend.list_working_directories()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ use crate::auth::{
|
||||
};
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
||||
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef,
|
||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
WorkspaceApiRef,
|
||||
};
|
||||
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||
use crate::error::RuntimeError;
|
||||
@@ -203,6 +204,10 @@ fn runtime_http_router_with_optional_auth(
|
||||
"/v1/working-directories",
|
||||
get(list_working_directories).post(create_working_directory),
|
||||
)
|
||||
.route(
|
||||
"/v1/working-directories/repository-access",
|
||||
post(authorize_working_directory_repository_access),
|
||||
)
|
||||
.route(
|
||||
"/v1/working-directories/{working_directory_id}/sessions",
|
||||
post(open_workdir_session),
|
||||
@@ -335,6 +340,11 @@ pub struct RuntimeHttpWorkingDirectoriesResponse {
|
||||
pub working_directories: Vec<WorkingDirectoryStatus>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpRepositoryAccessResponse {
|
||||
pub authorized: bool,
|
||||
}
|
||||
|
||||
/// Working directory response used by create/detail/delete endpoints.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpWorkingDirectoryResponse {
|
||||
@@ -513,6 +523,29 @@ async fn list_workers(
|
||||
Ok(Json(RuntimeHttpWorkersResponse { workers }))
|
||||
}
|
||||
|
||||
async fn authorize_working_directory_repository_access(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(auth): Extension<RuntimeAuthContext>,
|
||||
body: Result<Json<WorkingDirectoryRepositoryAccessRequest>, JsonRejection>,
|
||||
) -> RestResult<RuntimeHttpRepositoryAccessResponse> {
|
||||
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||
if request.materialization.workspace_id != auth.workspace_id {
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"working_directory_materialization_workspace_mismatch",
|
||||
"Repository access authority does not match the authenticated Workspace",
|
||||
));
|
||||
}
|
||||
state
|
||||
.runtime
|
||||
.authorize_working_directory_repository_access_from_resource(request)
|
||||
.await
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(Json(RuntimeHttpRepositoryAccessResponse {
|
||||
authorized: true,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_working_directories(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
||||
@@ -527,12 +560,23 @@ async fn list_working_directories(
|
||||
|
||||
async fn create_working_directory(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(auth): Extension<RuntimeAuthContext>,
|
||||
body: Result<Json<WorkingDirectoryRequest>, JsonRejection>,
|
||||
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
|
||||
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||
if let Some(materialization) = request.materialization.as_ref()
|
||||
&& materialization.workspace_id != auth.workspace_id
|
||||
{
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"working_directory_materialization_workspace_mismatch",
|
||||
"Repository materialization authority does not match the authenticated Workspace",
|
||||
));
|
||||
}
|
||||
let working_directory = state
|
||||
.runtime
|
||||
.create_working_directory(request)
|
||||
.create_working_directory_from_resource(request)
|
||||
.await
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
||||
working_directory,
|
||||
@@ -1559,6 +1603,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
||||
if path == "/v1/workers" && *method == Method::POST {
|
||||
return Some("workers:create");
|
||||
}
|
||||
if path == "/v1/working-directories/repository-access" && *method == Method::POST {
|
||||
return Some("workdirs:operate");
|
||||
}
|
||||
if path.starts_with("/v1/workdir-sessions")
|
||||
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
|
||||
{
|
||||
@@ -2220,6 +2267,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workdir_routes_require_dedicated_operation_permission() {
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
|
||||
Some("workdirs:operate")
|
||||
);
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
||||
Some("workdirs:operate")
|
||||
|
||||
@@ -23,10 +23,21 @@ use worker_runtime::http_server::{
|
||||
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
||||
};
|
||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
|
||||
use worker_runtime::working_directory::RuntimeGitCacheMaterializer;
|
||||
use worker_runtime::{Runtime, RuntimeOptions};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut arguments = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
if arguments.first().map(String::as_str) == Some("__repository-ssh") {
|
||||
arguments.remove(0);
|
||||
return match worker_runtime::working_directory::run_repository_ssh_client(&arguments) {
|
||||
Ok(status) => ExitCode::from(u8::try_from(status).unwrap_or(1)),
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
};
|
||||
}
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
@@ -169,6 +180,9 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
factory = factory.with_remote_worker_mutation_identity(identity);
|
||||
}
|
||||
}
|
||||
let mut backend_resource_client: Option<
|
||||
Arc<dyn worker_runtime::resource::BackendResourceClient>,
|
||||
> = None;
|
||||
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
|
||||
let identity = runtime_auth.identity.as_ref().ok_or_else(|| {
|
||||
ProcessError::Auth(
|
||||
@@ -181,26 +195,28 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
.to_owned(),
|
||||
));
|
||||
};
|
||||
factory = factory.with_resource_client(Arc::new(
|
||||
let client = Arc::new(
|
||||
worker_runtime::resource::HttpBackendResourceClient::new(
|
||||
endpoint,
|
||||
config.backend_resource_token.clone(),
|
||||
)
|
||||
.with_runtime_request_source(identity, trusted_server.server_id.clone()),
|
||||
));
|
||||
);
|
||||
factory = factory.with_resource_client(client.clone());
|
||||
backend_resource_client = Some(client);
|
||||
}
|
||||
let backend = Arc::new(
|
||||
WorkerRuntimeExecutionBackend::new(factory)
|
||||
.map_err(ProcessError::WorkerAdapter)?
|
||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||
fs_paths.workdir_target.clone(),
|
||||
)),
|
||||
);
|
||||
|
||||
match &config.http.store {
|
||||
let runtime = match &config.http.store {
|
||||
RuntimeHttpStoreSelection::Memory => {
|
||||
Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend)
|
||||
.map_err(ProcessError::Runtime)
|
||||
.map_err(ProcessError::Runtime)?
|
||||
}
|
||||
RuntimeHttpStoreSelection::Fs { root } => {
|
||||
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
|
||||
@@ -213,12 +229,20 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
);
|
||||
options.display_name = config.http.display_name.clone();
|
||||
Runtime::with_fs_store_and_execution_backend(options, backend)
|
||||
.map_err(ProcessError::Runtime)
|
||||
.map_err(ProcessError::Runtime)?
|
||||
}
|
||||
_ => Err(ProcessError::usage(
|
||||
_ => {
|
||||
return Err(ProcessError::usage(
|
||||
"unsupported Runtime catalog store selection".to_string(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Some(client) = backend_resource_client {
|
||||
runtime
|
||||
.install_backend_resource_client(client)
|
||||
.map_err(ProcessError::Runtime)?;
|
||||
}
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
||||
|
||||
@@ -11,18 +11,46 @@ use std::sync::Mutex;
|
||||
|
||||
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
|
||||
"application/vnd.yoi.profile-source-archive+tar";
|
||||
pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
|
||||
"application/vnd.yoi.repository-ssh-access+json";
|
||||
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
|
||||
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct RepositorySshAccessSecret {
|
||||
pub private_key: String,
|
||||
pub known_hosts_entry: String,
|
||||
}
|
||||
|
||||
impl Drop for RepositorySshAccessSecret {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.private_key);
|
||||
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RepositorySshAccessSecret {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RepositorySshAccessSecret")
|
||||
.field("private_key", &"[REDACTED]")
|
||||
.field("known_hosts_entry", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackendResourceKind {
|
||||
ProfileSourceArchive,
|
||||
RepositorySshAccess,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackendResourceOperation {
|
||||
FetchArchive,
|
||||
FetchOnce,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -66,7 +94,7 @@ pub struct BackendResourceFetchRequest {
|
||||
pub audit_correlation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BackendResourceFetchResponse {
|
||||
pub kind: BackendResourceKind,
|
||||
pub resource_id: String,
|
||||
@@ -76,6 +104,29 @@ pub struct BackendResourceFetchResponse {
|
||||
pub audit_correlation_id: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BackendResourceFetchResponse {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BackendResourceFetchResponse")
|
||||
.field("kind", &self.kind)
|
||||
.field("resource_id", &self.resource_id)
|
||||
.field("digest", &self.digest)
|
||||
.field("content_type", &self.content_type)
|
||||
.field(
|
||||
"bytes",
|
||||
&format_args!("[REDACTED; {} bytes]", self.bytes.len()),
|
||||
)
|
||||
.field("audit_correlation_id", &self.audit_correlation_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BackendResourceFetchResponse {
|
||||
fn drop(&mut self) {
|
||||
self.bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||
#[serde(tag = "code", rename_all = "snake_case")]
|
||||
pub enum BackendResourceError {
|
||||
@@ -248,7 +299,7 @@ pub fn build_profile_source_archive_fetch_request(
|
||||
|
||||
pub fn profile_source_archive_from_response(
|
||||
handle: &BackendResourceHandle,
|
||||
response: BackendResourceFetchResponse,
|
||||
mut response: BackendResourceFetchResponse,
|
||||
) -> Result<ProfileSourceArchive, BackendResourceError> {
|
||||
if handle.kind != BackendResourceKind::ProfileSourceArchive
|
||||
|| response.kind != BackendResourceKind::ProfileSourceArchive
|
||||
@@ -263,7 +314,7 @@ pub fn profile_source_archive_from_response(
|
||||
if response.content_type != handle.content_type {
|
||||
return Err(BackendResourceError::ContentTypeMismatch {
|
||||
expected: handle.content_type.clone(),
|
||||
actual: response.content_type,
|
||||
actual: response.content_type.clone(),
|
||||
});
|
||||
}
|
||||
let actual_bytes = response.bytes.len() as u64;
|
||||
@@ -278,7 +329,7 @@ pub fn profile_source_archive_from_response(
|
||||
return Err(BackendResourceError::DigestMismatch {
|
||||
expected: handle.digest.clone(),
|
||||
actual: if response.digest != handle.digest {
|
||||
response.digest
|
||||
response.digest.clone()
|
||||
} else {
|
||||
actual_digest
|
||||
},
|
||||
@@ -296,7 +347,7 @@ pub fn profile_source_archive_from_response(
|
||||
}
|
||||
})?,
|
||||
},
|
||||
content: response.bytes,
|
||||
content: std::mem::take(&mut response.bytes),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
|
||||
WorkerStatus, WorkerSummary, WorkingDirectoryRequest,
|
||||
WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
|
||||
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
|
||||
};
|
||||
use crate::config_bundle::{
|
||||
@@ -26,6 +26,10 @@ use crate::management::{
|
||||
};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
||||
use crate::resource::{
|
||||
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest, BackendResourceKind,
|
||||
REPOSITORY_SSH_ACCESS_CONTENT_TYPE, RepositorySshAccessSecret,
|
||||
};
|
||||
#[cfg(feature = "fs-store")]
|
||||
use crate::retention::{
|
||||
FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult,
|
||||
@@ -172,6 +176,14 @@ impl Runtime {
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
pub fn install_backend_resource_client(
|
||||
&self,
|
||||
client: Arc<dyn BackendResourceClient>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
self.lock()?.backend_resource_client = Some(BackendResourceClientRef(client));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create or restore a filesystem-backed Runtime.
|
||||
///
|
||||
/// The store is scoped by `options.root`; if the directory already exists,
|
||||
@@ -366,6 +378,103 @@ impl Runtime {
|
||||
.map_err(RuntimeError::from)
|
||||
}
|
||||
|
||||
pub async fn create_working_directory_from_resource(
|
||||
&self,
|
||||
mut request: WorkingDirectoryRequest,
|
||||
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
|
||||
if let Some(ssh) = request
|
||||
.materialization
|
||||
.as_mut()
|
||||
.and_then(|materialization| materialization.ssh.as_mut())
|
||||
{
|
||||
self.resolve_repository_access_resource(ssh).await?;
|
||||
}
|
||||
self.create_working_directory(request)
|
||||
}
|
||||
|
||||
pub fn authorize_working_directory_repository_access(
|
||||
&self,
|
||||
request: WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let backend = {
|
||||
let state = self.lock()?;
|
||||
state.ensure_running()?;
|
||||
state.execution_backend.clone().ok_or_else(|| {
|
||||
RuntimeError::ExecutionBackendUnavailable {
|
||||
message: "working directory Repository access requires an execution backend"
|
||||
.to_string(),
|
||||
}
|
||||
})?
|
||||
};
|
||||
backend
|
||||
.authorize_working_directory_repository_access(&request)
|
||||
.map_err(RuntimeError::from)
|
||||
}
|
||||
|
||||
async fn resolve_repository_access_resource(
|
||||
&self,
|
||||
ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
|
||||
) -> Result<(), RuntimeError> {
|
||||
if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (client, runtime_id) = {
|
||||
let state = self.lock()?;
|
||||
let client = state.backend_resource_client.clone().ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest(
|
||||
"Backend Repository access resource client is unavailable".to_string(),
|
||||
)
|
||||
})?;
|
||||
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
|
||||
})?;
|
||||
(client, runtime_id)
|
||||
};
|
||||
let mut response = client
|
||||
.0
|
||||
.fetch_resource(BackendResourceFetchRequest {
|
||||
handle: ssh.secret_resource.clone(),
|
||||
runtime_id,
|
||||
worker_id: None,
|
||||
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(repository_resource_error)?;
|
||||
if response.kind != BackendResourceKind::RepositorySshAccess
|
||||
|| response.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE
|
||||
|| response.resource_id != ssh.secret_resource.resource_id
|
||||
|| response.digest != ssh.secret_resource.digest
|
||||
|| response.bytes.len() as u64 > ssh.secret_resource.max_bytes
|
||||
{
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"Backend Repository SSH access resource response was invalid".to_string(),
|
||||
));
|
||||
}
|
||||
let secret = serde_json::from_slice::<RepositorySshAccessSecret>(&response.bytes);
|
||||
response.bytes.fill(0);
|
||||
let mut secret = secret.map_err(|_| {
|
||||
RuntimeError::InvalidRequest(
|
||||
"Backend Repository SSH access resource payload was invalid".to_string(),
|
||||
)
|
||||
})?;
|
||||
ssh.private_key =
|
||||
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
|
||||
ssh.known_hosts_entry =
|
||||
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn authorize_working_directory_repository_access_from_resource(
|
||||
&self,
|
||||
mut request: WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
|
||||
})?;
|
||||
self.resolve_repository_access_resource(ssh).await?;
|
||||
self.authorize_working_directory_repository_access(request)
|
||||
}
|
||||
|
||||
/// List Runtime-owned working directories through the attached execution backend.
|
||||
pub fn list_working_directories(
|
||||
&self,
|
||||
@@ -566,12 +675,13 @@ impl Runtime {
|
||||
let worker_id = request.worker_id;
|
||||
let worker_ref = WorkerRef::new(worker_id);
|
||||
|
||||
let durable_request = durable_create_worker_request(&request);
|
||||
let record = WorkerRecord {
|
||||
worker_ref: worker_ref.clone(),
|
||||
worker_id: worker_id.clone(),
|
||||
status: WorkerStatus::Stopped,
|
||||
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
|
||||
request: request.clone(),
|
||||
request: durable_request,
|
||||
run_generation: 1,
|
||||
working_directory: None,
|
||||
execution_handle: None,
|
||||
@@ -1842,6 +1952,15 @@ struct SubscriptionSink {
|
||||
lagged: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BackendResourceClientRef(Arc<dyn BackendResourceClient>);
|
||||
|
||||
impl std::fmt::Debug for BackendResourceClientRef {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("BackendResourceClientRef(..)")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RuntimeState {
|
||||
display_name: Option<String>,
|
||||
@@ -1853,6 +1972,7 @@ struct RuntimeState {
|
||||
persistence: RuntimePersistence,
|
||||
status: RuntimeStatus,
|
||||
execution_backend: Option<WorkerExecutionBackendRef>,
|
||||
backend_resource_client: Option<BackendResourceClientRef>,
|
||||
#[cfg(feature = "fs-store")]
|
||||
next_diagnostic_id: u64,
|
||||
workers: BTreeMap<WorkerId, WorkerRecord>,
|
||||
@@ -1880,6 +2000,7 @@ impl RuntimeState {
|
||||
persistence: RuntimePersistence::Memory,
|
||||
status: RuntimeStatus::Running,
|
||||
execution_backend: None,
|
||||
backend_resource_client: None,
|
||||
#[cfg(feature = "fs-store")]
|
||||
next_diagnostic_id: 1,
|
||||
workers: BTreeMap::new(),
|
||||
@@ -1908,6 +2029,7 @@ impl RuntimeState {
|
||||
persistence: RuntimePersistence::Fs(store),
|
||||
status: RuntimeStatus::Running,
|
||||
execution_backend: None,
|
||||
backend_resource_client: None,
|
||||
#[cfg(feature = "fs-store")]
|
||||
next_diagnostic_id: 1,
|
||||
workers: BTreeMap::new(),
|
||||
@@ -1959,6 +2081,7 @@ impl RuntimeState {
|
||||
persistence: RuntimePersistence::Fs(store),
|
||||
status: persisted.status,
|
||||
execution_backend: None,
|
||||
backend_resource_client: None,
|
||||
next_diagnostic_id,
|
||||
workers,
|
||||
config_bundles: BTreeMap::new(),
|
||||
@@ -2619,6 +2742,7 @@ impl RuntimeState {
|
||||
protocol::WorkerStatus::Running => Some(WorkerStatus::Running),
|
||||
protocol::WorkerStatus::Idle => Some(WorkerStatus::Idle),
|
||||
protocol::WorkerStatus::Paused => Some(WorkerStatus::Paused),
|
||||
protocol::WorkerStatus::Stopped => Some(WorkerStatus::Stopped),
|
||||
},
|
||||
protocol::Event::RunEnd { result } => match result {
|
||||
protocol::RunResult::Finished | protocol::RunResult::RolledBack => {
|
||||
@@ -2714,6 +2838,33 @@ fn worker_status_from_run_state(run_state: WorkerExecutionRunState) -> WorkerSta
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
|
||||
let category = match error {
|
||||
BackendResourceError::Expired => "expired",
|
||||
BackendResourceError::Unauthorized { .. } => "unauthorized",
|
||||
BackendResourceError::UnsupportedKind => "unsupported_kind",
|
||||
BackendResourceError::MissingResource => "missing_resource",
|
||||
BackendResourceError::Oversized { .. } => "oversized",
|
||||
BackendResourceError::DigestMismatch { .. } => "digest_mismatch",
|
||||
BackendResourceError::ContentTypeMismatch { .. } => "content_type_mismatch",
|
||||
BackendResourceError::InvalidResponse { .. } => "invalid_response",
|
||||
BackendResourceError::Transport { .. } => "transport",
|
||||
};
|
||||
RuntimeError::InvalidRequest(format!(
|
||||
"Backend Repository SSH access resource fetch failed: {category}"
|
||||
))
|
||||
}
|
||||
|
||||
fn durable_create_worker_request(request: &CreateWorkerRequest) -> CreateWorkerRequest {
|
||||
let mut durable = request.clone();
|
||||
if let Some(working_directory) = durable.working_directory_request.as_mut()
|
||||
&& let Some(materialization) = working_directory.materialization.as_mut()
|
||||
{
|
||||
materialization.ssh = None;
|
||||
}
|
||||
durable
|
||||
}
|
||||
|
||||
fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
|
||||
request
|
||||
.working_directory
|
||||
@@ -2884,7 +3035,9 @@ fn subscription_worker_state(status: WorkerStatus) -> SubscriptionWorkerState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, ProfileSelector, WorkingDirectoryClaim, WorkspaceApiRef,
|
||||
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
|
||||
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
|
||||
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
|
||||
};
|
||||
use crate::config_bundle::{
|
||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
|
||||
@@ -2894,6 +3047,8 @@ mod tests {
|
||||
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
|
||||
WorkerExecutionRestoreRequest, WorkerExecutionRunState,
|
||||
};
|
||||
use crate::working_directory::WorkingDirectoryDiagnostic;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(feature = "fs-store")]
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -2950,7 +3105,7 @@ mod tests {
|
||||
&mut activity,
|
||||
&internal_worker_status_event(
|
||||
internal_worker_ref("child-b", None),
|
||||
protocol::WorkerStatus::Idle,
|
||||
protocol::WorkerStatus::Stopped,
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -3115,6 +3270,243 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_worker_request_omits_repository_credentials() {
|
||||
let mut request = task_request("worker-secret-redaction");
|
||||
request.working_directory_request = Some(WorkingDirectoryRequest {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repository-1".to_string(),
|
||||
provider: "git".to_string(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:source".to_string(),
|
||||
selector: None,
|
||||
},
|
||||
materializer: MaterializerKind::RuntimeGitCache,
|
||||
backend_workdir_id: Some("working-directory-1".to_string()),
|
||||
materialization: Some(RepositoryMaterializationContext {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
cache_generation: 0,
|
||||
ssh: Some(RepositorySshMaterializationAccess {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repository-1".to_string(),
|
||||
repository_source_fingerprint: "sha256:source".to_string(),
|
||||
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
secret_resource: repository_resource_handle(),
|
||||
private_key: SensitiveString::new("private-key-bytes"),
|
||||
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
let durable = durable_create_worker_request(&request);
|
||||
|
||||
assert!(
|
||||
request
|
||||
.working_directory_request
|
||||
.as_ref()
|
||||
.and_then(|working_directory| working_directory.materialization.as_ref())
|
||||
.and_then(|materialization| materialization.ssh.as_ref())
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
durable
|
||||
.working_directory_request
|
||||
.as_ref()
|
||||
.and_then(|working_directory| working_directory.materialization.as_ref())
|
||||
.and_then(|materialization| materialization.ssh.as_ref())
|
||||
.is_none()
|
||||
);
|
||||
let serialized = serde_json::to_string(&durable).unwrap();
|
||||
assert!(!serialized.contains("private-key-bytes"));
|
||||
assert!(!serialized.contains("known-hosts-entry"));
|
||||
}
|
||||
|
||||
fn repository_resource_handle() -> crate::resource::BackendResourceHandle {
|
||||
crate::resource::BackendResourceHandle {
|
||||
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
scope_id: Some("repository-ssh-access".to_string()),
|
||||
runtime_id: Some("runtime-1".to_string()),
|
||||
worker_id: None,
|
||||
resource_id: "repository-access-1".to_string(),
|
||||
digest: "opaque:repository-access-1".to_string(),
|
||||
operation: crate::resource::BackendResourceOperation::FetchOnce,
|
||||
expires_at_unix_seconds: i64::MAX,
|
||||
nonce: "repository-access-1".to_string(),
|
||||
revision: "1".to_string(),
|
||||
generation: None,
|
||||
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
|
||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
|
||||
audit_correlation_id: "repository-access-1".to_string(),
|
||||
profile_source_graph: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_access_resource_is_fetched_before_provider_authorization() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
backend
|
||||
.repository_access_available
|
||||
.store(true, Ordering::SeqCst);
|
||||
runtime.bind_runtime_identity("runtime-1").unwrap();
|
||||
let handle = repository_resource_handle();
|
||||
runtime
|
||||
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
|
||||
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||
resource_id: handle.resource_id.clone(),
|
||||
digest: handle.digest.clone(),
|
||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||
private_key: "private-key-bytes".to_string(),
|
||||
known_hosts_entry: "known-hosts-entry".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
})),
|
||||
}))
|
||||
.unwrap();
|
||||
let request = WorkingDirectoryRepositoryAccessRequest {
|
||||
working_directory_id: "working-directory-1".to_string(),
|
||||
materialization: RepositoryMaterializationContext {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
cache_generation: 0,
|
||||
ssh: Some(RepositorySshMaterializationAccess {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repository-1".to_string(),
|
||||
repository_source_fingerprint: "sha256:source".to_string(),
|
||||
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
secret_resource: handle,
|
||||
private_key: SensitiveString::default(),
|
||||
known_hosts_entry: SensitiveString::default(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
let replay = request.clone();
|
||||
runtime
|
||||
.authorize_working_directory_repository_access_from_resource(request)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
runtime
|
||||
.authorize_working_directory_repository_access_from_resource(replay)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let accesses = backend.repository_accesses.lock().unwrap();
|
||||
assert_eq!(accesses.len(), 1);
|
||||
let access = accesses[0].materialization.ssh.as_ref().unwrap();
|
||||
assert_eq!(access.private_key.expose(), "private-key-bytes");
|
||||
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn working_directory_create_fetches_repository_access_before_provider_call() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
backend
|
||||
.repository_access_available
|
||||
.store(true, Ordering::SeqCst);
|
||||
runtime.bind_runtime_identity("runtime-1").unwrap();
|
||||
let handle = repository_resource_handle();
|
||||
runtime
|
||||
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
|
||||
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||
resource_id: handle.resource_id.clone(),
|
||||
digest: handle.digest.clone(),
|
||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||
private_key: "create-private-key-bytes".to_string(),
|
||||
known_hosts_entry: "create-known-hosts-entry".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
})),
|
||||
}))
|
||||
.unwrap();
|
||||
let request = WorkingDirectoryRequest {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repository-1".to_string(),
|
||||
provider: "git".to_string(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:source".to_string(),
|
||||
selector: None,
|
||||
},
|
||||
materializer: MaterializerKind::RuntimeGitCache,
|
||||
backend_workdir_id: Some("working-directory-1".to_string()),
|
||||
materialization: Some(RepositoryMaterializationContext {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
operation_id: "operation-create".to_string(),
|
||||
config_revision: 1,
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
cache_generation: 0,
|
||||
ssh: Some(RepositorySshMaterializationAccess {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repository-1".to_string(),
|
||||
repository_source_fingerprint: "sha256:source".to_string(),
|
||||
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
secret_resource: handle,
|
||||
private_key: SensitiveString::default(),
|
||||
known_hosts_entry: SensitiveString::default(),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
assert!(
|
||||
runtime
|
||||
.create_working_directory_from_resource(request)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let requests = backend.working_directory_requests.lock().unwrap();
|
||||
let access = requests[0]
|
||||
.materialization
|
||||
.as_ref()
|
||||
.and_then(|materialization| materialization.ssh.as_ref())
|
||||
.unwrap();
|
||||
assert_eq!(access.private_key.expose(), "create-private-key-bytes");
|
||||
assert_eq!(
|
||||
access.known_hosts_entry.expose(),
|
||||
"create-known-hosts-entry"
|
||||
);
|
||||
}
|
||||
|
||||
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
|
||||
let mut request = task_request(objective);
|
||||
request.workspace_api = Some(WorkspaceApiRef {
|
||||
@@ -3196,6 +3588,9 @@ mod tests {
|
||||
config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
|
||||
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
|
||||
dispatched_inputs: Mutex<Vec<WorkerInput>>,
|
||||
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
|
||||
repository_access_available: AtomicBool,
|
||||
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
|
||||
preserve_commit_ack_submission_id: AtomicBool,
|
||||
#[cfg(feature = "ws-server")]
|
||||
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
|
||||
@@ -3236,6 +3631,38 @@ mod tests {
|
||||
"test-execution-backend"
|
||||
}
|
||||
|
||||
fn create_working_directory(
|
||||
&self,
|
||||
request: &WorkingDirectoryRequest,
|
||||
) -> Result<CatalogWorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||
self.working_directory_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.clone());
|
||||
Err(WorkingDirectoryDiagnostic::rejected(
|
||||
"working_directory_unsupported",
|
||||
"Worker execution backend does not support working directory materialization",
|
||||
))
|
||||
}
|
||||
|
||||
fn authorize_working_directory_repository_access(
|
||||
&self,
|
||||
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
self.repository_accesses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.clone());
|
||||
if self.repository_access_available.load(Ordering::SeqCst) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkingDirectoryDiagnostic::rejected(
|
||||
"working_directory_repository_access_unsupported",
|
||||
"Worker execution backend does not support Repository access authorization",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
||||
self.run_generations
|
||||
.lock()
|
||||
@@ -3343,6 +3770,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct TestRepositoryResourceClient {
|
||||
response: Mutex<Option<crate::resource::BackendResourceFetchResponse>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackendResourceClient for TestRepositoryResourceClient {
|
||||
async fn fetch_resource(
|
||||
&self,
|
||||
_request: BackendResourceFetchRequest,
|
||||
) -> Result<crate::resource::BackendResourceFetchResponse, BackendResourceError> {
|
||||
self.response
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.ok_or(BackendResourceError::MissingResource)
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_with_backend() -> Runtime {
|
||||
let runtime = Runtime::with_execution_backend(
|
||||
RuntimeOptions::default(),
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::auth::{
|
||||
};
|
||||
use crate::catalog::{
|
||||
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
};
|
||||
use crate::execution::{
|
||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
|
||||
@@ -38,9 +38,7 @@ use crate::working_directory::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use protocol::{Event, Method, Segment, WorkerStatus};
|
||||
use session_store::{
|
||||
CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore, collect_state,
|
||||
};
|
||||
use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore};
|
||||
#[cfg(test)]
|
||||
use session_store::{FsStore, FsWorkerStore};
|
||||
use tokio::runtime::Runtime;
|
||||
@@ -68,8 +66,10 @@ const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
|
||||
|
||||
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
|
||||
let LogEntry::UserInput { extensions, .. } = entry else {
|
||||
return false;
|
||||
let extensions = match entry {
|
||||
LogEntry::UserInput { extensions, .. }
|
||||
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||
_ => return false,
|
||||
};
|
||||
extensions.iter().any(|extension| {
|
||||
extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN
|
||||
@@ -212,11 +212,11 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
|
||||
return Err(WorkerObservationError::NotFound);
|
||||
}
|
||||
let entries = sink.subscribe_with_snapshot().0;
|
||||
let state = collect_state(&entries);
|
||||
Ok(WorkerSessionCapture {
|
||||
segment_id: format!("runtime:{runtime_id}:worker:{worker_id}"),
|
||||
items: state.history,
|
||||
})
|
||||
WorkerSessionCapture::from_log_entries(
|
||||
format!("runtime:{runtime_id}:worker:{worker_id}"),
|
||||
&entries,
|
||||
)
|
||||
.map_err(WorkerObservationError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,13 +706,17 @@ fn runtime_local_workdir_session(
|
||||
root: &Path,
|
||||
cwd: &Path,
|
||||
scope: manifest::SharedScope,
|
||||
command_environment: std::collections::BTreeMap<String, String>,
|
||||
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
|
||||
) -> WorkdirSessionHandle {
|
||||
Arc::new(LocalWorkdirSession::materialized_bound(
|
||||
Arc::new(LocalWorkdirSession::materialized_bound_with_environment(
|
||||
Workdir::new(workdir_id),
|
||||
root.to_path_buf(),
|
||||
cwd.to_path_buf(),
|
||||
scope,
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
command_environment,
|
||||
resources,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -893,6 +897,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
worker.scope().clone(),
|
||||
binding.command_environment(),
|
||||
binding.session_resources(),
|
||||
)));
|
||||
} else {
|
||||
worker.bind_workdir_session(None);
|
||||
@@ -1071,6 +1077,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
worker.scope().clone(),
|
||||
binding.command_environment(),
|
||||
binding.session_resources(),
|
||||
)));
|
||||
} else {
|
||||
worker.bind_workdir_session(None);
|
||||
@@ -1538,7 +1546,9 @@ fn accepted_notify_run_state(status: WorkerStatus, auto_run: bool) -> WorkerExec
|
||||
match status {
|
||||
WorkerStatus::Running => WorkerExecutionRunState::Busy,
|
||||
WorkerStatus::Idle if auto_run => WorkerExecutionRunState::Busy,
|
||||
WorkerStatus::Idle | WorkerStatus::Paused => WorkerExecutionRunState::Idle,
|
||||
WorkerStatus::Idle | WorkerStatus::Paused | WorkerStatus::Stopped => {
|
||||
WorkerExecutionRunState::Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1582,6 +1592,19 @@ where
|
||||
Ok(materializer.create(request)?.status())
|
||||
}
|
||||
|
||||
fn authorize_working_directory_repository_access(
|
||||
&self,
|
||||
request: &WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| {
|
||||
WorkingDirectoryDiagnostic::rejected(
|
||||
"working_directory_materializer_unavailable",
|
||||
"working directory Repository access requested, but no materializer is configured for this runtime backend",
|
||||
)
|
||||
})?;
|
||||
materializer.authorize_repository_access(request)
|
||||
}
|
||||
|
||||
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||
self.working_directory_materializer
|
||||
.as_ref()
|
||||
@@ -1624,6 +1647,8 @@ where
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
manifest::SharedScope::new(scope),
|
||||
binding.command_environment(),
|
||||
binding.session_resources(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2142,7 +2167,7 @@ mod tests {
|
||||
use crate::identity::WorkerRef;
|
||||
use crate::management::RuntimeOptions;
|
||||
use crate::observation::WorkerObservationCursor;
|
||||
use crate::working_directory::LocalGitWorktreeMaterializer;
|
||||
use crate::working_directory::RuntimeGitCacheMaterializer;
|
||||
use agen::Engine;
|
||||
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
@@ -2482,7 +2507,9 @@ mod tests {
|
||||
let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
|
||||
let worker = Worker::new(
|
||||
manifest,
|
||||
Engine::new(self.client.clone()),
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
||||
self.client.clone(),
|
||||
),
|
||||
store,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
@@ -2728,8 +2755,9 @@ mod tests {
|
||||
source_fingerprint: "sha256:test".to_string(),
|
||||
selector: Some(RepositorySelector::from("HEAD")),
|
||||
},
|
||||
materializer: MaterializerKind::LocalGitWorktree,
|
||||
materializer: MaterializerKind::RuntimeGitCache,
|
||||
backend_workdir_id: None,
|
||||
materialization: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2853,12 +2881,16 @@ mod tests {
|
||||
root.path(),
|
||||
root.path(),
|
||||
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||
Default::default(),
|
||||
Vec::new(),
|
||||
);
|
||||
let restored = runtime_local_workdir_session(
|
||||
"working-directory-42",
|
||||
root.path(),
|
||||
root.path(),
|
||||
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||
Default::default(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
assert_eq!(spawned.workdir().id().as_str(), "working-directory-42");
|
||||
@@ -3213,14 +3245,17 @@ mod tests {
|
||||
matches!(
|
||||
entry,
|
||||
LogEntry::UserInput { segments, .. }
|
||||
| LogEntry::AnnotatedUserInput { segments, .. }
|
||||
if segments == &vec![Segment::text("start the ticket")]
|
||||
)
|
||||
}));
|
||||
let submission_id = entries
|
||||
.iter()
|
||||
.find_map(|entry| {
|
||||
let LogEntry::UserInput { extensions, .. } = entry else {
|
||||
return None;
|
||||
let extensions = match entry {
|
||||
LogEntry::UserInput { extensions, .. }
|
||||
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||
_ => return None,
|
||||
};
|
||||
extensions
|
||||
.iter()
|
||||
@@ -3330,7 +3365,7 @@ mod tests {
|
||||
};
|
||||
let backend = WorkerRuntimeExecutionBackend::new(factory)
|
||||
.unwrap()
|
||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||
runtime_base.path(),
|
||||
));
|
||||
let runtime =
|
||||
@@ -3485,7 +3520,7 @@ mod tests {
|
||||
};
|
||||
let backend = WorkerRuntimeExecutionBackend::new(factory)
|
||||
.unwrap()
|
||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||
runtime_base.path(),
|
||||
));
|
||||
let runtime =
|
||||
@@ -3524,7 +3559,7 @@ mod tests {
|
||||
let repo = create_clean_repo();
|
||||
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
|
||||
.unwrap()
|
||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||
runtime_base.path(),
|
||||
));
|
||||
let runtime =
|
||||
@@ -3560,7 +3595,7 @@ mod tests {
|
||||
let repo = create_clean_repo();
|
||||
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
|
||||
.unwrap()
|
||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
|
||||
runtime_base.path(),
|
||||
));
|
||||
let runtime =
|
||||
@@ -3574,9 +3609,15 @@ mod tests {
|
||||
|
||||
assert!(format!("{error:?}").contains("spawn failed"));
|
||||
let working_directories_root = runtime_base.path();
|
||||
let remaining_entries = fs::read_dir(working_directories_root)
|
||||
.map(|entries| entries.count())
|
||||
let remaining_workdirs = fs::read_dir(working_directories_root)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
assert_eq!(remaining_entries, 0);
|
||||
assert_eq!(remaining_workdirs, 0);
|
||||
assert!(working_directories_root.join(".repository-cache").is_dir());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,11 +66,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
WorkerRunResult::Finished => println!("(finished)"),
|
||||
WorkerRunResult::Paused => println!("(paused)"),
|
||||
WorkerRunResult::LimitReached => println!("(turn limit reached)"),
|
||||
WorkerRunResult::Interrupted { message, .. } => println!("(interrupted: {message})"),
|
||||
WorkerRunResult::RolledBack => println!("(empty turn rolled back)"),
|
||||
}
|
||||
|
||||
// 5. Extract the assistant's reply from history
|
||||
let history = worker.engine().history();
|
||||
let history = worker.history();
|
||||
if let Some(text) = history
|
||||
.iter()
|
||||
.rev()
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::compact::token_counter::{
|
||||
EstimateSource, savings_for_prune_impl, token_estimates_for_prune_impl,
|
||||
};
|
||||
|
||||
impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
/// Enable prune projection on the underlying Engine.
|
||||
///
|
||||
/// Registers the config and token/savings-estimator closures on the Engine.
|
||||
|
||||
@@ -242,13 +242,13 @@ pub(crate) fn savings_for_prune_impl(
|
||||
|
||||
// ── Worker に生やす公開 API ───────────────────────────────────────────────
|
||||
|
||||
impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
/// 現在の history 全体の推定トークン数。
|
||||
///
|
||||
/// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。
|
||||
pub fn total_tokens(&self) -> TokenEstimate {
|
||||
let usage = self.usage_history();
|
||||
agen::token_counter::total_tokens(self.history(), &usage)
|
||||
agen::token_counter::total_tokens(&self.history(), &usage)
|
||||
}
|
||||
|
||||
/// 任意の history index 時点でのプロンプト全長推定。
|
||||
@@ -259,7 +259,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
/// pointer 以降に増えたプロンプト長を測るのに使う。
|
||||
pub fn total_tokens_at(&self, history_len: usize) -> TokenEstimate {
|
||||
let usage = self.usage_history();
|
||||
agen::token_counter::total_tokens_at(self.history(), &usage, history_len)
|
||||
agen::token_counter::total_tokens_at(&self.history(), &usage, history_len)
|
||||
}
|
||||
|
||||
/// 末尾から `retained` トークン以上を残すための分割位置。
|
||||
@@ -267,7 +267,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
/// `history[..cut.index]` が要約/破棄される側、`history[cut.index..]` が残る側。
|
||||
pub fn split_for_retained(&self, retained: u64) -> SplitPoint {
|
||||
let usage = self.usage_history();
|
||||
split_for_retained_impl(self.history(), &usage, retained)
|
||||
split_for_retained_impl(&self.history(), &usage, retained)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,11 @@ use workdir::LocalWorkdirSession;
|
||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
||||
|
||||
use 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), "");
|
||||
}
|
||||
}
|
||||
|
||||
+181
-14
@@ -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,
|
||||
@@ -485,6 +485,7 @@ impl WorkerController {
|
||||
// into the controller task so the in-flight turn can be reached
|
||||
// via these handles while worker itself is borrowed by drive_turn.
|
||||
let cancel_tx = worker.engine_mut().cancel_sender();
|
||||
let pause_tx = worker.engine_mut().pause_sender();
|
||||
let notify_buffer = worker.notify_buffer_handle();
|
||||
|
||||
tokio::spawn(controller_loop(
|
||||
@@ -494,6 +495,7 @@ impl WorkerController {
|
||||
shared_state,
|
||||
runtime_dir,
|
||||
cancel_tx,
|
||||
pause_tx,
|
||||
notify_buffer,
|
||||
self_parent_socket,
|
||||
spawner_name,
|
||||
@@ -763,6 +765,19 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
||||
id: result.tool_use_id.clone(),
|
||||
summary: result.summary.clone(),
|
||||
output: result.content.clone(),
|
||||
disposition: Some(match result.disposition {
|
||||
agen::ToolResultDisposition::Success => protocol::ToolResultDisposition::Success,
|
||||
agen::ToolResultDisposition::Error => protocol::ToolResultDisposition::Error,
|
||||
agen::ToolResultDisposition::Interrupted => {
|
||||
protocol::ToolResultDisposition::Interrupted
|
||||
}
|
||||
agen::ToolResultDisposition::Cancelled => {
|
||||
protocol::ToolResultDisposition::Cancelled
|
||||
}
|
||||
agen::ToolResultDisposition::OutcomeUnknown => {
|
||||
protocol::ToolResultDisposition::OutcomeUnknown
|
||||
}
|
||||
}),
|
||||
is_error: result.is_error,
|
||||
});
|
||||
});
|
||||
@@ -873,6 +888,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);
|
||||
}
|
||||
@@ -1115,6 +1138,7 @@ async fn controller_loop<C, St>(
|
||||
shared_state: Arc<WorkerSharedState>,
|
||||
runtime_dir: Arc<RuntimeDir>,
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
pause_tx: mpsc::Sender<()>,
|
||||
notify_buffer: NotifyBuffer,
|
||||
self_parent_socket: Option<PathBuf>,
|
||||
spawner_name: String,
|
||||
@@ -1161,6 +1185,9 @@ async fn controller_loop<C, St>(
|
||||
// clear at run start prevents stale partial output left by an older
|
||||
// interrupted/error turn from being carried into the next snapshot.
|
||||
worker.clear_in_flight_events();
|
||||
let parent_originated = run.is_parent_originated();
|
||||
let user_input_run = matches!(&run, PendingRun::Run(_) | PendingRun::RunTracked { .. });
|
||||
if !user_input_run {
|
||||
set_controller_status(
|
||||
&shared_state,
|
||||
&runtime_dir,
|
||||
@@ -1168,15 +1195,25 @@ async fn controller_loop<C, St>(
|
||||
WorkerStatus::Running,
|
||||
)
|
||||
.await;
|
||||
let parent_originated = run.is_parent_originated();
|
||||
}
|
||||
let (mut new_status, shutdown) = match run {
|
||||
PendingRun::Run(input) => {
|
||||
let (input_commit_tx, input_commit_rx) = oneshot::channel();
|
||||
drive_turn(
|
||||
worker.run(input),
|
||||
worker.run_with_input_extensions_and_commit_hook(
|
||||
input,
|
||||
Vec::new(),
|
||||
move || {
|
||||
let _ = input_commit_tx.send(());
|
||||
},
|
||||
),
|
||||
&mut method_rx,
|
||||
&event_tx,
|
||||
&cancel_tx,
|
||||
&pause_tx,
|
||||
&shared_state,
|
||||
&runtime_dir,
|
||||
Some(input_commit_rx),
|
||||
¬ify_buffer,
|
||||
self_parent_socket.as_ref(),
|
||||
&spawner_name,
|
||||
@@ -1186,12 +1223,22 @@ async fn controller_loop<C, St>(
|
||||
.await
|
||||
}
|
||||
PendingRun::RunTracked { input, extension } => {
|
||||
let (input_commit_tx, input_commit_rx) = oneshot::channel();
|
||||
drive_turn(
|
||||
worker.run_with_input_extensions(input, vec![extension]),
|
||||
worker.run_with_input_extensions_and_commit_hook(
|
||||
input,
|
||||
vec![extension],
|
||||
move || {
|
||||
let _ = input_commit_tx.send(());
|
||||
},
|
||||
),
|
||||
&mut method_rx,
|
||||
&event_tx,
|
||||
&cancel_tx,
|
||||
&pause_tx,
|
||||
&shared_state,
|
||||
&runtime_dir,
|
||||
Some(input_commit_rx),
|
||||
¬ify_buffer,
|
||||
self_parent_socket.as_ref(),
|
||||
&spawner_name,
|
||||
@@ -1206,7 +1253,10 @@ async fn controller_loop<C, St>(
|
||||
&mut method_rx,
|
||||
&event_tx,
|
||||
&cancel_tx,
|
||||
&pause_tx,
|
||||
&shared_state,
|
||||
&runtime_dir,
|
||||
None,
|
||||
¬ify_buffer,
|
||||
self_parent_socket.as_ref(),
|
||||
&spawner_name,
|
||||
@@ -1221,7 +1271,10 @@ async fn controller_loop<C, St>(
|
||||
&mut method_rx,
|
||||
&event_tx,
|
||||
&cancel_tx,
|
||||
&pause_tx,
|
||||
&shared_state,
|
||||
&runtime_dir,
|
||||
None,
|
||||
¬ify_buffer,
|
||||
self_parent_socket.as_ref(),
|
||||
&spawner_name,
|
||||
@@ -1346,7 +1399,7 @@ async fn controller_loop<C, St>(
|
||||
});
|
||||
}
|
||||
},
|
||||
WorkerStatus::Idle => {
|
||||
WorkerStatus::Idle | WorkerStatus::Stopped => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::NotRunning,
|
||||
message: "Worker is not running".into(),
|
||||
@@ -1387,7 +1440,7 @@ async fn controller_loop<C, St>(
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
WorkerStatus::Running => {
|
||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message:
|
||||
@@ -1401,7 +1454,7 @@ async fn controller_loop<C, St>(
|
||||
WorkerStatus::Idle | WorkerStatus::Paused => {
|
||||
emit_rewind_targets(&worker, &event_tx)
|
||||
}
|
||||
WorkerStatus::Running => {
|
||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
||||
@@ -1430,7 +1483,7 @@ async fn controller_loop<C, St>(
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
WorkerStatus::Running => {
|
||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
||||
@@ -1618,7 +1671,10 @@ async fn drive_turn<F>(
|
||||
method_rx: &mut mpsc::Receiver<Method>,
|
||||
event_tx: &broadcast::Sender<Event>,
|
||||
cancel_tx: &mpsc::Sender<()>,
|
||||
pause_tx: &mpsc::Sender<()>,
|
||||
shared_state: &Arc<WorkerSharedState>,
|
||||
runtime_dir: &RuntimeDir,
|
||||
mut input_commit_rx: Option<oneshot::Receiver<()>>,
|
||||
notify_buffer: &NotifyBuffer,
|
||||
parent_socket: Option<&PathBuf>,
|
||||
self_name: &str,
|
||||
@@ -1634,14 +1690,58 @@ where
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// If input commit and provider completion become ready together, expose
|
||||
// Running only after processing the commit fence. This makes the
|
||||
// Running snapshot contract deterministic even for immediate clients.
|
||||
biased;
|
||||
committed = async {
|
||||
input_commit_rx
|
||||
.as_mut()
|
||||
.expect("input commit receiver guarded by select condition")
|
||||
.await
|
||||
}, if input_commit_rx.is_some() => {
|
||||
input_commit_rx = None;
|
||||
if committed.is_ok() {
|
||||
set_controller_status(
|
||||
shared_state,
|
||||
runtime_dir,
|
||||
event_tx,
|
||||
WorkerStatus::Running,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
result = &mut worker_future => {
|
||||
return match result {
|
||||
Ok(r) => {
|
||||
let (status, run_result) = match r {
|
||||
WorkerRunResult::Finished if pause_requested => {
|
||||
(WorkerStatus::Paused, RunResult::Paused)
|
||||
}
|
||||
WorkerRunResult::Finished => (WorkerStatus::Idle, RunResult::Finished),
|
||||
WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused),
|
||||
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
|
||||
WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack),
|
||||
WorkerRunResult::Interrupted { .. } if pause_requested => {
|
||||
let _ = event_tx.send(Event::RunEnd { result: RunResult::Paused });
|
||||
return (WorkerStatus::Paused, shutdown_requested);
|
||||
}
|
||||
WorkerRunResult::Interrupted { code, message } => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code,
|
||||
message: message.clone(),
|
||||
});
|
||||
if parent_originated {
|
||||
crate::ipc::event::fire_and_forget(
|
||||
parent_socket.cloned(),
|
||||
protocol::WorkerEvent::Errored {
|
||||
worker_name: self_name.to_string(),
|
||||
message,
|
||||
},
|
||||
);
|
||||
}
|
||||
return (WorkerStatus::Idle, shutdown_requested);
|
||||
}
|
||||
};
|
||||
let _ = event_tx.send(Event::RunEnd { result: run_result });
|
||||
if parent_originated && matches!(run_result, RunResult::Finished) {
|
||||
@@ -1690,7 +1790,7 @@ where
|
||||
}
|
||||
Some(Method::Pause) => {
|
||||
pause_requested = true;
|
||||
let _ = cancel_tx.try_send(());
|
||||
let _ = pause_tx.try_send(());
|
||||
}
|
||||
Some(Method::Shutdown) => {
|
||||
shutdown_requested = true;
|
||||
@@ -1752,7 +1852,7 @@ where
|
||||
|
||||
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>)
|
||||
where
|
||||
C: LlmClient,
|
||||
C: LlmClient + 'static,
|
||||
St: Store,
|
||||
{
|
||||
match worker.list_rewind_targets() {
|
||||
@@ -1778,7 +1878,7 @@ fn apply_rewind<C, St>(
|
||||
expected_head_entries: usize,
|
||||
) -> bool
|
||||
where
|
||||
C: LlmClient,
|
||||
C: LlmClient + 'static,
|
||||
St: Store,
|
||||
{
|
||||
match worker.rewind_to(target, expected_head_entries) {
|
||||
@@ -1826,7 +1926,7 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
|
||||
|
||||
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
|
||||
where
|
||||
C: LlmClient,
|
||||
C: LlmClient + 'static,
|
||||
St: Store,
|
||||
{
|
||||
let manifest = worker.manifest();
|
||||
@@ -1942,11 +2042,13 @@ mod tests {
|
||||
event_tx: broadcast::Sender<Event>,
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
_cancel_rx: mpsc::Receiver<()>,
|
||||
pause_tx: mpsc::Sender<()>,
|
||||
_pause_rx: mpsc::Receiver<()>,
|
||||
shared_state: Arc<WorkerSharedState>,
|
||||
notify_buffer: NotifyBuffer,
|
||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||
parent_socket_path: PathBuf,
|
||||
_runtime_dir: Arc<RuntimeDir>,
|
||||
runtime_dir: Arc<RuntimeDir>,
|
||||
_temp: TempDir,
|
||||
}
|
||||
|
||||
@@ -1960,6 +2062,7 @@ mod tests {
|
||||
let (method_tx, method_rx) = mpsc::channel::<Method>(16);
|
||||
let (event_tx, _) = broadcast::channel::<Event>(16);
|
||||
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
||||
let (pause_tx, pause_rx) = mpsc::channel::<()>(1);
|
||||
let shared_state = Arc::new(WorkerSharedState::new(
|
||||
"child-worker".to_string(),
|
||||
session_store::new_segment_id(),
|
||||
@@ -1985,11 +2088,13 @@ mod tests {
|
||||
event_tx,
|
||||
cancel_tx,
|
||||
_cancel_rx: cancel_rx,
|
||||
pause_tx,
|
||||
_pause_rx: pause_rx,
|
||||
shared_state,
|
||||
notify_buffer,
|
||||
spawned_registry,
|
||||
parent_socket_path,
|
||||
_runtime_dir: runtime_dir,
|
||||
runtime_dir,
|
||||
_temp: temp,
|
||||
}
|
||||
}
|
||||
@@ -2042,7 +2147,10 @@ mod tests {
|
||||
&mut env.method_rx,
|
||||
&env.event_tx,
|
||||
&env.cancel_tx,
|
||||
&env.pause_tx,
|
||||
&env.shared_state,
|
||||
&env.runtime_dir,
|
||||
None,
|
||||
&env.notify_buffer,
|
||||
Some(&env.parent_socket_path),
|
||||
"child-worker",
|
||||
@@ -2063,6 +2171,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_waits_for_run_boundary_and_uses_safe_pause_channel() {
|
||||
let mut env = make_env().await;
|
||||
let method_tx = env._method_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
method_tx.send(Method::Pause).await.expect("send pause");
|
||||
});
|
||||
|
||||
let worker_future = async {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
Ok::<_, WorkerError>(WorkerRunResult::Finished)
|
||||
};
|
||||
let started_at = std::time::Instant::now();
|
||||
let (status, shutdown) = drive_turn(
|
||||
worker_future,
|
||||
&mut env.method_rx,
|
||||
&env.event_tx,
|
||||
&env.cancel_tx,
|
||||
&env.pause_tx,
|
||||
&env.shared_state,
|
||||
&env.runtime_dir,
|
||||
None,
|
||||
&env.notify_buffer,
|
||||
None,
|
||||
"child-worker",
|
||||
&env.spawned_registry,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, WorkerStatus::Paused);
|
||||
assert!(!shutdown);
|
||||
assert!(started_at.elapsed() >= Duration::from_millis(100));
|
||||
assert!(env._pause_rx.try_recv().is_ok());
|
||||
assert!(env._cancel_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_parent_originated_finished_stays_silent() {
|
||||
let mut env = make_env().await;
|
||||
@@ -2074,7 +2220,10 @@ mod tests {
|
||||
&mut env.method_rx,
|
||||
&env.event_tx,
|
||||
&env.cancel_tx,
|
||||
&env.pause_tx,
|
||||
&env.shared_state,
|
||||
&env.runtime_dir,
|
||||
None,
|
||||
&env.notify_buffer,
|
||||
Some(&env.parent_socket_path),
|
||||
"child-worker",
|
||||
@@ -2109,7 +2258,10 @@ mod tests {
|
||||
&mut env.method_rx,
|
||||
&env.event_tx,
|
||||
&env.cancel_tx,
|
||||
&env.pause_tx,
|
||||
&env.shared_state,
|
||||
&env.runtime_dir,
|
||||
None,
|
||||
&env.notify_buffer,
|
||||
Some(&env.parent_socket_path),
|
||||
"child-worker",
|
||||
@@ -2150,7 +2302,10 @@ mod tests {
|
||||
&mut env.method_rx,
|
||||
&env.event_tx,
|
||||
&env.cancel_tx,
|
||||
&env.pause_tx,
|
||||
&env.shared_state,
|
||||
&env.runtime_dir,
|
||||
None,
|
||||
&env.notify_buffer,
|
||||
Some(&env.parent_socket_path),
|
||||
"child-worker",
|
||||
@@ -2189,7 +2344,10 @@ mod tests {
|
||||
&mut env.method_rx,
|
||||
&env.event_tx,
|
||||
&env.cancel_tx,
|
||||
&env.pause_tx,
|
||||
&env.shared_state,
|
||||
&env.runtime_dir,
|
||||
None,
|
||||
&env.notify_buffer,
|
||||
Some(&env.parent_socket_path),
|
||||
"parent",
|
||||
@@ -2225,7 +2383,10 @@ mod tests {
|
||||
&mut env.method_rx,
|
||||
&env.event_tx,
|
||||
&env.cancel_tx,
|
||||
&env.pause_tx,
|
||||
&env.shared_state,
|
||||
&env.runtime_dir,
|
||||
None,
|
||||
&env.notify_buffer,
|
||||
Some(&env.parent_socket_path),
|
||||
"parent",
|
||||
@@ -2259,7 +2420,10 @@ mod tests {
|
||||
&mut env.method_rx,
|
||||
&env.event_tx,
|
||||
&env.cancel_tx,
|
||||
&env.pause_tx,
|
||||
&env.shared_state,
|
||||
&env.runtime_dir,
|
||||
None,
|
||||
&env.notify_buffer,
|
||||
Some(&env.parent_socket_path),
|
||||
"parent",
|
||||
@@ -2292,7 +2456,10 @@ mod tests {
|
||||
&mut env.method_rx,
|
||||
&env.event_tx,
|
||||
&env.cancel_tx,
|
||||
&env.pause_tx,
|
||||
&env.shared_state,
|
||||
&env.runtime_dir,
|
||||
None,
|
||||
&env.notify_buffer,
|
||||
Some(&env.parent_socket_path),
|
||||
"child-worker",
|
||||
|
||||
+622
-73
@@ -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,11 +1440,13 @@ 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| {
|
||||
self.plan_error.is_some()
|
||||
|| self.reports.iter().any(|report| {
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
@@ -1443,17 +1455,19 @@ impl FeatureRegistryInstallReport {
|
||||
}
|
||||
|
||||
pub fn error_message(&self) -> String {
|
||||
self.reports
|
||||
let mut errors = self
|
||||
.plan_error
|
||||
.iter()
|
||||
.flat_map(|report| {
|
||||
.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))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}));
|
||||
errors.join("; ")
|
||||
}
|
||||
|
||||
pub fn installed_tool_names(&self) -> Vec<String> {
|
||||
@@ -1485,12 +1499,265 @@ 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 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,10 +1790,14 @@ 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>(
|
||||
pub(crate) fn install_into_engine<C: LlmClient, A>(
|
||||
self,
|
||||
worker: &mut Engine<C, Mutable>,
|
||||
worker: &mut Engine<C, Mutable, A>,
|
||||
hook_builder: &mut HookRegistryBuilder,
|
||||
) -> FeatureRegistryInstallReport {
|
||||
let mut pending_tools = Vec::new();
|
||||
@@ -1568,53 +1839,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
|
||||
let plan = match build_feature_install_plan(&descriptors) {
|
||||
Ok(plan) => plan,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
let reports = descriptors
|
||||
.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
|
||||
.iter()
|
||||
.map(|service| service.id.clone()),
|
||||
.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",
|
||||
);
|
||||
ordered_modules.push(entry);
|
||||
report
|
||||
})
|
||||
.collect();
|
||||
return FeatureRegistryInstallReport {
|
||||
reports,
|
||||
services: FeatureServiceRegistry::default(),
|
||||
plan_error: Some(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut service_registry = FeatureServiceRegistry::default();
|
||||
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
|
||||
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
|
||||
let ordered_modules = plan
|
||||
.ordered_indices
|
||||
.into_iter()
|
||||
.map(|index| {
|
||||
(
|
||||
modules[index]
|
||||
.take()
|
||||
.expect("planned feature module is selected exactly once"),
|
||||
descriptors[index].clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (module, descriptor) in ordered_modules {
|
||||
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 +1951,7 @@ impl FeatureRegistryBuilder {
|
||||
FeatureRegistryInstallReport {
|
||||
reports,
|
||||
services: service_registry,
|
||||
plan_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1821,6 +2092,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct PlannedServiceFeature {
|
||||
descriptor: FeatureDescriptor,
|
||||
install_calls: Arc<AtomicUsize>,
|
||||
fail_install: bool,
|
||||
}
|
||||
|
||||
impl PlannedServiceFeature {
|
||||
fn new(descriptor: FeatureDescriptor) -> Self {
|
||||
Self {
|
||||
descriptor,
|
||||
install_calls: Arc::new(AtomicUsize::new(0)),
|
||||
fail_install: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for PlannedServiceFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
self.descriptor.clone()
|
||||
}
|
||||
|
||||
fn install(
|
||||
&self,
|
||||
context: &mut FeatureInstallContext<'_>,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
self.install_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.fail_install {
|
||||
return Err(FeatureInstallError::Install(
|
||||
"injected provider failure".into(),
|
||||
));
|
||||
}
|
||||
for service in &self.descriptor.provides_services {
|
||||
context
|
||||
.services()
|
||||
.provide(service.clone(), Arc::new(service.id.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn provided_service(id: &'static str, version: &'static str) -> ServiceDeclaration {
|
||||
ServiceDeclaration::new(ServiceId::builtin(id), version, "test service")
|
||||
}
|
||||
|
||||
fn required_service(id: &'static str, version: ServiceVersionReq) -> ServiceRequirement {
|
||||
{
|
||||
let mut requirement =
|
||||
ServiceRequirement::required(ServiceId::builtin(id), "test dependency");
|
||||
requirement.version = version;
|
||||
requirement
|
||||
}
|
||||
}
|
||||
|
||||
fn instruction(id: &'static str, prompt_ref: &'static str) -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
FeatureInstructionId::builtin(id),
|
||||
@@ -1830,6 +2154,249 @@ 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_orders_compatible_optional_provider_before_consumer() {
|
||||
let service = ServiceId::builtin("catalog");
|
||||
let consumer = PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
|
||||
ServiceRequirement::optional(service.clone(), "optional catalog"),
|
||||
),
|
||||
);
|
||||
let provider = PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("provider", "Provider")
|
||||
.with_provided_service(provided_service("catalog", "1")),
|
||||
);
|
||||
let builder = FeatureRegistryBuilder::new()
|
||||
.with_module(consumer)
|
||||
.with_module(provider);
|
||||
|
||||
let plan = builder.plan().expect("optional binding should be valid");
|
||||
assert_eq!(
|
||||
plan.ordered_features(),
|
||||
&[
|
||||
FeatureId::builtin("provider"),
|
||||
FeatureId::builtin("consumer"),
|
||||
]
|
||||
);
|
||||
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
let report = builder.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
assert!(!report.has_errors());
|
||||
let consumer = report
|
||||
.reports
|
||||
.iter()
|
||||
.find(|report| report.feature_id == FeatureId::builtin("consumer"))
|
||||
.expect("consumer report");
|
||||
assert!(
|
||||
consumer
|
||||
.resolved_service_requirements
|
||||
.iter()
|
||||
.any(|requirement| requirement.id == service)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_plan_rejects_missing_required_service() {
|
||||
let builder = FeatureRegistryBuilder::new().with_module(PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("consumer", "Consumer")
|
||||
.with_service_requirement(required_service("missing", ServiceVersionReq::any())),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
builder.plan(),
|
||||
Err(FeaturePlanError::MissingServiceProvider {
|
||||
consumer: FeatureId::builtin("consumer"),
|
||||
service: ServiceId::builtin("missing"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_plan_rejects_ambiguous_provider_independent_of_registration_order() {
|
||||
fn plan(reverse: bool) -> FeaturePlanError {
|
||||
let provider_a = PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("provider-a", "Provider A")
|
||||
.with_provided_service(provided_service("catalog", "1")),
|
||||
);
|
||||
let provider_b = PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("provider-b", "Provider B")
|
||||
.with_provided_service(provided_service("catalog", "1")),
|
||||
);
|
||||
let mut builder = FeatureRegistryBuilder::new();
|
||||
if reverse {
|
||||
builder.add_module(provider_b).add_module(provider_a);
|
||||
} else {
|
||||
builder.add_module(provider_a).add_module(provider_b);
|
||||
}
|
||||
builder.plan().expect_err("duplicate providers must fail")
|
||||
}
|
||||
|
||||
let expected = FeaturePlanError::AmbiguousServiceProvider {
|
||||
service: ServiceId::builtin("catalog"),
|
||||
providers: vec![
|
||||
FeatureId::builtin("provider-a"),
|
||||
FeatureId::builtin("provider-b"),
|
||||
],
|
||||
};
|
||||
assert_eq!(plan(false), expected);
|
||||
assert_eq!(plan(true), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_plan_rejects_service_version_mismatch() {
|
||||
let builder = FeatureRegistryBuilder::new()
|
||||
.with_module(PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
|
||||
required_service("catalog", ServiceVersionReq::exact("2")),
|
||||
),
|
||||
))
|
||||
.with_module(PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("provider", "Provider")
|
||||
.with_provided_service(provided_service("catalog", "1")),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
builder.plan(),
|
||||
Err(FeaturePlanError::ServiceVersionMismatch {
|
||||
consumer: FeatureId::builtin("consumer"),
|
||||
service: ServiceId::builtin("catalog"),
|
||||
requirement: "2".into(),
|
||||
provider: FeatureId::builtin("provider"),
|
||||
provider_version: "1".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_plan_reports_service_cycle_members() {
|
||||
let builder = FeatureRegistryBuilder::new()
|
||||
.with_module(PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("alpha", "Alpha")
|
||||
.with_provided_service(provided_service("alpha-service", "1"))
|
||||
.with_service_requirement(required_service(
|
||||
"beta-service",
|
||||
ServiceVersionReq::any(),
|
||||
)),
|
||||
))
|
||||
.with_module(PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("beta", "Beta")
|
||||
.with_provided_service(provided_service("beta-service", "1"))
|
||||
.with_service_requirement(required_service(
|
||||
"alpha-service",
|
||||
ServiceVersionReq::any(),
|
||||
)),
|
||||
));
|
||||
|
||||
let error = builder.plan().expect_err("cycle must fail before install");
|
||||
let FeaturePlanError::ServiceDependencyCycle { features, services } = error else {
|
||||
panic!("unexpected plan error: {error:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
features.into_iter().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([FeatureId::builtin("alpha"), FeatureId::builtin("beta")])
|
||||
);
|
||||
assert_eq!(
|
||||
services.into_iter().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([
|
||||
ServiceId::builtin("alpha-service"),
|
||||
ServiceId::builtin("beta-service"),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_failure_has_no_install_side_effects_and_reports_identities() {
|
||||
let first = PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("first", "First")
|
||||
.with_provided_service(provided_service("control", "1")),
|
||||
);
|
||||
let first_calls = Arc::clone(&first.install_calls);
|
||||
let second = PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("second", "Second")
|
||||
.with_provided_service(provided_service("control", "1")),
|
||||
);
|
||||
let second_calls = Arc::clone(&second.install_calls);
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(first)
|
||||
.with_module(second)
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
|
||||
assert!(report.has_errors());
|
||||
assert!(matches!(
|
||||
report.plan_error,
|
||||
Some(FeaturePlanError::AmbiguousServiceProvider { .. })
|
||||
));
|
||||
assert!(report.error_message().contains("builtin:control"));
|
||||
assert!(report.error_message().contains("builtin:first"));
|
||||
assert!(report.error_message().contains("builtin:second"));
|
||||
assert_eq!(first_calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(second_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(pending_tools.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_install_failure_is_fatal_and_does_not_queue_tools() {
|
||||
let provider_calls = Arc::new(AtomicUsize::new(0));
|
||||
let provider = PlannedServiceFeature {
|
||||
descriptor: FeatureDescriptor::builtin("provider", "Provider")
|
||||
.with_provided_service(provided_service("control", "1")),
|
||||
install_calls: Arc::clone(&provider_calls),
|
||||
fail_install: true,
|
||||
};
|
||||
let consumer = ToolFeature {
|
||||
descriptor: FeatureDescriptor::builtin("consumer", "Consumer")
|
||||
.with_service_requirement(required_service("control", ServiceVersionReq::any()))
|
||||
.with_tool(ToolDeclaration::new("Dependent", "dependent tool")),
|
||||
contribution_name: "Dependent",
|
||||
model_visible_name: "Dependent",
|
||||
};
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(consumer)
|
||||
.with_module(provider)
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
|
||||
assert!(report.has_errors());
|
||||
assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
|
||||
assert!(pending_tools.is_empty());
|
||||
assert!(report.error_message().contains("builtin:provider"));
|
||||
assert!(report.error_message().contains("injected provider failure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_contributions_are_recorded() {
|
||||
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
||||
@@ -2382,10 +2949,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 +2962,6 @@ mod tests {
|
||||
.with_module(ServiceFeature {
|
||||
descriptor: consumer,
|
||||
})
|
||||
.with_module(ServiceFeature {
|
||||
descriptor: missing,
|
||||
})
|
||||
.with_module(ServiceFeature {
|
||||
descriptor: optional,
|
||||
})
|
||||
@@ -2413,18 +2973,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 +3091,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]
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod memory_extract;
|
||||
pub mod merge_request;
|
||||
pub mod objective;
|
||||
pub mod orchestration;
|
||||
mod resource_projection;
|
||||
pub mod session_explore;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -356,16 +356,42 @@ pub fn manage_worker_feature(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SubWorkerControlFeature {
|
||||
control: Arc<dyn WorkerControlService>,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SubWorkerControlFeature {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("SubWorkerControlFeature")
|
||||
.finish_non_exhaustive()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,38 +399,7 @@ pub fn sub_worker_control_feature(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
) -> SubWorkerControlFeature {
|
||||
let workspace_id = client.workspace_id().unwrap_or_default().to_string();
|
||||
SubWorkerControlFeature {
|
||||
control: Arc::new(WorkspaceWorkerControlService {
|
||||
client,
|
||||
workspace_id,
|
||||
registry: Some(registry),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for SubWorkerControlFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("sub_worker", "SubWorker")
|
||||
.with_description("Parent-owned SubWorker control authority.")
|
||||
.with_provided_service(ServiceDeclaration::new(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||
"Known-SubWorker discovery and permission-fenced control operations",
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context.services().provide(
|
||||
ServiceDeclaration::new(
|
||||
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||
"Known-SubWorker discovery and permission-fenced control operations",
|
||||
),
|
||||
self.control.clone(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
SubWorkerControlFeature::new(client, registry)
|
||||
}
|
||||
|
||||
impl FeatureModule for ManageWorkerFeature {
|
||||
@@ -1097,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());
|
||||
|
||||
@@ -6,7 +6,9 @@ use memory::backend::{
|
||||
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
|
||||
};
|
||||
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence};
|
||||
use memory::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
|
||||
use memory::schema::{
|
||||
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -174,17 +176,29 @@ impl Tool for StageMemoryCandidateTool {
|
||||
"StageMemoryCandidate requires at least one entry_ref".to_string(),
|
||||
));
|
||||
}
|
||||
let mut evidence = Vec::with_capacity(params.entry_refs.len());
|
||||
let mut source_refs = Vec::with_capacity(params.entry_refs.len());
|
||||
let mut entries = Vec::with_capacity(params.entry_refs.len());
|
||||
for entry_ref in ¶ms.entry_refs {
|
||||
let projection = self.state.view.evidence_for(entry_ref).ok_or_else(|| {
|
||||
entries.push(self.state.view.evidence_for(entry_ref).ok_or_else(|| {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"unknown SessionEntryRef {entry_ref:?} for this extraction capture"
|
||||
))
|
||||
})?;
|
||||
evidence.push(staging_evidence(&projection));
|
||||
source_refs.push(source_evidence_ref(&projection));
|
||||
})?);
|
||||
}
|
||||
if matches!(params.kind, CandidateKind::Preference)
|
||||
&& entries.iter().any(|entry| {
|
||||
!matches!(
|
||||
entry.origin,
|
||||
crate::WorkerHistoryProvenance::HumanInput { .. }
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let evidence = entries.iter().map(staging_evidence).collect();
|
||||
let source_refs = entries.iter().map(source_evidence_ref).collect();
|
||||
let candidate = ExtractedCandidate {
|
||||
kind: params.kind,
|
||||
claim: params.claim,
|
||||
@@ -310,11 +324,65 @@ fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
|
||||
}
|
||||
}
|
||||
|
||||
fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
|
||||
use crate::WorkerHistoryProvenance as Origin;
|
||||
let mut evidence = EvidenceOrigin {
|
||||
kind: EvidenceOriginKind::LegacyUnknown,
|
||||
account_id: None,
|
||||
workspace_id: None,
|
||||
runtime_id: None,
|
||||
worker_id: None,
|
||||
flow_selector: None,
|
||||
flow_definition_id: None,
|
||||
flow_definition_revision: None,
|
||||
};
|
||||
match origin {
|
||||
Origin::HumanInput { account_id } => {
|
||||
evidence.kind = EvidenceOriginKind::HumanInput;
|
||||
evidence.account_id = Some(account_id.clone());
|
||||
}
|
||||
Origin::WorkerInput { actor } => {
|
||||
evidence.kind = EvidenceOriginKind::WorkerInput;
|
||||
evidence.workspace_id = actor.workspace_id.clone();
|
||||
evidence.runtime_id = actor.runtime_id.clone();
|
||||
evidence.worker_id = Some(actor.worker_id.clone());
|
||||
}
|
||||
Origin::FlowInstruction {
|
||||
selector,
|
||||
definition_id,
|
||||
definition_revision,
|
||||
..
|
||||
} => {
|
||||
evidence.kind = EvidenceOriginKind::FlowInstruction;
|
||||
evidence.flow_selector = Some(selector.clone());
|
||||
evidence.flow_definition_id = Some(definition_id.clone());
|
||||
evidence.flow_definition_revision = Some(*definition_revision);
|
||||
}
|
||||
Origin::BackendInstruction { .. } => evidence.kind = EvidenceOriginKind::BackendInstruction,
|
||||
Origin::ModelOutput { worker } => {
|
||||
evidence.kind = EvidenceOriginKind::ModelOutput;
|
||||
evidence.workspace_id = worker.workspace_id.clone();
|
||||
evidence.runtime_id = worker.runtime_id.clone();
|
||||
evidence.worker_id = Some(worker.worker_id.clone());
|
||||
}
|
||||
Origin::ToolOutput { worker } => {
|
||||
evidence.kind = EvidenceOriginKind::ToolOutput;
|
||||
evidence.workspace_id = worker.workspace_id.clone();
|
||||
evidence.runtime_id = worker.runtime_id.clone();
|
||||
evidence.worker_id = Some(worker.worker_id.clone());
|
||||
}
|
||||
Origin::DerivedSummary => evidence.kind = EvidenceOriginKind::DerivedSummary,
|
||||
Origin::LegacyUnknown => evidence.kind = EvidenceOriginKind::LegacyUnknown,
|
||||
}
|
||||
evidence
|
||||
}
|
||||
|
||||
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
|
||||
StagingEvidence {
|
||||
id: entry.entry_ref.to_string(),
|
||||
kind: evidence_kind(entry),
|
||||
entry_range: Some(entry.entry_range),
|
||||
origin: Some(evidence_origin(&entry.origin)),
|
||||
excerpt: Some(entry.excerpt.clone()),
|
||||
summary: Some(entry.summary.clone()),
|
||||
}
|
||||
@@ -325,6 +393,7 @@ fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
|
||||
segment_id: Some(entry.segment_id.clone()),
|
||||
entry_range: Some(entry.entry_range),
|
||||
evidence_id: Some(entry.entry_ref.to_string()),
|
||||
origin: Some(evidence_origin(&entry.origin)),
|
||||
evidence_kind: Some(evidence_kind(entry)),
|
||||
label: Some(entry.label.clone()),
|
||||
summary: Some(entry.summary.clone()),
|
||||
@@ -432,6 +501,15 @@ mod tests {
|
||||
assert!(input.contains("StageMemoryCandidate.entry_refs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_origin_projects_account_authority_into_evidence() {
|
||||
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput {
|
||||
account_id: "account-1".into(),
|
||||
});
|
||||
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
|
||||
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_input_failures_remain_invalid_argument_tool_errors() {
|
||||
let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend(
|
||||
@@ -445,6 +523,19 @@ mod tests {
|
||||
assert!(matches!(http, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preference_rejects_legacy_unknown_before_backend_mutation() {
|
||||
let tool = StageMemoryCandidateTool { state: state() };
|
||||
let error = tool
|
||||
.execute(
|
||||
r#"{"kind":"preference","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
|
||||
let tool = StageMemoryCandidateTool { state: state() };
|
||||
|
||||
@@ -50,11 +50,11 @@ struct MergeRequestTool {
|
||||
kind: Kind,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ShowInput {
|
||||
struct TicketInput {
|
||||
ticket: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct OpenInput {
|
||||
struct OpenMergeRequestInput {
|
||||
ticket: String,
|
||||
repository_id: String,
|
||||
selector_from: String,
|
||||
@@ -63,7 +63,7 @@ struct OpenInput {
|
||||
summary: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct CompleteInput {
|
||||
struct CompleteMergeRequestInput {
|
||||
ticket: String,
|
||||
operation_id: String,
|
||||
approval_event_id: String,
|
||||
@@ -86,7 +86,7 @@ enum MergeResolutionInput {
|
||||
ConflictsResolved,
|
||||
}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ReviewInput {
|
||||
struct ReviewMergeRequestInput {
|
||||
decision: ReviewDecisionInput,
|
||||
#[serde(default)]
|
||||
body: String,
|
||||
@@ -133,19 +133,19 @@ impl Kind {
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Show => "MergeRequestShow",
|
||||
Self::Readiness => "MergeRequestReadinessCheck",
|
||||
Self::Open => "MergeRequestOpen",
|
||||
Self::Complete => "MergeRequestComplete",
|
||||
Self::Review => "MergeRequestReview",
|
||||
Self::Show => "ShowMergeRequest",
|
||||
Self::Readiness => "CheckMergeRequestReadiness",
|
||||
Self::Open => "OpenMergeRequest",
|
||||
Self::Complete => "CompleteMergeRequest",
|
||||
Self::Review => "ReviewMergeRequest",
|
||||
}
|
||||
}
|
||||
fn schema(self) -> serde_json::Value {
|
||||
match self {
|
||||
Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)),
|
||||
Self::Open => json!(schemars::schema_for!(OpenInput)),
|
||||
Self::Complete => json!(schemars::schema_for!(CompleteInput)),
|
||||
Self::Review => json!(schemars::schema_for!(ReviewInput)),
|
||||
Self::Show | Self::Readiness => json!(schemars::schema_for!(TicketInput)),
|
||||
Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
|
||||
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
|
||||
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,13 +156,13 @@ impl Tool for MergeRequestTool {
|
||||
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
|
||||
})?;
|
||||
if matches!(self.kind, Kind::Show) {
|
||||
let value: ShowInput = parse(input)?;
|
||||
let value: TicketInput = parse(input)?;
|
||||
nonempty(&value.ticket)?;
|
||||
return self.show_current_merge_request(ws, &value.ticket);
|
||||
}
|
||||
let (method, path, body) = match self.kind {
|
||||
Kind::Readiness => {
|
||||
let v: ShowInput = parse(input)?;
|
||||
let v: TicketInput = parse(input)?;
|
||||
nonempty(&v.ticket)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Get,
|
||||
@@ -170,9 +170,9 @@ impl Tool for MergeRequestTool {
|
||||
None,
|
||||
)
|
||||
}
|
||||
Kind::Show => unreachable!("MergeRequestShow is handled above"),
|
||||
Kind::Show => unreachable!("ShowMergeRequest is handled above"),
|
||||
Kind::Open => {
|
||||
let v: OpenInput = parse(input)?;
|
||||
let v: OpenMergeRequestInput = parse(input)?;
|
||||
nonempty(&v.ticket)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
@@ -183,7 +183,7 @@ impl Tool for MergeRequestTool {
|
||||
)
|
||||
}
|
||||
Kind::Complete => {
|
||||
let v: CompleteInput = parse(input)?;
|
||||
let v: CompleteMergeRequestInput = parse(input)?;
|
||||
nonempty(&v.ticket)?;
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
@@ -194,7 +194,7 @@ impl Tool for MergeRequestTool {
|
||||
)
|
||||
}
|
||||
Kind::Review => {
|
||||
let v: ReviewInput = parse(input)?;
|
||||
let v: ReviewMergeRequestInput = parse(input)?;
|
||||
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(
|
||||
"Review submit requires injected Reviewer capability".into(),
|
||||
@@ -397,19 +397,21 @@ impl FeatureModule for MergeRequestFeature {
|
||||
|
||||
pub fn description(n: &str) -> Option<&'static str> {
|
||||
match n {
|
||||
"MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."),
|
||||
"MergeRequestReadinessCheck" => {
|
||||
Some("Resolve current provider refs and derive readiness from valid review events.")
|
||||
}
|
||||
"MergeRequestOpen" => {
|
||||
Some("Open a Merge Request with immutable source and target selectors.")
|
||||
}
|
||||
"MergeRequestComplete" => {
|
||||
Some("Complete using an approved review event and final target-ref evidence.")
|
||||
}
|
||||
"MergeRequestReview" => {
|
||||
Some("Submit the injected Reviewer capability result for its captured subject ref.")
|
||||
}
|
||||
"ShowMergeRequest" => Some(
|
||||
"Read the selector-based Merge Request, append-only thread, source-review freshness, and target-integration evidence before review, fix, or handoff decisions.",
|
||||
),
|
||||
"CheckMergeRequestReadiness" => Some(
|
||||
"Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.",
|
||||
),
|
||||
"OpenMergeRequest" => Some(
|
||||
"Open the Ticket's one Merge Request with immutable source and target selectors; reuse it and advance only selector_from with a normal non-force push for later fixes.",
|
||||
),
|
||||
"CompleteMergeRequest" => Some(
|
||||
"Record Orchestrator-owned integration using unchanged-source approval and refreshed final target-ref evidence.",
|
||||
),
|
||||
"ReviewMergeRequest" => Some(
|
||||
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -469,6 +471,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_facing_operations_use_only_verb_first_names() {
|
||||
for name in [
|
||||
"ShowMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"OpenMergeRequest",
|
||||
"CompleteMergeRequest",
|
||||
"ReviewMergeRequest",
|
||||
] {
|
||||
assert!(description(name).is_some(), "missing operation {name}");
|
||||
}
|
||||
for legacy in [
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReadinessCheck",
|
||||
"MergeRequestOpen",
|
||||
"MergeRequestComplete",
|
||||
"MergeRequestReview",
|
||||
] {
|
||||
assert!(
|
||||
description(legacy).is_none(),
|
||||
"legacy alias {legacy} must not remain registered"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn show_resolves_ticket_projection_then_reads_canonical_resource() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||
@@ -546,7 +573,7 @@ mod tests {
|
||||
open: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(tool_names(coder), ["MergeRequestShow", "MergeRequestOpen"]);
|
||||
assert_eq!(tool_names(coder), ["ShowMergeRequest", "OpenMergeRequest"]);
|
||||
|
||||
let reviewer = MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
@@ -555,7 +582,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
tool_names(reviewer),
|
||||
["MergeRequestShow", "MergeRequestReview"]
|
||||
["ShowMergeRequest", "ReviewMergeRequest"]
|
||||
);
|
||||
|
||||
let orchestrator = MergeRequestFeatureConfig {
|
||||
@@ -567,9 +594,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
tool_names(orchestrator),
|
||||
[
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReadinessCheck",
|
||||
"MergeRequestComplete"
|
||||
"ShowMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"CompleteMergeRequest"
|
||||
]
|
||||
);
|
||||
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
||||
@@ -581,8 +608,8 @@ mod tests {
|
||||
#[test]
|
||||
fn schemas_hide_revision_and_commit_authority() {
|
||||
let schemas = [
|
||||
schemars::schema_for!(OpenInput),
|
||||
schemars::schema_for!(CompleteInput),
|
||||
schemars::schema_for!(OpenMergeRequestInput),
|
||||
schemars::schema_for!(CompleteMergeRequestInput),
|
||||
];
|
||||
for s in schemas {
|
||||
let j = serde_json::to_string(&s).unwrap();
|
||||
|
||||
@@ -14,6 +14,8 @@ use serde_json::json;
|
||||
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
|
||||
use super::resource_projection::{project_objective_detail, project_objective_query};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceHttpObjectiveBackend {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
@@ -37,6 +39,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let response = project_objective_query(response).map_err(ToolError::ExecutionFailed)?;
|
||||
Ok(ToolOutput {
|
||||
summary: "Queried Objectives".to_string(),
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
@@ -58,8 +61,10 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let response = project_objective_detail(response).map_err(ToolError::ExecutionFailed)?;
|
||||
let objective_ref = response.objective_ref().to_string();
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Read objective {id}"),
|
||||
summary: format!("Read objective {objective_ref}"),
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -84,7 +89,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Created objective {}", response.id),
|
||||
format!("Created objective {}", &response.resource_key),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -112,7 +117,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Edited objective {}", response.id),
|
||||
format!("Edited objective {}", &response.resource_key),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -134,7 +139,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Updated objective {} state", response.id),
|
||||
format!("Updated objective {} state", &response.resource_key),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -142,6 +147,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> {
|
||||
let id = validate_id(&input.id, "ObjectiveLinkTicket")?;
|
||||
let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?;
|
||||
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
|
||||
let url = format!("{}/ticket-links", self.objective_url(id));
|
||||
let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
|
||||
self.client.as_ref(),
|
||||
@@ -154,7 +160,10 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Linked ticket {ticket_id} to objective {}", response.id),
|
||||
format!(
|
||||
"Linked ticket {ticket_resource_key} to objective {}",
|
||||
&response.resource_key
|
||||
),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
@@ -165,16 +174,46 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?;
|
||||
let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?;
|
||||
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
|
||||
let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id);
|
||||
let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Unlinked ticket {ticket_id} from objective {}", response.id),
|
||||
format!(
|
||||
"Unlinked ticket {ticket_resource_key} from objective {}",
|
||||
&response.resource_key
|
||||
),
|
||||
response,
|
||||
)?)
|
||||
}
|
||||
|
||||
async fn ticket_resource_key(&self, ticket_reference: &str) -> Result<String, ToolError> {
|
||||
let workspace_id = self.client.workspace_id().unwrap_or_default();
|
||||
let response: serde_json::Value = decode_response(
|
||||
self.client
|
||||
.execute(WorkspaceRequest::get(format!(
|
||||
"/api/w/{workspace_id}/tickets/{ticket_reference}"
|
||||
)))
|
||||
.map_err(WorkspaceObjectiveBackendError::from)
|
||||
.map_err(backend_error)?,
|
||||
)
|
||||
.map_err(backend_error)?;
|
||||
response
|
||||
.get("resource_key")
|
||||
.or_else(|| {
|
||||
response
|
||||
.get("meta")
|
||||
.and_then(|meta| meta.get("resource_key"))
|
||||
})
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|key| is_canonical_resource_key(key, "T-"))
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn objective_url(&self, id: &str) -> String {
|
||||
let workspace_id = self.client.workspace_id().unwrap_or_default();
|
||||
format!("/api/w/{workspace_id}/objectives/{id}")
|
||||
@@ -185,7 +224,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
pub enum WorkspaceObjectiveBackendError {
|
||||
#[error("workspace objective backend request failed: {0}")]
|
||||
Request(#[from] crate::worker::WorkspaceClientError),
|
||||
#[error("workspace objective backend returned HTTP {status}: {body}")]
|
||||
#[error("workspace objective backend returned HTTP {status}")]
|
||||
Http {
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
@@ -247,10 +286,26 @@ fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||
serde_json::from_str(&response.body).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
|
||||
resource_key.strip_prefix(prefix).is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
||||
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"required O- human key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
let projected = serde_json::json!({
|
||||
"objective": &response.resource_key,
|
||||
"title": response.title,
|
||||
"state": response.state,
|
||||
});
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
||||
content: Some(serde_json::to_string_pretty(&projected).map_err(decode_error)?),
|
||||
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -260,7 +315,7 @@ fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() || id.contains('/') {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{tool_name} requires non-empty canonical id without '/'"
|
||||
"{tool_name} requires a non-empty Objective reference without '/'"
|
||||
)));
|
||||
}
|
||||
Ok(id)
|
||||
@@ -411,9 +466,9 @@ const EDIT_DESCRIPTION: &str =
|
||||
const SET_STATE_DESCRIPTION: &str =
|
||||
"Set an Objective state through Backend Workspace API authority.";
|
||||
const LINK_TICKET_DESCRIPTION: &str =
|
||||
"Link a Ticket id to an Objective through Backend Workspace API authority.";
|
||||
"Link a Ticket reference to an Objective through Backend Workspace API authority.";
|
||||
const UNLINK_TICKET_DESCRIPTION: &str =
|
||||
"Unlink a Ticket id from an Objective through Backend Workspace API authority.";
|
||||
"Unlink a Ticket reference from an Objective through Backend Workspace API authority.";
|
||||
|
||||
fn list_schema() -> serde_json::Value {
|
||||
json!({
|
||||
@@ -422,7 +477,7 @@ fn list_schema() -> serde_json::Value {
|
||||
"properties":{
|
||||
"query":{"type":["string","null"]},
|
||||
"states":{"type":"array","items":{"type":"string"},"default":[]},
|
||||
"linked_ticket_id":{"type":["string","null"]},
|
||||
"linked_ticket_id":{"type":["string","null"],"description":"Linked Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."},
|
||||
"updated_after":{"type":["string","null"]},
|
||||
"updated_before":{"type":["string","null"]},
|
||||
"sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]},
|
||||
@@ -438,7 +493,7 @@ fn show_schema() -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required":["id"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"event_limit":{"type":["integer","null"],"minimum":1,"maximum":50},
|
||||
"event_cursor":{"type":["string","null"]}
|
||||
}
|
||||
@@ -454,7 +509,7 @@ fn create_schema() -> serde_json::Value {
|
||||
"title":{"type":"string","minLength":1},
|
||||
"body_md":{"type":"string"},
|
||||
"state":{"type":"string","default":"active"},
|
||||
"linked_tickets":{"type":"array","items":{"type":"string"}}
|
||||
"linked_tickets":{"type":"array","items":{"type":"string"},"description":"Linked Ticket references. Prefer T-*; canonical internal ids remain accepted for compatibility."}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -465,7 +520,7 @@ fn edit_schema() -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required":["id"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"title":{"type":["string","null"]},
|
||||
"old_string":{"type":["string","null"]},
|
||||
"new_string":{"type":["string","null"]},
|
||||
@@ -480,7 +535,7 @@ fn set_state_schema() -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required":["id","state"],
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"state":{"type":"string","minLength":1}
|
||||
}
|
||||
})
|
||||
@@ -500,8 +555,8 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
|
||||
"additionalProperties": false,
|
||||
"required": required,
|
||||
"properties":{
|
||||
"id":{"type":"string"},
|
||||
"ticket_id":{"type":"string"}
|
||||
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
|
||||
"ticket_id":{"type":"string","description":"Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -595,21 +650,20 @@ fn default_state() -> String {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct ObjectiveDetail {
|
||||
id: String,
|
||||
resource_key: String,
|
||||
title: String,
|
||||
state: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<String>,
|
||||
body: String,
|
||||
body_truncated: bool,
|
||||
record_source: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agen::tool::ToolDefinition;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
thread,
|
||||
};
|
||||
|
||||
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||
let mut names = definitions
|
||||
@@ -656,4 +710,135 @@ mod tests {
|
||||
let link = link_ticket_schema();
|
||||
assert_eq!(link["required"], json!(["id", "ticket_id"]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn objective_show_summary_uses_projected_human_key() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace/objectives/00001INTERNAL/show HTTP/1.1")
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"id": "00001INTERNAL",
|
||||
"resource_key": "O-3",
|
||||
"title": "Objective",
|
||||
"body": "Body",
|
||||
"state": "active",
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"linked_ticket_summaries": [],
|
||||
"events": [],
|
||||
"event_page": {"next_cursor": null, "has_more": false}
|
||||
})
|
||||
.to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||
));
|
||||
|
||||
let output = backend
|
||||
.show(ShowObjectiveInput {
|
||||
id: "00001INTERNAL".to_string(),
|
||||
event_limit: None,
|
||||
event_cursor: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(output.summary, "Read objective O-3");
|
||||
assert!(!output.summary.contains("00001INTERNAL"));
|
||||
assert!(!output.content.unwrap().contains("00001INTERNAL"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
for mutation in ["POST", "DELETE"] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with("GET /api/w/workspace/tickets/00001INTERNAL HTTP/1.1"));
|
||||
let response_body = serde_json::json!({"resource_key": "T-7"}).to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with(&format!(
|
||||
"{mutation} /api/w/workspace/objectives/O-3/ticket-links"
|
||||
)));
|
||||
let response_body = serde_json::json!({
|
||||
"resource_key": "O-3",
|
||||
"title": "Objective",
|
||||
"state": "active"
|
||||
})
|
||||
.to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||
));
|
||||
|
||||
let linked = backend
|
||||
.link_ticket(ObjectiveLinkTicketInput {
|
||||
id: "O-3".to_string(),
|
||||
ticket_id: "00001INTERNAL".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let unlinked = backend
|
||||
.unlink_ticket(ObjectiveUnlinkTicketInput {
|
||||
id: "O-3".to_string(),
|
||||
ticket_id: "00001INTERNAL".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
for output in [linked, unlinked] {
|
||||
assert!(output.summary.contains("T-7"));
|
||||
assert!(!output.summary.contains("00001INTERNAL"));
|
||||
assert!(!output.content.unwrap().contains("00001INTERNAL"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_output_rejects_noncanonical_human_keys() {
|
||||
let response = ObjectiveDetail {
|
||||
resource_key: "O-internal".to_string(),
|
||||
title: "Objective".to_string(),
|
||||
state: "active".to_string(),
|
||||
};
|
||||
assert!(objective_output("created".to_string(), response).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,18 +89,19 @@ impl Tool for SpawnTicketCoderTool {
|
||||
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
|
||||
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
|
||||
})?;
|
||||
let ticket_id = authority_id(input.ticket_id, "ticket_id")?;
|
||||
let workflow_state = self
|
||||
let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
|
||||
let ticket = self
|
||||
.ticket_service
|
||||
.workflow_state(&ticket_id)
|
||||
.ticket_handoff(&ticket_ref)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !matches!(
|
||||
workflow_state,
|
||||
ticket.workflow_state,
|
||||
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
|
||||
) {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Ticket {ticket_id} must be queued or inprogress before spawning its Coder; current state is {}",
|
||||
workflow_state.as_str()
|
||||
"Ticket {} must be queued or inprogress before spawning its Coder; current state is {}",
|
||||
ticket.resource_key,
|
||||
ticket.workflow_state.as_str()
|
||||
)));
|
||||
}
|
||||
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
||||
@@ -115,14 +116,14 @@ impl Tool for SpawnTicketCoderTool {
|
||||
)?,
|
||||
relative_cwd,
|
||||
profile: CODER_PROFILE.to_string(),
|
||||
ticket_id: Some(ticket_id.clone()),
|
||||
operation_id: Some(format!("spawn-ticket-coder:{ticket_id}:{call_id}")),
|
||||
display_name: format!("Coder · {ticket_id}"),
|
||||
ticket_id: Some(ticket.id.clone()),
|
||||
operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
|
||||
display_name: format!("Coder · {}", ticket.resource_key),
|
||||
initial_submit: vec![
|
||||
Segment::Flow {
|
||||
selector: CODER_FLOW.to_string(),
|
||||
},
|
||||
Segment::text(format!("Implement Ticket {ticket_id}.")),
|
||||
Segment::text(format!("Implement Ticket {}.", ticket.resource_key)),
|
||||
],
|
||||
})
|
||||
.await
|
||||
@@ -134,7 +135,7 @@ impl Tool for SpawnTicketCoderTool {
|
||||
)));
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Spawned Coder for Ticket {ticket_id}"),
|
||||
summary: format!("Spawned Coder for Ticket {}", ticket.resource_key),
|
||||
content: Some(response.body),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -201,21 +202,31 @@ mod tests {
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
use super::*;
|
||||
use crate::feature::builtin::ticket::TicketHandoff;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingTicketService;
|
||||
|
||||
impl TicketService for RecordingTicketService {
|
||||
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||
Ok(TicketWorkflowState::Queued)
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
assert_eq!(ticket_ref, "T-482");
|
||||
Ok(TicketHandoff {
|
||||
id: "00001KZXN51C7".to_string(),
|
||||
resource_key: "T-482".to_string(),
|
||||
workflow_state: TicketWorkflowState::Queued,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedTicketService(TicketWorkflowState);
|
||||
|
||||
impl TicketService for FixedTicketService {
|
||||
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||
Ok(self.0)
|
||||
fn ticket_handoff(&self, _ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
Ok(TicketHandoff {
|
||||
id: "00001KZXN51C7".to_string(),
|
||||
resource_key: "T-482".to_string(),
|
||||
workflow_state: self.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +258,7 @@ mod tests {
|
||||
};
|
||||
tool.execute(
|
||||
&serde_json::json!({
|
||||
"ticket_id": "00001KZXN51C7",
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"working_directory_id": "workdir-1"
|
||||
})
|
||||
@@ -265,16 +276,20 @@ mod tests {
|
||||
request.operation_id.as_deref(),
|
||||
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
|
||||
);
|
||||
assert_eq!(request.display_name, "Coder · 00001KZXN51C7");
|
||||
assert_eq!(request.display_name, "Coder · T-482");
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![
|
||||
Segment::Flow {
|
||||
selector: CODER_FLOW.to_string()
|
||||
},
|
||||
Segment::text("Implement Ticket 00001KZXN51C7.")
|
||||
Segment::text("Implement Ticket T-482.")
|
||||
]
|
||||
);
|
||||
assert!(!request.display_name.contains("00001KZXN51C7"));
|
||||
assert!(request.initial_submit.iter().all(|segment| {
|
||||
!Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelTicketQueryResponse {
|
||||
tickets: Vec<ModelTicketQueryItem>,
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketQueryItem {
|
||||
ticket: String,
|
||||
title: String,
|
||||
state: String,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
workspace_action_priority: Option<String>,
|
||||
matched_fields: Vec<String>,
|
||||
snippet: Option<String>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
linked_objectives: Vec<String>,
|
||||
relation_count: usize,
|
||||
blocker_count: usize,
|
||||
unresolved_blocker_count: usize,
|
||||
unresolved_review_count: usize,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelTicketDetail {
|
||||
ticket: String,
|
||||
title: String,
|
||||
body: String,
|
||||
state: String,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
thread: Vec<ModelTicketEvent>,
|
||||
relations: ModelTicketRelations,
|
||||
linked_objectives: Vec<ModelObjectiveSummary>,
|
||||
assignments: Vec<ModelAssignment>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
evidence: Option<ModelTicketEvidence>,
|
||||
actions: Option<ModelTicketActions>,
|
||||
event_page: Option<ModelEventPage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelObjectiveQueryResponse {
|
||||
objectives: Vec<ModelObjectiveQueryItem>,
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveQueryItem {
|
||||
objective: String,
|
||||
title: String,
|
||||
summary: Option<String>,
|
||||
state: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<String>,
|
||||
linked_ticket_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ModelObjectiveDetail {
|
||||
objective: String,
|
||||
title: String,
|
||||
body: String,
|
||||
state: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<ModelTicketSummary>,
|
||||
events: Vec<ModelObjectiveEvent>,
|
||||
event_page: ModelObjectiveEventPage,
|
||||
}
|
||||
|
||||
impl ModelObjectiveDetail {
|
||||
pub(super) fn objective_ref(&self) -> &str {
|
||||
&self.objective
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelWorkerSummary {
|
||||
worker: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvent {
|
||||
sequence: usize,
|
||||
kind: String,
|
||||
body: Option<String>,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
struct ModelTicketRelations {
|
||||
outgoing: Vec<ModelRelation>,
|
||||
incoming: Vec<ModelRelation>,
|
||||
blockers: Vec<ModelBlocker>,
|
||||
notices: Vec<ModelNotice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelRelation {
|
||||
ticket: String,
|
||||
kind: String,
|
||||
note: Option<String>,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelBlocker {
|
||||
ticket: String,
|
||||
kind: String,
|
||||
state: Option<String>,
|
||||
resolved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelNotice {
|
||||
kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveSummary {
|
||||
objective: String,
|
||||
title: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketSummary {
|
||||
ticket: String,
|
||||
title: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelAssignment {
|
||||
role: String,
|
||||
principal: String,
|
||||
assigned_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelEvidenceEvent {
|
||||
sequence: usize,
|
||||
kind: String,
|
||||
created_at: Option<String>,
|
||||
excerpt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelMergeRequest {
|
||||
state: String,
|
||||
selector_from: Option<String>,
|
||||
selector_to: String,
|
||||
review_status: String,
|
||||
subject_ref: Option<String>,
|
||||
review_excerpt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvidence {
|
||||
has_merge_request: bool,
|
||||
has_current_subject_ref: bool,
|
||||
has_review_request: bool,
|
||||
has_commit: bool,
|
||||
review_status: Option<String>,
|
||||
approved_current_subject: bool,
|
||||
unresolved_request_changes: bool,
|
||||
complete_for_integration: bool,
|
||||
missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketActions {
|
||||
can_assign_orchestrator: bool,
|
||||
can_unassign_orchestrator: bool,
|
||||
can_queue: bool,
|
||||
can_start_manual_coder: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelEventPage {
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveEvent {
|
||||
kind: String,
|
||||
created_at: String,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelObjectiveEventPage {
|
||||
next_cursor: Option<String>,
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
|
||||
let root = object(&value, "Ticket query response")?;
|
||||
let page = object_field(root, "page")?;
|
||||
let tickets = array_field(root, "items")?
|
||||
.iter()
|
||||
.map(project_ticket_query_item)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ModelTicketQueryResponse {
|
||||
tickets,
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
|
||||
let item = object(value, "Ticket query item")?;
|
||||
Ok(ModelTicketQueryItem {
|
||||
ticket: human_ref(item, "resource_key", "T-")?,
|
||||
title: string_field(item, "title")?,
|
||||
state: string_field(item, "state")?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
priority: optional_string(item, "priority")?,
|
||||
created_at: optional_string(item, "created_at")?,
|
||||
updated_at: optional_string(item, "updated_at")?,
|
||||
workspace_action_priority: optional_string(item, "workspace_action_priority")?,
|
||||
matched_fields: string_array(item, "matched_fields")?,
|
||||
snippet: optional_string(item, "snippet")?,
|
||||
current_coder: item
|
||||
.get("current_coder")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?,
|
||||
linked_objectives: string_array(item, "linked_objective_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "O-"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
relation_count: usize_field(item, "relation_count")?,
|
||||
blocker_count: usize_field(item, "blocker_count")?,
|
||||
unresolved_blocker_count: usize_field(item, "unresolved_blocker_count")?,
|
||||
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
|
||||
evidence: item.get("evidence").map(project_evidence).transpose()?,
|
||||
merge_request: item
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_merge_request)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
||||
let root = object(&value, "Ticket detail response")?;
|
||||
let current_coder = root
|
||||
.get("current_coder")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?;
|
||||
let assignments = array_field(root, "assignments")?
|
||||
.iter()
|
||||
.map(|assignment| project_assignment(assignment, current_coder.as_ref()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ModelTicketDetail {
|
||||
ticket: human_ref(root, "resource_key", "T-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
readiness: optional_string(root, "readiness")?,
|
||||
priority: optional_string(root, "priority")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
updated_at: optional_string(root, "updated_at")?,
|
||||
thread: array_field(root, "events")?
|
||||
.iter()
|
||||
.map(project_ticket_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
relations: project_relations(root.get("relations"))?,
|
||||
linked_objectives: array_field(root, "linked_objectives")?
|
||||
.iter()
|
||||
.map(project_objective_summary)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
assignments,
|
||||
current_coder,
|
||||
implementation_reports: array_field(root, "implementation_reports")?
|
||||
.iter()
|
||||
.map(project_evidence_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
merge_request: root
|
||||
.get("merge_request")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_merge_request)
|
||||
.transpose()?,
|
||||
evidence: root.get("evidence").map(project_evidence).transpose()?,
|
||||
actions: root
|
||||
.get("action_eligibility")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_actions)
|
||||
.transpose()?,
|
||||
event_page: root
|
||||
.get("event_page")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_event_page)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_objective_query(value: Value) -> Result<ModelObjectiveQueryResponse, String> {
|
||||
let root = object(&value, "Objective query response")?;
|
||||
let page = object_field(root, "page")?;
|
||||
Ok(ModelObjectiveQueryResponse {
|
||||
objectives: array_field(root, "items")?
|
||||
.iter()
|
||||
.map(project_objective_query_item)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem, String> {
|
||||
let item = object(value, "Objective query item")?;
|
||||
let linked_tickets = string_array(item, "linked_ticket_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "T-"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ModelObjectiveQueryItem {
|
||||
objective: human_ref(item, "resource_key", "O-")?,
|
||||
title: string_field(item, "title")?,
|
||||
summary: optional_string(item, "snippet")?,
|
||||
state: string_field(item, "state")?,
|
||||
created_at: optional_string(item, "created_at")?,
|
||||
updated_at: optional_string(item, "updated_at")?,
|
||||
linked_ticket_count: linked_tickets.len(),
|
||||
linked_tickets,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
|
||||
let root = object(&value, "Objective detail response")?;
|
||||
Ok(ModelObjectiveDetail {
|
||||
objective: human_ref(root, "resource_key", "O-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
updated_at: optional_string(root, "updated_at")?,
|
||||
linked_tickets: array_field(root, "linked_ticket_summaries")?
|
||||
.iter()
|
||||
.map(project_ticket_summary)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
events: array_field(root, "events")?
|
||||
.iter()
|
||||
.map(project_objective_event)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
event_page: project_objective_event_page(
|
||||
root.get("event_page")
|
||||
.ok_or_else(|| "Objective detail response is missing event_page".to_string())?,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
||||
let worker = object(value, "Worker summary")?;
|
||||
Ok(ModelWorkerSummary {
|
||||
worker: human_ref(worker, "worker_resource_key", "W-")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
|
||||
let event = object(value, "Ticket event")?;
|
||||
Ok(ModelTicketEvent {
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
body: match event.get("body") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(body)) => Some(body.clone()),
|
||||
Some(_) => return Err("invalid Ticket event body".to_string()),
|
||||
},
|
||||
created_at: optional_string(event, "at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_relations(value: Option<&Value>) -> Result<ModelTicketRelations, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(ModelTicketRelations::default());
|
||||
};
|
||||
let relations = object(value, "Ticket relations")?;
|
||||
Ok(ModelTicketRelations {
|
||||
outgoing: array_field(relations, "outgoing")?
|
||||
.iter()
|
||||
.map(|value| project_relation(value, "target_resource_key", "kind"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
incoming: array_field(relations, "incoming")?
|
||||
.iter()
|
||||
.map(|value| project_relation(value, "source_resource_key", "forward_kind"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
blockers: array_field(relations, "blockers")?
|
||||
.iter()
|
||||
.map(project_blocker)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
notices: array_field(relations, "notices")?
|
||||
.iter()
|
||||
.map(project_notice)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_relation(
|
||||
value: &Value,
|
||||
ticket_key: &str,
|
||||
kind_key: &str,
|
||||
) -> Result<ModelRelation, String> {
|
||||
let relation = object(value, "Ticket relation")?;
|
||||
let relation_data = relation.get("relation").and_then(Value::as_object);
|
||||
let kind = if kind_key == "kind" {
|
||||
relation_data
|
||||
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
|
||||
.and_then(|data| string_field(data, "kind"))?
|
||||
} else {
|
||||
string_field(relation, kind_key)?
|
||||
};
|
||||
let note = match relation_data {
|
||||
Some(data) => optional_string(data, "note")?,
|
||||
None => optional_string(relation, "note")?,
|
||||
};
|
||||
let created_at = match relation_data {
|
||||
Some(data) => optional_string(data, "at")?,
|
||||
None => optional_string(relation, "at")?,
|
||||
};
|
||||
Ok(ModelRelation {
|
||||
ticket: human_ref(relation, ticket_key, "T-")?,
|
||||
kind,
|
||||
note,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
|
||||
let blocker = object(value, "Ticket blocker")?;
|
||||
Ok(ModelBlocker {
|
||||
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
|
||||
kind: string_field(blocker, "relation_kind")?,
|
||||
state: optional_string(blocker, "blocking_state")?,
|
||||
resolved: bool_field(blocker, "resolved")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_notice(value: &Value) -> Result<ModelNotice, String> {
|
||||
let notice = object(value, "Ticket notice")?;
|
||||
Ok(ModelNotice {
|
||||
kind: string_field(notice, "kind")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
|
||||
let summary = object(value, "Objective summary")?;
|
||||
Ok(ModelObjectiveSummary {
|
||||
objective: human_ref(summary, "resource_key", "O-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
state: string_field(summary, "state")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
||||
let summary = object(value, "Ticket summary")?;
|
||||
Ok(ModelTicketSummary {
|
||||
ticket: human_ref(summary, "resource_key", "T-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
state: string_field(summary, "state")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_assignment(
|
||||
value: &Value,
|
||||
current_coder: Option<&ModelWorkerSummary>,
|
||||
) -> Result<ModelAssignment, String> {
|
||||
let assignment = object(value, "Ticket assignment")?;
|
||||
let principal = object_field(assignment, "principal")?;
|
||||
let kind = string_field(principal, "kind")?;
|
||||
let principal = match kind.as_str() {
|
||||
"worker" => current_coder
|
||||
.map(|coder| coder.worker.clone())
|
||||
.ok_or_else(|| {
|
||||
"Worker assignment is missing a Workspace human key projection".to_string()
|
||||
})?,
|
||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||
"user" => "user".to_string(),
|
||||
other => format!("source:{other}"),
|
||||
};
|
||||
Ok(ModelAssignment {
|
||||
role: string_field(assignment, "role")?,
|
||||
principal,
|
||||
assigned_at: string_field(assignment, "assigned_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_evidence_event(value: &Value) -> Result<ModelEvidenceEvent, String> {
|
||||
let event = object(value, "Ticket evidence event")?;
|
||||
Ok(ModelEvidenceEvent {
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
created_at: optional_string(event, "at")?,
|
||||
excerpt: string_field(event, "excerpt")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
|
||||
let merge = object(value, "Merge Request summary")?;
|
||||
Ok(ModelMergeRequest {
|
||||
state: string_field(merge, "state")?,
|
||||
selector_from: optional_string(merge, "selector_from")?,
|
||||
selector_to: string_field(merge, "selector_to")?,
|
||||
review_status: string_field(merge, "review_status")?,
|
||||
subject_ref: optional_string(merge, "subject_ref")?,
|
||||
review_excerpt: optional_string(merge, "review_excerpt")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
|
||||
let evidence = object(value, "Ticket evidence")?;
|
||||
Ok(ModelTicketEvidence {
|
||||
has_merge_request: bool_field(evidence, "has_merge_request")?,
|
||||
has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
|
||||
has_review_request: bool_field(evidence, "has_review_request")?,
|
||||
has_commit: bool_field(evidence, "has_commit")?,
|
||||
review_status: optional_string(evidence, "review_status")?,
|
||||
approved_current_subject: bool_field(evidence, "approved_current_subject")?,
|
||||
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
|
||||
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
|
||||
missing: string_array(evidence, "missing")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
|
||||
let actions = object(value, "Ticket actions")?;
|
||||
Ok(ModelTicketActions {
|
||||
can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?,
|
||||
can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?,
|
||||
can_queue: bool_field(actions, "can_queue")?,
|
||||
can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_event_page(value: &Value) -> Result<ModelEventPage, String> {
|
||||
let page = object(value, "Ticket event page")?;
|
||||
Ok(ModelEventPage {
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_event(value: &Value) -> Result<ModelObjectiveEvent, String> {
|
||||
let event = object(value, "Objective event")?;
|
||||
let body = optional_string(event, "body")?;
|
||||
Ok(ModelObjectiveEvent {
|
||||
kind: string_field(event, "kind")?,
|
||||
created_at: string_field(event, "created_at")?,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_objective_event_page(value: &Value) -> Result<ModelObjectiveEventPage, String> {
|
||||
let page = object(value, "Objective event page")?;
|
||||
Ok(ModelObjectiveEventPage {
|
||||
next_cursor: optional_string(page, "next_cursor")?,
|
||||
has_more: bool_field(page, "has_more")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map<String, Value>, String> {
|
||||
value
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("{context} must be an object"))
|
||||
}
|
||||
|
||||
fn object_field<'a>(
|
||||
object: &'a Map<String, Value>,
|
||||
key: &str,
|
||||
) -> Result<&'a Map<String, Value>, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn string_field(object: &Map<String, Value>, key: &str) -> Result<String, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn optional_string(object: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
|
||||
match object.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(value)) => Ok(Some(value.clone())),
|
||||
Some(_) => Err(format!("invalid {key}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_field(object: &Map<String, Value>, key: &str) -> Result<bool, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn usize_field(object: &Map<String, Value>, key: &str) -> Result<usize, String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, String> {
|
||||
array_field(object, key)?
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("invalid {key}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
|
||||
let value = object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
|
||||
validate_human_ref(value, prefix)
|
||||
}
|
||||
|
||||
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> {
|
||||
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
});
|
||||
if valid {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(format!("required {prefix} human key is unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn objective_projection_exposes_only_human_resource_references() {
|
||||
let projected = project_objective_detail(json!({
|
||||
"id": "00001M10HW6BV",
|
||||
"resource_key": "O-543",
|
||||
"title": "Objective",
|
||||
"body": "Body",
|
||||
"state": "active",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-02T00:00:00Z",
|
||||
"linked_tickets": ["00001M0E82D1V"],
|
||||
"linked_ticket_summaries": [{
|
||||
"id": "00001M0E82D1V",
|
||||
"resource_key": "T-496",
|
||||
"title": "Ticket",
|
||||
"state": "done",
|
||||
"updated_at": "2026-01-02T00:00:00Z"
|
||||
}],
|
||||
"events": [{
|
||||
"sequence": 3,
|
||||
"event_ref": "objective-event-3",
|
||||
"kind": "linked_ticket",
|
||||
"created_at": "2026-01-02T00:00:00Z",
|
||||
"body": "linked"
|
||||
}],
|
||||
"event_page": {"next_cursor": null, "has_more": false, "window_start_sequence": 3, "window_end_sequence": 3}
|
||||
})).expect("projection");
|
||||
let json = serde_json::to_value(projected).expect("serialize");
|
||||
let text = json.to_string();
|
||||
assert!(text.contains("O-543"));
|
||||
assert!(text.contains("T-496"));
|
||||
assert!(!text.contains("00001M10HW6BV"));
|
||||
assert!(!text.contains("00001M0E82D1V"));
|
||||
assert!(!text.contains("event_ref"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_projections_accept_workspace_api_shapes_and_scrub_internal_ids() {
|
||||
let ticket = project_ticket_query(json!({
|
||||
"page": {"next_cursor": null, "has_more": false},
|
||||
"record_authority": "workspace_sqlite",
|
||||
"items": [{
|
||||
"id": "00001TICKETINTERNAL",
|
||||
"resource_key": "T-543",
|
||||
"title": "Ticket",
|
||||
"state": "inprogress",
|
||||
"readiness": null,
|
||||
"priority": "high",
|
||||
"created_at": null,
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"workspace_action_priority": "active_work",
|
||||
"matched_fields": ["title"],
|
||||
"snippet": "Ticket",
|
||||
"current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
|
||||
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
|
||||
"linked_objective_keys": ["O-6"],
|
||||
"relation_count": 0,
|
||||
"blocker_count": 0,
|
||||
"unresolved_blocker_count": 0,
|
||||
"unresolved_review_count": 0,
|
||||
"evidence": {
|
||||
"has_merge_request": false,
|
||||
"has_current_subject_ref": false,
|
||||
"has_review_request": false,
|
||||
"has_commit": false,
|
||||
"review_status": null,
|
||||
"approved_current_subject": false,
|
||||
"unresolved_request_changes": false,
|
||||
"complete_for_integration": false,
|
||||
"missing": ["merge_request"]
|
||||
},
|
||||
"merge_request": null
|
||||
}]
|
||||
})).expect("Ticket query projection");
|
||||
let ticket_json = serde_json::to_string(&ticket).expect("serialize Ticket query");
|
||||
assert!(ticket_json.contains("T-543"));
|
||||
assert!(ticket_json.contains("O-6"));
|
||||
assert!(ticket_json.contains("W-12"));
|
||||
assert!(!ticket_json.contains("00001TICKETINTERNAL"));
|
||||
assert!(!ticket_json.contains("runtime-internal"));
|
||||
assert!(!ticket_json.contains("worker-internal"));
|
||||
|
||||
let objective = project_objective_query(json!({
|
||||
"page": {"next_cursor": null, "has_more": false},
|
||||
"record_authority": "workspace_sqlite",
|
||||
"items": [{
|
||||
"id": "00001OBJECTIVEINTERNAL",
|
||||
"resource_key": "O-6",
|
||||
"title": "Objective",
|
||||
"state": "active",
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"matched_fields": [],
|
||||
"snippet": null,
|
||||
"linked_ticket_count": 1,
|
||||
"linked_tickets": ["00001TICKETINTERNAL"],
|
||||
"linked_ticket_keys": ["T-543"]
|
||||
}]
|
||||
}))
|
||||
.expect("Objective query projection");
|
||||
let objective_json = serde_json::to_string(&objective).expect("serialize Objective query");
|
||||
assert!(objective_json.contains("O-6"));
|
||||
assert!(objective_json.contains("T-543"));
|
||||
assert!(objective_json.contains("\"summary\":null"));
|
||||
assert!(!objective_json.contains("00001OBJECTIVEINTERNAL"));
|
||||
assert!(!objective_json.contains("00001TICKETINTERNAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_resource_projection_rejects_noncanonical_keys() {
|
||||
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
||||
assert!(validate_human_ref(key.to_string(), prefix).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_projection_fails_closed_without_worker_resource_key() {
|
||||
let error = project_worker(&json!({"worker_resource_key": null}))
|
||||
.expect_err("missing W-key must fail");
|
||||
assert!(error.contains("W-"));
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ impl Tool for ShowOverviewTool {
|
||||
.map(|entry| {
|
||||
serde_json::json!({
|
||||
"entry_ref": entry.id,
|
||||
"origin": entry.origin,
|
||||
"entry_range": entry.entry_range,
|
||||
"kind": entry.kind.as_str(),
|
||||
"label": entry.label,
|
||||
@@ -234,15 +235,16 @@ impl Tool for SearchEntriesTool {
|
||||
.transpose()?;
|
||||
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
|
||||
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
|
||||
let view = self.state.view();
|
||||
if let (Some(from), Some(through)) = (&from, &through) {
|
||||
if from.source_index() > through.source_index() {
|
||||
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"SearchEntries from must not be after through".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
|
||||
let hits = self.state.view().search(&SearchOptions {
|
||||
let hits = view.search(&SearchOptions {
|
||||
query: params.query,
|
||||
kind,
|
||||
tool_part,
|
||||
@@ -318,6 +320,7 @@ impl Tool for ReadEntryTool {
|
||||
.map(|entry| {
|
||||
serde_json::json!({
|
||||
"entry_ref": entry.id,
|
||||
"origin": entry.origin,
|
||||
"entry_range": entry.entry_range,
|
||||
"kind": entry.kind.as_str(),
|
||||
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
|
||||
|
||||
@@ -33,6 +33,8 @@ use crate::feature::{
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
|
||||
use super::resource_projection::{project_ticket_detail, project_ticket_query};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkspaceTicketReadKind {
|
||||
Query,
|
||||
@@ -153,8 +155,10 @@ struct WorkspaceQueryTicketInput {
|
||||
/// stale_after_rescope, and missing_evidence.
|
||||
#[serde(default)]
|
||||
attention: Vec<WorkspaceTicketAttentionFilter>,
|
||||
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
related_ticket_id: Option<String>,
|
||||
relation_kind: Option<WorkspaceTicketRelationFilter>,
|
||||
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
|
||||
linked_objective_id: Option<String>,
|
||||
updated_after: Option<String>,
|
||||
updated_before: Option<String>,
|
||||
@@ -169,6 +173,7 @@ struct WorkspaceQueryTicketInput {
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
struct WorkspaceShowTicketInput {
|
||||
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
id: String,
|
||||
/// Most-recent thread entries to return, bounded by the Backend to 1..=50.
|
||||
event_limit: Option<usize>,
|
||||
@@ -229,13 +234,27 @@ impl Tool for WorkspaceTicketReadTool {
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Workspace Ticket API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"Workspace Ticket API request failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
let response_value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Workspace Ticket API returned invalid JSON: {error}"
|
||||
))
|
||||
})?;
|
||||
let content = match self.kind {
|
||||
WorkspaceTicketReadKind::Query => serde_json::to_string(
|
||||
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||
),
|
||||
WorkspaceTicketReadKind::Show => serde_json::to_string(
|
||||
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
|
||||
),
|
||||
}
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().to_string(),
|
||||
content: Some(response.body),
|
||||
content: Some(content),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -267,7 +286,20 @@ pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||
const TICKET_SERVICE_VERSION: &str = "1";
|
||||
|
||||
pub trait TicketService: Send + Sync {
|
||||
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError>;
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketHandoff {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub workflow_state: TicketWorkflowState,
|
||||
}
|
||||
|
||||
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
|
||||
resource_key.strip_prefix("T-").is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
struct BackendTicketService {
|
||||
@@ -275,10 +307,18 @@ struct BackendTicketService {
|
||||
}
|
||||
|
||||
impl TicketService for BackendTicketService {
|
||||
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||
self.backend
|
||||
.show(ticket_id.into())
|
||||
.map(|ticket| ticket.meta.workflow_state)
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
let ticket = self.backend.show(ticket_ref.into())?;
|
||||
let resource_key = ticket
|
||||
.meta
|
||||
.resource_key
|
||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
|
||||
Ok(TicketHandoff {
|
||||
id: ticket.meta.id,
|
||||
resource_key,
|
||||
workflow_state: ticket.meta.workflow_state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,14 +752,69 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket REST API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"ticket REST API request failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
serde_json::from_str(&response.body)
|
||||
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
TicketError::Conflict(format!("decode ticket REST response: {error}"))
|
||||
})?;
|
||||
Self::canonicalize_ticket_references(&mut value);
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||
}
|
||||
|
||||
fn canonicalize_ticket_references(value: &mut Value) {
|
||||
match value {
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
Self::canonicalize_ticket_references(value);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for value in object.values_mut() {
|
||||
Self::canonicalize_ticket_references(value);
|
||||
}
|
||||
if let Some(resource_key) = object
|
||||
.get("resource_key")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||
.map(ToOwned::to_owned)
|
||||
&& object.contains_key("id")
|
||||
{
|
||||
object.insert("id".to_string(), Value::String(resource_key));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_ticket_resource_key(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
base: &str,
|
||||
reference: &TicketIdOrSlug,
|
||||
) -> TicketResult<String> {
|
||||
let response: Value = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/{}", Self::ticket_path(reference)),
|
||||
None,
|
||||
)?;
|
||||
response
|
||||
.get("resource_key")
|
||||
.or_else(|| {
|
||||
response
|
||||
.get("meta")
|
||||
.and_then(|meta| meta.get("resource_key"))
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("required Ticket human key is unavailable".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn request_unit(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
method: WorkspaceRequestMethod,
|
||||
@@ -739,8 +834,8 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket REST API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"ticket REST API request failed with HTTP status {}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
Ok(TicketBackendOperationResult::Unit)
|
||||
@@ -781,12 +876,22 @@ impl WorkspaceHttpTicketBackend {
|
||||
Ok(TicketBackendOperationResult::Tickets(tickets))
|
||||
}
|
||||
TicketBackendOperation::Show { id } => {
|
||||
let ticket = Self::request(
|
||||
let ticket: Ticket = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/{}/record", Self::ticket_path(&id)),
|
||||
None,
|
||||
)?;
|
||||
if !ticket
|
||||
.meta
|
||||
.resource_key
|
||||
.as_deref()
|
||||
.is_some_and(is_canonical_ticket_resource_key)
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"required Ticket human key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||
}
|
||||
TicketBackendOperation::Create { input } => {
|
||||
@@ -889,7 +994,14 @@ impl WorkspaceHttpTicketBackend {
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||
let relation = Self::request(
|
||||
let source_resource_key =
|
||||
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
|
||||
let target_resource_key = Self::resolve_ticket_resource_key(
|
||||
client.clone(),
|
||||
&base,
|
||||
&TicketIdOrSlug::Id(relation.target.clone()),
|
||||
)?;
|
||||
let mut relation: TicketRelation = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||
@@ -897,20 +1009,30 @@ impl WorkspaceHttpTicketBackend {
|
||||
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
|
||||
})?),
|
||||
)?;
|
||||
relation.ticket_id = source_resource_key;
|
||||
relation.target = target_resource_key;
|
||||
relation.author = "workspace".to_string();
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
|
||||
let source_resource_key =
|
||||
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
|
||||
let target_resource_key =
|
||||
Self::resolve_ticket_resource_key(client.clone(), &base, &target)?;
|
||||
let target = match target {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value,
|
||||
};
|
||||
let relation = Self::request(
|
||||
let mut relation: TicketRelation = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||
Some(serde_json::json!({ "kind": kind, "target": target })),
|
||||
)?;
|
||||
relation.ticket_id = source_resource_key;
|
||||
relation.target = target_resource_key;
|
||||
relation.author = "workspace".to_string();
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||
@@ -1245,6 +1367,23 @@ mod tests {
|
||||
.expect("tool exists")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_ticket_backend_canonicalizes_model_facing_ticket_ids() {
|
||||
let mut value = serde_json::json!({
|
||||
"id": "00001INTERNAL",
|
||||
"resource_key": "T-42",
|
||||
"nested": {
|
||||
"id": "00002INTERNAL",
|
||||
"resource_key": "T-43"
|
||||
},
|
||||
"body": "user-authored 00003BODY stays unchanged"
|
||||
});
|
||||
WorkspaceHttpTicketBackend::canonicalize_ticket_references(&mut value);
|
||||
assert_eq!(value["id"], "T-42");
|
||||
assert_eq!(value["nested"]["id"], "T-43");
|
||||
assert_eq!(value["body"], "user-authored 00003BODY stays unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() {
|
||||
let client: Arc<dyn WorkspaceClient> = Arc::new(
|
||||
@@ -1721,11 +1860,102 @@ provider = "github"
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_records_relation_with_authoritative_human_keys() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
for (expected_path, resource_key) in [
|
||||
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
|
||||
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with(expected_path));
|
||||
let body = serde_json::json!({"meta": {"resource_key": resource_key}}).to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(), body
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1")
|
||||
);
|
||||
let body = serde_json::to_string(&TicketRelation {
|
||||
ticket_id: "01SOURCE".to_string(),
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: "01TARGET".to_string(),
|
||||
note: None,
|
||||
author: "worker-internal".to_string(),
|
||||
at: "2026-08-06T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace-a", format!("http://{addr}")),
|
||||
));
|
||||
|
||||
let relation = backend
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id("01SOURCE".to_string()),
|
||||
NewTicketRelation {
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: "01TARGET".to_string(),
|
||||
note: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(relation.ticket_id, "T-1");
|
||||
assert_eq!(relation.target, "T-2");
|
||||
assert_eq!(relation.author, "workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_deletes_exact_ticket_relation() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
for (expected_path, resource_key) in [
|
||||
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
|
||||
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with(expected_path));
|
||||
let response_body = serde_json::json!({
|
||||
"meta": {"resource_key": resource_key}
|
||||
})
|
||||
.to_string();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
@@ -1766,8 +1996,16 @@ provider = "github"
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(removed.ticket_id, "01SOURCE");
|
||||
assert_eq!(removed.target, "01TARGET");
|
||||
assert_eq!(removed.ticket_id, "T-1");
|
||||
assert_eq!(removed.target, "T-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
|
||||
assert!(is_canonical_ticket_resource_key("T-482"));
|
||||
for invalid in ["", "00001KZVNXFNK", "T-", "T-key", "O-482"] {
|
||||
assert!(!is_canonical_ticket_resource_key(invalid));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(test)]
|
||||
use agen::Item;
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::collect_state;
|
||||
use session_store::{LogEntry, collect_state};
|
||||
|
||||
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
|
||||
use crate::feature::{
|
||||
@@ -60,7 +61,27 @@ pub struct WorkerObservationSubject {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerSessionCapture {
|
||||
pub segment_id: String,
|
||||
pub items: Vec<Item>,
|
||||
pub entries: Vec<agen::HistoryEntry<crate::SessionHistoryMetadata>>,
|
||||
}
|
||||
|
||||
impl WorkerSessionCapture {
|
||||
pub fn from_log_entries(
|
||||
segment_id: impl Into<String>,
|
||||
log_entries: &[LogEntry],
|
||||
) -> Result<Self, String> {
|
||||
let segment_id = segment_id.into();
|
||||
let state = collect_state(log_entries);
|
||||
let parsed_segment_id = segment_id.parse().unwrap_or_default();
|
||||
let entries = crate::session_history::restore_history_entries(
|
||||
state.session_id.unwrap_or_default(),
|
||||
parsed_segment_id,
|
||||
log_entries,
|
||||
)?;
|
||||
Ok(Self {
|
||||
segment_id,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -161,9 +182,17 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
|
||||
})
|
||||
.collect::<Result<Vec<session_store::LogEntry>, _>>()?;
|
||||
let state = collect_state(&entries);
|
||||
let segment_id = response.segment_id;
|
||||
let parsed_segment_id = segment_id.parse().unwrap_or_default();
|
||||
let typed_entries = crate::session_history::restore_history_entries(
|
||||
state.session_id.unwrap_or_default(),
|
||||
parsed_segment_id,
|
||||
&entries,
|
||||
)
|
||||
.map_err(WorkerObservationError::Unavailable)?;
|
||||
Ok(WorkerSessionCapture {
|
||||
segment_id: response.segment_id,
|
||||
items: state.history,
|
||||
segment_id,
|
||||
entries: typed_entries,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -392,9 +421,15 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
|
||||
.ok_or(WorkerObservationError::NotFound)?;
|
||||
let entries = record.session.entries();
|
||||
let state = collect_state(&entries);
|
||||
let typed_entries = crate::session_history::restore_history_entries(
|
||||
state.session_id.unwrap_or_default(),
|
||||
Default::default(),
|
||||
&entries,
|
||||
)
|
||||
.map_err(WorkerObservationError::Unavailable)?;
|
||||
Ok(WorkerSessionCapture {
|
||||
segment_id: format!("subworker:{name}"),
|
||||
items: state.history,
|
||||
entries: typed_entries,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -508,6 +543,7 @@ impl Tool for ViewSessionOverviewTool {
|
||||
.map(|entry| {
|
||||
serde_json::json!({
|
||||
"entry_ref": entry.id,
|
||||
"origin": entry.origin,
|
||||
"entry_range": entry.entry_range,
|
||||
"kind": entry.kind.as_str(),
|
||||
"label": entry.label,
|
||||
@@ -547,7 +583,7 @@ impl Tool for SearchSessionEntriesTool {
|
||||
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
|
||||
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
|
||||
if let (Some(from), Some(through)) = (&from, &through) {
|
||||
if from.source_index() > through.source_index() {
|
||||
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"SearchSessionEntries from must not be after through".to_string(),
|
||||
));
|
||||
@@ -573,6 +609,7 @@ impl Tool for SearchSessionEntriesTool {
|
||||
.map(|entry| {
|
||||
serde_json::json!({
|
||||
"entry_ref": entry.id,
|
||||
"origin": entry.origin,
|
||||
"entry_range": entry.entry_range,
|
||||
"kind": entry.kind.as_str(),
|
||||
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
|
||||
@@ -628,6 +665,7 @@ impl Tool for ReadSessionEntryTool {
|
||||
.map(|entry| {
|
||||
serde_json::json!({
|
||||
"entry_ref": entry.id,
|
||||
"origin": entry.origin,
|
||||
"entry_range": entry.entry_range,
|
||||
"kind": entry.kind.as_str(),
|
||||
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
|
||||
@@ -661,7 +699,10 @@ async fn latest_view(
|
||||
.capture_worker_session(subject)
|
||||
.await
|
||||
.map_err(tool_error)?;
|
||||
Ok(SessionCapture::new(capture.segment_id, capture.items))
|
||||
Ok(SessionCapture::from_history_entries(
|
||||
capture.segment_id,
|
||||
capture.entries,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_input<T: serde::de::DeserializeOwned>(
|
||||
@@ -751,9 +792,23 @@ mod tests {
|
||||
if subject != &granted_subject() {
|
||||
return Err(WorkerObservationError::NotFound);
|
||||
}
|
||||
let entries = self
|
||||
.captures
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let mut metadata = crate::SessionHistoryMetadata::legacy_unknown();
|
||||
metadata.entry_id =
|
||||
session_store::LoggedSessionHistoryEntryId(format!("fake-{index:08}"));
|
||||
agen::HistoryEntry::new(item, metadata)
|
||||
})
|
||||
.collect();
|
||||
Ok(WorkerSessionCapture {
|
||||
segment_id: "segment".to_string(),
|
||||
items: self.captures.lock().unwrap().clone(),
|
||||
entries,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -796,7 +851,7 @@ mod tests {
|
||||
let read = read_definition(provider.clone())().1;
|
||||
let hidden = read
|
||||
.execute(
|
||||
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"E00000000"}"#,
|
||||
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"Efake-00000000"}"#,
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
@@ -810,7 +865,7 @@ mod tests {
|
||||
.push(message("a1", Role::Assistant, "second"));
|
||||
let output = read
|
||||
.execute(
|
||||
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000000"}"#,
|
||||
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000000"}"#,
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
@@ -819,7 +874,7 @@ mod tests {
|
||||
|
||||
let output = read
|
||||
.execute(
|
||||
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000001"}"#,
|
||||
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000001"}"#,
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::timeline::event::UsageEvent;
|
||||
use agen::{Engine, llm_client::LlmClient};
|
||||
use agen::{Engine, EngineError, llm_client::LlmClient};
|
||||
use manifest::{Scope, WorkerManifest};
|
||||
use protocol::{Event, InFlightSnapshot, WorkerStatus};
|
||||
use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry};
|
||||
@@ -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,17 @@ 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>,
|
||||
agen::state::Mutable,
|
||||
crate::SessionHistoryMetadata,
|
||||
>,
|
||||
) + Send,
|
||||
>,
|
||||
>,
|
||||
pub features: FeatureRegistryBuilder,
|
||||
pub required_tools: &'static [&'static str],
|
||||
pub authority: InternalWorkerAuthority,
|
||||
@@ -102,6 +115,7 @@ where
|
||||
input,
|
||||
cache_key,
|
||||
max_turns,
|
||||
engine_configurator,
|
||||
features,
|
||||
required_tools,
|
||||
authority,
|
||||
@@ -120,7 +134,9 @@ where
|
||||
|
||||
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
||||
let usage_slot = last_usage.clone();
|
||||
let mut engine = Engine::new(client).system_prompt(system_prompt);
|
||||
let mut engine =
|
||||
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
|
||||
.system_prompt(system_prompt);
|
||||
engine.on_usage(move |usage| {
|
||||
if let Ok(mut slot) = usage_slot.lock() {
|
||||
*slot = Some(usage.clone());
|
||||
@@ -128,7 +144,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 +164,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();
|
||||
@@ -188,12 +211,28 @@ where
|
||||
on_cancel_sender(worker.engine_mut().cancel_sender());
|
||||
|
||||
match worker.run_text(&input).await {
|
||||
Ok(lifecycle) => Ok(InternalWorkerResult {
|
||||
Ok(lifecycle @ WorkerRunResult::Finished)
|
||||
| Ok(lifecycle @ WorkerRunResult::Paused)
|
||||
| Ok(lifecycle @ WorkerRunResult::RolledBack) => Ok(InternalWorkerResult {
|
||||
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
|
||||
identity,
|
||||
lifecycle,
|
||||
history_entries: store.entries_count(session_id, segment_id),
|
||||
}),
|
||||
Ok(WorkerRunResult::LimitReached) => Err(InternalWorkerError {
|
||||
source: WorkerError::Engine(EngineError::Aborted(
|
||||
"internal Worker reached its turn limit".to_string(),
|
||||
)),
|
||||
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
|
||||
identity,
|
||||
history_entries: store.entries_count(session_id, segment_id),
|
||||
}),
|
||||
Ok(WorkerRunResult::Interrupted { message, .. }) => Err(InternalWorkerError {
|
||||
source: WorkerError::Engine(EngineError::Aborted(message)),
|
||||
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
|
||||
identity,
|
||||
history_entries: store.entries_count(session_id, segment_id),
|
||||
}),
|
||||
Err(source) => Err(InternalWorkerError {
|
||||
source,
|
||||
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
|
||||
@@ -221,6 +260,7 @@ impl Default for InternalWorkerVisibility {
|
||||
pub(crate) enum InternalWorkerSessionStatus {
|
||||
Idle,
|
||||
Running,
|
||||
Paused,
|
||||
Stopping,
|
||||
Stopped,
|
||||
Failed,
|
||||
@@ -231,9 +271,10 @@ impl InternalWorkerSessionStatus {
|
||||
match self {
|
||||
Self::Idle => 0,
|
||||
Self::Running => 1,
|
||||
Self::Stopping => 2,
|
||||
Self::Stopped => 3,
|
||||
Self::Failed => 4,
|
||||
Self::Paused => 2,
|
||||
Self::Stopping => 3,
|
||||
Self::Stopped => 4,
|
||||
Self::Failed => 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,16 +282,37 @@ impl InternalWorkerSessionStatus {
|
||||
match value {
|
||||
0 => Self::Idle,
|
||||
1 => Self::Running,
|
||||
2 => Self::Stopping,
|
||||
3 => Self::Stopped,
|
||||
2 => Self::Paused,
|
||||
3 => Self::Stopping,
|
||||
4 => Self::Stopped,
|
||||
_ => Self::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_internal_turn_result(
|
||||
result: Result<WorkerRunResult, WorkerError>,
|
||||
) -> (InternalWorkerSessionStatus, Option<String>) {
|
||||
match result {
|
||||
Ok(WorkerRunResult::Finished) => (InternalWorkerSessionStatus::Idle, None),
|
||||
Ok(WorkerRunResult::Paused) => (InternalWorkerSessionStatus::Paused, None),
|
||||
Ok(WorkerRunResult::LimitReached) => (
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
Some("internal Worker reached its turn limit".to_string()),
|
||||
),
|
||||
Ok(WorkerRunResult::Interrupted { message, .. }) => {
|
||||
(InternalWorkerSessionStatus::Stopped, Some(message))
|
||||
}
|
||||
Ok(WorkerRunResult::RolledBack) => (
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
Some("internal Worker run was cancelled before AI output".to_string()),
|
||||
),
|
||||
Err(error) => (InternalWorkerSessionStatus::Failed, Some(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[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")]
|
||||
@@ -343,10 +405,11 @@ impl InternalWorkerSessionHandle {
|
||||
entries,
|
||||
status: match self.status() {
|
||||
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
|
||||
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
||||
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
|
||||
InternalWorkerSessionStatus::Stopping
|
||||
| InternalWorkerSessionStatus::Stopped
|
||||
| InternalWorkerSessionStatus::Failed => WorkerStatus::Paused,
|
||||
| InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
|
||||
},
|
||||
error: self.last_error.lock().unwrap().clone(),
|
||||
in_flight,
|
||||
@@ -378,6 +441,7 @@ impl InternalWorkerSessionHandle {
|
||||
.map_err(
|
||||
|current| match InternalWorkerSessionStatus::decode(current) {
|
||||
InternalWorkerSessionStatus::Running
|
||||
| InternalWorkerSessionStatus::Paused
|
||||
| InternalWorkerSessionStatus::Stopping => InternalWorkerSessionError::Busy,
|
||||
InternalWorkerSessionStatus::Stopped | InternalWorkerSessionStatus::Failed => {
|
||||
InternalWorkerSessionError::Stopped
|
||||
@@ -410,7 +474,6 @@ impl InternalWorkerSessionHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus {
|
||||
loop {
|
||||
let notified = self.state_changed.notified();
|
||||
@@ -475,6 +538,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
input,
|
||||
cache_key,
|
||||
max_turns,
|
||||
engine_configurator,
|
||||
features,
|
||||
required_tools,
|
||||
authority,
|
||||
@@ -484,7 +548,9 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
|
||||
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
|
||||
let usage_slot = last_usage.clone();
|
||||
let mut engine = Engine::new(client).system_prompt(system_prompt);
|
||||
let mut engine =
|
||||
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
|
||||
.system_prompt(system_prompt);
|
||||
engine.on_usage(move |usage| {
|
||||
if let Ok(mut slot) = usage_slot.lock() {
|
||||
*slot = Some(usage.clone());
|
||||
@@ -492,7 +558,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 +575,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 +612,104 @@ 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::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
|
||||
.system_prompt(system_prompt);
|
||||
engine.set_cache_key(cache_key);
|
||||
engine.set_max_turns(max_turns);
|
||||
if let Some(configure) = engine_configurator {
|
||||
configure(&mut engine);
|
||||
}
|
||||
let store = EphemeralSessionStore::default();
|
||||
let inherited_workdir_session = authority.workdir_session.clone();
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
engine,
|
||||
store.clone(),
|
||||
authority.workspace,
|
||||
authority.filesystem,
|
||||
authority.scope,
|
||||
)
|
||||
.await
|
||||
.map_err(|source| InternalWorkerSessionError::Build {
|
||||
message: source.to_string(),
|
||||
})?;
|
||||
if let Some(session) = inherited_workdir_session {
|
||||
worker.bind_workdir_session(Some(session));
|
||||
}
|
||||
let install_report = worker.install_features(features);
|
||||
let installed_tools = install_report.installed_tool_names();
|
||||
let install_failed = install_report
|
||||
.reports
|
||||
.iter()
|
||||
.any(|report| !report.installed);
|
||||
let missing = required_tools
|
||||
.iter()
|
||||
.filter(|required| {
|
||||
!installed_tools
|
||||
.iter()
|
||||
.any(|installed| installed == **required)
|
||||
})
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
if install_failed || !missing.is_empty() {
|
||||
let diagnostics = install_report
|
||||
.reports
|
||||
.iter()
|
||||
.flat_map(|report| report.diagnostics.iter())
|
||||
.map(|diagnostic| diagnostic.message.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Err(InternalWorkerSessionError::Build {
|
||||
message: format!(
|
||||
"internal Worker feature installation failed: {diagnostics}; missing tools: {}",
|
||||
missing.join(", ")
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Box::pin(prepare_internal_worker_session(
|
||||
worker, store, visibility, None, None,
|
||||
))
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Sender<Event>) {
|
||||
let (_, mut log_rx) = sink.subscribe_with_snapshot();
|
||||
tokio::spawn(async move {
|
||||
@@ -620,13 +791,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut run => {
|
||||
let (turn_status, error) = match result {
|
||||
Ok(_) => (InternalWorkerSessionStatus::Idle, None),
|
||||
Err(error) => (
|
||||
InternalWorkerSessionStatus::Failed,
|
||||
Some(error.to_string()),
|
||||
),
|
||||
};
|
||||
let (turn_status, error) = classify_internal_turn_result(result);
|
||||
actor_in_flight.clear();
|
||||
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
|
||||
if let Some(message) = error {
|
||||
@@ -635,11 +800,20 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
code: protocol::ErrorCode::Internal,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
let _ = event_tx.send(Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
});
|
||||
}
|
||||
let protocol_status = match turn_status {
|
||||
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
|
||||
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
||||
InternalWorkerSessionStatus::Stopped
|
||||
| InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
|
||||
InternalWorkerSessionStatus::Running
|
||||
| InternalWorkerSessionStatus::Stopping => {
|
||||
unreachable!("run completion cannot remain active")
|
||||
}
|
||||
};
|
||||
let _ = event_tx.send(Event::Status {
|
||||
status: protocol_status,
|
||||
});
|
||||
if let Some(callback) = &on_turn_end {
|
||||
callback(turn_status);
|
||||
}
|
||||
@@ -653,7 +827,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
let _ = (&mut run).await;
|
||||
actor_in_flight.clear();
|
||||
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
|
||||
let _ = event_tx.send(Event::Status { status: WorkerStatus::Paused });
|
||||
let _ = event_tx.send(Event::Status { status: WorkerStatus::Stopped });
|
||||
let _ = event_tx.send(Event::Shutdown);
|
||||
state_changed.notify_waiters();
|
||||
let _ = done.send(());
|
||||
@@ -679,7 +853,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
std::sync::atomic::Ordering::Release,
|
||||
);
|
||||
let _ = event_tx.send(Event::Status {
|
||||
status: WorkerStatus::Paused,
|
||||
status: WorkerStatus::Stopped,
|
||||
});
|
||||
let _ = event_tx.send(Event::Shutdown);
|
||||
state_changed.notify_waiters();
|
||||
@@ -989,6 +1163,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FailingClient;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for FailingClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
Err(ClientError::Config(
|
||||
"intentional internal failure".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CancelBeforeAiClient {
|
||||
calls: Arc<AtomicUsize>,
|
||||
@@ -1076,12 +1270,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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1100,6 +1296,77 @@ permission = "write"
|
||||
assert_eq!(result.identity.kind, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_turn_result_mapping_is_exhaustive() {
|
||||
let cases = [
|
||||
(
|
||||
WorkerRunResult::Finished,
|
||||
InternalWorkerSessionStatus::Idle,
|
||||
false,
|
||||
),
|
||||
(
|
||||
WorkerRunResult::Paused,
|
||||
InternalWorkerSessionStatus::Paused,
|
||||
false,
|
||||
),
|
||||
(
|
||||
WorkerRunResult::LimitReached,
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
true,
|
||||
),
|
||||
(
|
||||
WorkerRunResult::Interrupted {
|
||||
code: protocol::ErrorCode::Internal,
|
||||
message: "cancelled".to_string(),
|
||||
},
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
true,
|
||||
),
|
||||
(
|
||||
WorkerRunResult::RolledBack,
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
for (result, expected_status, expects_error) in cases {
|
||||
let (status, error) = classify_internal_turn_result(Ok(result));
|
||||
assert_eq!(status, expected_status);
|
||||
assert_eq!(error.is_some(), expects_error);
|
||||
}
|
||||
|
||||
let (status, error) = classify_internal_turn_result(Err(WorkerError::Engine(
|
||||
EngineError::Aborted("fatal".to_string()),
|
||||
)));
|
||||
assert_eq!(status, InternalWorkerSessionStatus::Failed);
|
||||
assert!(error.is_some_and(|message| message.contains("fatal")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fatal_internal_run_transitions_to_stopped_protocol_status() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut internal_spec = spec(calls, &[]);
|
||||
internal_spec.client = Box::new(FailingClient);
|
||||
|
||||
let handle = spawn_internal_worker_session(internal_spec)
|
||||
.await
|
||||
.expect("spawn failing Internal Worker session");
|
||||
assert_eq!(
|
||||
handle.wait_until_idle().await,
|
||||
InternalWorkerSessionStatus::Stopped
|
||||
);
|
||||
assert_eq!(handle.status(), InternalWorkerSessionStatus::Stopped);
|
||||
assert_eq!(handle.protocol_snapshot().status, WorkerStatus::Stopped);
|
||||
assert!(
|
||||
handle
|
||||
.last_error
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.is_some_and(|message| message.contains("intentional internal failure"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_accepts_follow_up_turns_and_stops_without_runtime_registration() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::prompt::catalog::PromptCatalog;
|
||||
use agen::Item;
|
||||
use agen::{Item, ToolResultDisposition};
|
||||
|
||||
/// Build synthetic `Item::ToolResult` items for every unanswered
|
||||
/// `Item::ToolCall` in `history`, preserving order.
|
||||
@@ -28,7 +28,16 @@ pub(crate) fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Ve
|
||||
for item in history {
|
||||
if let Item::ToolCall { call_id, .. } = item {
|
||||
if !answered.contains(call_id.as_str()) {
|
||||
out.push(Item::tool_result(call_id.clone(), summary));
|
||||
out.push(Item::tool_result_item_with_disposition_and_attachments(
|
||||
call_id.clone(),
|
||||
summary,
|
||||
Some(
|
||||
"Execution ended before completion could be confirmed. Completion and side effects are unknown."
|
||||
.to_string(),
|
||||
),
|
||||
ToolResultDisposition::OutcomeUnknown,
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,10 +86,12 @@ mod tests {
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary: got,
|
||||
disposition,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(call_id, "c1");
|
||||
assert_eq!(got, &summary);
|
||||
assert_eq!(*disposition, ToolResultDisposition::OutcomeUnknown);
|
||||
}
|
||||
other => panic!("expected ToolResult, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! decisions (continue / skip / abort / pause).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -33,7 +34,9 @@ use crate::hook::{
|
||||
};
|
||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
||||
use crate::prompt::catalog::PromptCatalog;
|
||||
use crate::session_history::SessionHistoryMetadata;
|
||||
use crate::worker::SystemItemCommitter;
|
||||
use agen::HistoryEntry;
|
||||
use agen::token_counter::total_tokens;
|
||||
|
||||
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
||||
@@ -73,6 +76,7 @@ pub(crate) struct WorkerInterceptor {
|
||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||
/// attached.
|
||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||
/// Next turn index assigned by `on_prompt_submit`.
|
||||
next_turn_index: AtomicUsize,
|
||||
/// Tool calls observed in the current turn (reset on each new prompt).
|
||||
@@ -80,6 +84,7 @@ pub(crate) struct WorkerInterceptor {
|
||||
}
|
||||
|
||||
impl WorkerInterceptor {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(
|
||||
registry: Arc<HookRegistry>,
|
||||
compact_state: Option<Arc<CompactState>>,
|
||||
@@ -88,6 +93,28 @@ impl WorkerInterceptor {
|
||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||
) -> Self {
|
||||
Self::new_with_history_queue(
|
||||
registry,
|
||||
compact_state,
|
||||
usage_history,
|
||||
pending_notifies,
|
||||
pending_attachments,
|
||||
prompts,
|
||||
log_writer,
|
||||
Arc::new(Mutex::new(VecDeque::new())),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_with_history_queue(
|
||||
registry: Arc<HookRegistry>,
|
||||
compact_state: Option<Arc<CompactState>>,
|
||||
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
||||
pending_notifies: NotifyBuffer,
|
||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
@@ -99,6 +126,7 @@ impl WorkerInterceptor {
|
||||
prompts,
|
||||
prompt_workspace_id: None,
|
||||
log_writer,
|
||||
pending_committed_history,
|
||||
next_turn_index: AtomicUsize::new(0),
|
||||
tool_calls_this_turn: AtomicUsize::new(0),
|
||||
}
|
||||
@@ -125,7 +153,11 @@ impl WorkerInterceptor {
|
||||
return Ok(());
|
||||
};
|
||||
for item in items {
|
||||
writer.commit_system_item(item.clone())?;
|
||||
let entry = writer.commit_system_item(item.clone())?;
|
||||
self.pending_committed_history
|
||||
.lock()
|
||||
.expect("pending committed history poisoned")
|
||||
.push_back(entry);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -507,7 +539,12 @@ mod tests {
|
||||
&self,
|
||||
entry: session_store::LogEntry,
|
||||
) -> Result<(), session_store::StoreError> {
|
||||
if let session_store::LogEntry::SystemItem { item, .. } = entry {
|
||||
let item = match entry {
|
||||
session_store::LogEntry::SystemItem { item, .. } => Some(item),
|
||||
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = item {
|
||||
self.committed
|
||||
.lock()
|
||||
.expect("committed system-item list poisoned")
|
||||
|
||||
@@ -29,15 +29,21 @@ pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtoco
|
||||
|
||||
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
||||
match entry {
|
||||
LogEntry::SegmentStart { .. } => {
|
||||
entry @ (LogEntry::SegmentStart { .. } | LogEntry::AnnotatedSegmentStart { .. }) => {
|
||||
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
|
||||
Some(Event::SegmentRotated { entry: value })
|
||||
}
|
||||
LogEntry::UserInput { segments, .. } => Some(Event::UserMessage { segments }),
|
||||
LogEntry::UserInput { segments, .. } | LogEntry::AnnotatedUserInput { segments, .. } => {
|
||||
Some(Event::UserMessage { segments })
|
||||
}
|
||||
LogEntry::SystemItem { item, .. } => {
|
||||
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
|
||||
Some(Event::SystemItem { item: value })
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
||||
Some(Event::SystemItem { item: value })
|
||||
}
|
||||
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
|
||||
other => {
|
||||
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod prompt;
|
||||
pub mod runtime;
|
||||
pub mod segment_log_sink;
|
||||
mod session_capture;
|
||||
mod session_history;
|
||||
pub mod shared_state;
|
||||
mod shutdown_after_idle;
|
||||
pub mod skill;
|
||||
@@ -33,14 +34,19 @@ pub use manifest::{
|
||||
};
|
||||
pub use model_client::{ProviderError, build_client};
|
||||
pub use prompt::catalog::{
|
||||
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, WorkspacePromptProjection,
|
||||
prompt_schema_source,
|
||||
CatalogError, EffectivePromptCatalog, OrchestratorQueueAttentionContext,
|
||||
OrchestratorQueueAttentionPrompt, OrchestratorQueueAttentionTicket, PromptCatalog,
|
||||
WorkerPrompt, WorkspacePromptProjection, prompt_schema_source,
|
||||
};
|
||||
pub use prompt::source::PromptCatalogSource;
|
||||
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
|
||||
pub use runtime::dir::RuntimeDir;
|
||||
pub use segment_log_sink::SegmentLogSink;
|
||||
pub use session_history::{
|
||||
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
|
||||
WorkerHistoryProvenance, WorkerSubjectSnapshot,
|
||||
};
|
||||
pub use shared_state::WorkerSharedState;
|
||||
pub use worker::{
|
||||
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
|
||||
|
||||
@@ -34,7 +34,7 @@ impl PermissionHook {
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
pub(crate) fn apply_permissions_from_manifest(&mut self) {
|
||||
let Some(permissions) = self.manifest().permissions.clone() else {
|
||||
return;
|
||||
|
||||
@@ -102,7 +102,6 @@ pub enum WorkerPrompt {
|
||||
AgentsMdSection,
|
||||
ResidentMemorySummarySection,
|
||||
WorkerOrchestrationGuidanceSection,
|
||||
TicketEventCompanionNotice,
|
||||
SubWorkerSpawnToolDescription,
|
||||
}
|
||||
|
||||
@@ -122,7 +121,6 @@ impl WorkerPrompt {
|
||||
Self::WorkerOrchestrationGuidanceSection => {
|
||||
"internal.worker_orchestration_guidance_section"
|
||||
}
|
||||
Self::TicketEventCompanionNotice => "worker.ticket_event_companion_notice",
|
||||
Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description",
|
||||
}
|
||||
}
|
||||
@@ -139,13 +137,97 @@ impl WorkerPrompt {
|
||||
WorkerPrompt::AgentsMdSection,
|
||||
WorkerPrompt::ResidentMemorySummarySection,
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
WorkerPrompt::TicketEventCompanionNotice,
|
||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
||||
];
|
||||
}
|
||||
|
||||
/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OrchestratorQueueAttentionTicket {
|
||||
resource_key: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
impl OrchestratorQueueAttentionTicket {
|
||||
pub fn new(
|
||||
resource_key: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
) -> Result<Self, CatalogError> {
|
||||
let resource_key = resource_key.into();
|
||||
if !is_ticket_resource_key(&resource_key) {
|
||||
return Err(CatalogError::InvalidQueueAttentionResourceKey);
|
||||
}
|
||||
Ok(Self {
|
||||
resource_key,
|
||||
title: bounded_queue_attention_text(&title.into(), 240),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared model-visible context for every Orchestrator backlog attention renderer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OrchestratorQueueAttentionContext {
|
||||
tickets: Vec<OrchestratorQueueAttentionTicket>,
|
||||
separator: &'static str,
|
||||
omitted_ticket_count: usize,
|
||||
}
|
||||
|
||||
impl OrchestratorQueueAttentionContext {
|
||||
pub const MAX_TICKETS: usize = 20;
|
||||
|
||||
pub fn new(tickets: Vec<OrchestratorQueueAttentionTicket>) -> Self {
|
||||
let omitted_ticket_count = tickets.len().saturating_sub(Self::MAX_TICKETS);
|
||||
Self {
|
||||
tickets: tickets.into_iter().take(Self::MAX_TICKETS).collect(),
|
||||
separator: "—",
|
||||
omitted_ticket_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt-catalog entries that must share the same backlog-attention body contract.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OrchestratorQueueAttentionPrompt {
|
||||
Server,
|
||||
Tui,
|
||||
}
|
||||
|
||||
impl OrchestratorQueueAttentionPrompt {
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Server => "internal.workspace_orchestrator_queue_attention",
|
||||
Self::Tui => "panel.orchestrator_idle_queue_notice",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ticket_resource_key(input: &str) -> bool {
|
||||
input.len() <= 32
|
||||
&& input.strip_prefix("T-").is_some_and(|suffix| {
|
||||
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn bounded_queue_attention_text(input: &str, max_chars: usize) -> String {
|
||||
let mut output = String::new();
|
||||
for (index, character) in input.chars().enumerate() {
|
||||
if index == max_chars {
|
||||
output.push('…');
|
||||
break;
|
||||
}
|
||||
output.push(if character.is_control() {
|
||||
' '
|
||||
} else {
|
||||
character
|
||||
});
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CatalogError {
|
||||
#[error("queued Ticket resource key is missing or invalid")]
|
||||
InvalidQueueAttentionResourceKey,
|
||||
#[error("failed to build builtin Prompt source tree: {0}")]
|
||||
BuiltinTree(String),
|
||||
#[error("failed to evaluate builtin Prompt source tree: {0}")]
|
||||
@@ -322,6 +404,14 @@ impl PromptCatalog {
|
||||
self.render_name(key, Value::from_serialize(context))
|
||||
}
|
||||
|
||||
pub fn orchestrator_queue_attention(
|
||||
&self,
|
||||
prompt: OrchestratorQueueAttentionPrompt,
|
||||
context: &OrchestratorQueueAttentionContext,
|
||||
) -> Result<String, CatalogError> {
|
||||
self.render_serializable(prompt.key(), context)
|
||||
}
|
||||
|
||||
pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> {
|
||||
let template = self
|
||||
.env
|
||||
@@ -593,6 +683,12 @@ mod tests {
|
||||
fn builtin_dcdl_catalog_loads() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
assert!(!catalog.projection.templates.is_empty());
|
||||
assert!(
|
||||
!catalog
|
||||
.projection
|
||||
.templates
|
||||
.contains_key("worker.ticket_event_companion_notice")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -609,6 +705,103 @@ mod tests {
|
||||
assert!(!prompt.contains("use the Ticket repository `origin` transport"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_request_prompts_use_selector_refs_and_verb_first_operations() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
let templates = &catalog.projection.templates;
|
||||
let common = &templates["common.merge_request"];
|
||||
let coder = &templates["role.coder"];
|
||||
let orchestrator = &templates["role.orchestrator"];
|
||||
let reviewer = &templates["role.reviewer"];
|
||||
let combined = format!("{common}\n{coder}\n{orchestrator}\n{reviewer}");
|
||||
|
||||
for operation in [
|
||||
"OpenMergeRequest",
|
||||
"ShowMergeRequest",
|
||||
"ReviewMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"CompleteMergeRequest",
|
||||
] {
|
||||
assert!(combined.contains(operation), "missing {operation}");
|
||||
}
|
||||
for stale_operation in [
|
||||
"MergeRequestOpen",
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReview",
|
||||
"MergeRequestReadinessCheck",
|
||||
"MergeRequestComplete",
|
||||
"MergeRequestAddRevision",
|
||||
] {
|
||||
assert!(
|
||||
!combined.contains(stale_operation),
|
||||
"stale operation {stale_operation} remains in prompt authority"
|
||||
);
|
||||
}
|
||||
assert!(common.contains("selector_from"));
|
||||
assert!(common.contains("normal non-force push"));
|
||||
assert!(common.contains("Moving only the target ref does not invalidate approval"));
|
||||
assert!(common.contains("take precedence over stale Memory"));
|
||||
assert!(coder.contains("Never invent an add-revision operation"));
|
||||
assert!(orchestrator.contains("Target-only movement preserves source approval"));
|
||||
assert!(reviewer.contains("target-only movement does not invalidate approval"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_attention_prompts_share_sanitized_contract_and_true_truncation() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
let tickets = (1..=OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
|
||||
.map(|index| {
|
||||
OrchestratorQueueAttentionTicket::new(
|
||||
format!("T-{index}"),
|
||||
format!("Ticket {index}\nwith control\u{7}"),
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let context = OrchestratorQueueAttentionContext::new(tickets);
|
||||
let server = catalog
|
||||
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
|
||||
.unwrap();
|
||||
let tui = catalog
|
||||
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(server, tui);
|
||||
assert!(server.starts_with("Queued Tickets require attention:"));
|
||||
assert!(server.contains("- T-1 — Ticket 1 with control "));
|
||||
assert!(!server.contains("T-21"));
|
||||
assert!(server.contains("were omitted from this notice: 1"));
|
||||
assert!(server.contains("Re-query current Ticket authority"));
|
||||
assert!(server.contains("Reread the current Ticket state before acting"));
|
||||
for secret in [
|
||||
"workspace_id",
|
||||
"Workspace:",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"bounded",
|
||||
] {
|
||||
assert!(!server.contains(secret), "leaked {secret}: {server}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_attention_prompt_omits_truncation_text_for_complete_list() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
let context = OrchestratorQueueAttentionContext::new(vec![
|
||||
OrchestratorQueueAttentionTicket::new("T-541", "Attention contract").unwrap(),
|
||||
]);
|
||||
let rendered = catalog
|
||||
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("- T-541 — Attention contract"));
|
||||
assert!(!rendered.contains("omitted"));
|
||||
assert!(matches!(
|
||||
OrchestratorQueueAttentionTicket::new("opaque-id", "must fail"),
|
||||
Err(CatalogError::InvalidQueueAttentionResourceKey)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_rejects_dynamic_legacy_missing_and_cycles() {
|
||||
let invalid = BTreeMap::from([
|
||||
|
||||
@@ -121,8 +121,11 @@ impl SegmentLogSink {
|
||||
matches!(
|
||||
entry,
|
||||
LogEntry::SegmentStart { .. }
|
||||
| LogEntry::AnnotatedSegmentStart { .. }
|
||||
| LogEntry::UserInput { .. }
|
||||
| LogEntry::AnnotatedUserInput { .. }
|
||||
| LogEntry::SystemItem { .. }
|
||||
| LogEntry::AnnotatedSystemItem { .. }
|
||||
| LogEntry::Invoke { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::{Item, Role};
|
||||
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
|
||||
use agen::{HistoryEntry, Item, Role};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT: usize = 20;
|
||||
@@ -21,14 +22,21 @@ const OVERVIEW_ANCHOR_STRIDE: usize = 8;
|
||||
pub(crate) struct SessionEntryRef(String);
|
||||
|
||||
impl SessionEntryRef {
|
||||
pub(crate) fn new(source_index: usize) -> Self {
|
||||
Self(format!("E{source_index:08}"))
|
||||
pub(crate) fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
|
||||
Self(format!("E{}", entry_id.0))
|
||||
}
|
||||
|
||||
pub(crate) fn parse(value: &str) -> Option<Self> {
|
||||
let reference = Self(value.to_string());
|
||||
reference.source_index()?;
|
||||
Some(reference)
|
||||
let suffix = value.strip_prefix('E')?;
|
||||
if suffix.is_empty()
|
||||
|| suffix.len() > 64
|
||||
|| !suffix
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Self(value.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn as_str(&self) -> &str {
|
||||
@@ -97,6 +105,7 @@ impl ToolPart {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OverviewItem {
|
||||
pub id: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub entry_range: [u64; 2],
|
||||
pub kind: ReferenceKind,
|
||||
pub label: String,
|
||||
@@ -107,6 +116,7 @@ pub(crate) struct OverviewItem {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ReferenceEntry {
|
||||
pub id: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub entry_range: [u64; 2],
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
@@ -132,6 +142,7 @@ pub(crate) struct SearchOptions {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SearchHit {
|
||||
pub id: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
pub tool_name: Option<String>,
|
||||
@@ -143,6 +154,7 @@ pub(crate) struct SearchHit {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum ReadSelector<'a> {
|
||||
Id(&'a str),
|
||||
#[cfg(test)]
|
||||
EntryRange([u64; 2]),
|
||||
}
|
||||
|
||||
@@ -176,6 +188,7 @@ impl Default for ReadOptions {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ReadEntry {
|
||||
pub id: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
pub tool_name: Option<String>,
|
||||
@@ -194,6 +207,7 @@ pub(crate) struct ReadResult {
|
||||
pub(crate) struct SessionEntryEvidence {
|
||||
pub segment_id: String,
|
||||
pub entry_ref: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub entry_range: [u64; 2],
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
@@ -205,26 +219,42 @@ pub(crate) struct SessionEntryEvidence {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SessionCapture {
|
||||
segment_id: String,
|
||||
items: Arc<Vec<Item>>,
|
||||
entries: Arc<Vec<HistoryEntry<SessionHistoryMetadata>>>,
|
||||
overview: Vec<OverviewItem>,
|
||||
index: Vec<ReferenceEntry>,
|
||||
}
|
||||
|
||||
impl SessionCapture {
|
||||
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
|
||||
let entries = items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let mut metadata = SessionHistoryMetadata::legacy_unknown();
|
||||
metadata.entry_id =
|
||||
session_store::LoggedSessionHistoryEntryId(format!("{index:08}"));
|
||||
HistoryEntry::new(item, metadata)
|
||||
})
|
||||
.collect();
|
||||
Self::from_history_entries(segment_id, entries)
|
||||
}
|
||||
|
||||
pub(crate) fn from_history_entries(
|
||||
segment_id: impl Into<String>,
|
||||
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
||||
) -> Self {
|
||||
let segment_id = segment_id.into();
|
||||
let items = Arc::new(items);
|
||||
let entries = Arc::new(entries);
|
||||
let mut overview = Vec::new();
|
||||
let mut index = Vec::new();
|
||||
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
let item = &entry.item;
|
||||
let entry_range = [idx as u64, idx as u64];
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let kind = match role {
|
||||
Role::User => ReferenceKind::User,
|
||||
Role::Assistant => ReferenceKind::Assistant,
|
||||
Role::System => continue,
|
||||
let Some(kind) = message_reference_kind(&entry.annotation.origin, role) else {
|
||||
continue;
|
||||
};
|
||||
let text = content
|
||||
.iter()
|
||||
@@ -233,9 +263,10 @@ impl SessionCapture {
|
||||
.join("");
|
||||
let label = format!("{} message", kind.as_str());
|
||||
let summary = truncate_chars(&text, 240);
|
||||
let id = SessionEntryRef::new(idx);
|
||||
let id = SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id);
|
||||
index.push(ReferenceEntry {
|
||||
id: id.clone(),
|
||||
origin: entry.annotation.origin.clone(),
|
||||
entry_range,
|
||||
kind,
|
||||
tool_part: None,
|
||||
@@ -247,6 +278,7 @@ impl SessionCapture {
|
||||
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
|
||||
overview.push(OverviewItem {
|
||||
id: id.clone(),
|
||||
origin: entry.annotation.origin.clone(),
|
||||
entry_range,
|
||||
kind,
|
||||
label,
|
||||
@@ -260,7 +292,8 @@ impl SessionCapture {
|
||||
} => {
|
||||
let text = format!("{name}\n{arguments}");
|
||||
index.push(ReferenceEntry {
|
||||
id: SessionEntryRef::new(idx),
|
||||
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
|
||||
origin: entry.annotation.origin.clone(),
|
||||
entry_range,
|
||||
kind: ReferenceKind::Tool,
|
||||
tool_part: Some(ToolPart::Input),
|
||||
@@ -286,7 +319,8 @@ impl SessionCapture {
|
||||
content.as_deref().unwrap_or_default(),
|
||||
);
|
||||
index.push(ReferenceEntry {
|
||||
id: SessionEntryRef::new(idx),
|
||||
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
|
||||
origin: entry.annotation.origin.clone(),
|
||||
entry_range,
|
||||
kind: ReferenceKind::Tool,
|
||||
tool_part: Some(ToolPart::Output),
|
||||
@@ -326,7 +360,7 @@ impl SessionCapture {
|
||||
|
||||
Self {
|
||||
segment_id,
|
||||
items,
|
||||
entries,
|
||||
overview,
|
||||
index,
|
||||
}
|
||||
@@ -336,6 +370,14 @@ impl SessionCapture {
|
||||
&self.overview
|
||||
}
|
||||
|
||||
pub(crate) fn source_index_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
|
||||
self.index
|
||||
.iter()
|
||||
.find(|entry| entry.id == *reference)
|
||||
.map(|entry| entry.entry_range[0])
|
||||
.or_else(|| reference.source_index())
|
||||
}
|
||||
|
||||
pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
|
||||
let query = options.query.trim().to_lowercase();
|
||||
let limit = options
|
||||
@@ -346,12 +388,12 @@ impl SessionCapture {
|
||||
let min_entry_index = options
|
||||
.from
|
||||
.as_ref()
|
||||
.and_then(SessionEntryRef::source_index)
|
||||
.and_then(|reference| self.source_index_for_ref(reference))
|
||||
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
|
||||
let max_entry_index = options
|
||||
.through
|
||||
.as_ref()
|
||||
.and_then(SessionEntryRef::source_index)
|
||||
.and_then(|reference| self.source_index_for_ref(reference))
|
||||
.unwrap_or(u64::MAX);
|
||||
let mut skipped = 0usize;
|
||||
let mut hits = Vec::new();
|
||||
@@ -390,6 +432,7 @@ impl SessionCapture {
|
||||
}
|
||||
hits.push(SearchHit {
|
||||
id: entry.id.clone(),
|
||||
origin: entry.origin.clone(),
|
||||
kind: entry.kind,
|
||||
tool_part: entry.tool_part,
|
||||
tool_name: entry.tool_name.clone(),
|
||||
@@ -418,6 +461,7 @@ impl SessionCapture {
|
||||
.iter()
|
||||
.filter(|entry| entry.id.as_str() == id)
|
||||
.collect(),
|
||||
#[cfg(test)]
|
||||
ReadSelector::EntryRange([start, end]) => self
|
||||
.index
|
||||
.iter()
|
||||
@@ -440,13 +484,18 @@ impl SessionCapture {
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(item) = self.items.get(entry.entry_range[0] as usize) else {
|
||||
let Some(item) = self
|
||||
.entries
|
||||
.get(entry.entry_range[0] as usize)
|
||||
.map(|entry| &entry.item)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes));
|
||||
bytes = bytes.saturating_add(text.len());
|
||||
entries.push(ReadEntry {
|
||||
id: entry.id.clone(),
|
||||
origin: entry.origin.clone(),
|
||||
kind: entry.kind,
|
||||
tool_part: entry.tool_part,
|
||||
tool_name: entry.tool_name.clone(),
|
||||
@@ -483,6 +532,7 @@ impl SessionCapture {
|
||||
Some(SessionEntryEvidence {
|
||||
segment_id: self.segment_id.clone(),
|
||||
entry_ref: entry.id.clone(),
|
||||
origin: entry.origin.clone(),
|
||||
entry_range: entry.entry_range,
|
||||
kind: entry.kind,
|
||||
tool_part: entry.tool_part,
|
||||
@@ -493,6 +543,28 @@ impl SessionCapture {
|
||||
}
|
||||
}
|
||||
|
||||
fn message_reference_kind(
|
||||
origin: &WorkerHistoryProvenance,
|
||||
provider_role: &Role,
|
||||
) -> Option<ReferenceKind> {
|
||||
match origin {
|
||||
WorkerHistoryProvenance::HumanInput { .. }
|
||||
| WorkerHistoryProvenance::WorkerInput { .. } => Some(ReferenceKind::User),
|
||||
WorkerHistoryProvenance::ModelOutput { .. } => Some(ReferenceKind::Assistant),
|
||||
WorkerHistoryProvenance::ToolOutput { .. } => Some(ReferenceKind::Tool),
|
||||
WorkerHistoryProvenance::LegacyUnknown => match provider_role {
|
||||
Role::User => Some(ReferenceKind::User),
|
||||
Role::Assistant => Some(ReferenceKind::Assistant),
|
||||
Role::System => None,
|
||||
},
|
||||
// Flow/backend/system content remains out of the observation surface
|
||||
// even when represented with a provider user/system role.
|
||||
WorkerHistoryProvenance::FlowInstruction { .. }
|
||||
| WorkerHistoryProvenance::BackendInstruction { .. }
|
||||
| WorkerHistoryProvenance::DerivedSummary => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
item: &Item,
|
||||
entry: &ReferenceEntry,
|
||||
@@ -561,6 +633,60 @@ fn truncate_chars(text: &str, max_chars: usize) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flow_user_role_is_excluded_while_explicit_human_origin_remains_evidence() {
|
||||
let entries = vec![
|
||||
crate::session_history::history_entry(
|
||||
Item::user_message("trusted flow instruction"),
|
||||
WorkerHistoryProvenance::FlowInstruction {
|
||||
selector: "builtin:coder-review".into(),
|
||||
definition_id: "coder-review".into(),
|
||||
definition_revision: 3,
|
||||
instance_id: "instance".into(),
|
||||
state_id: "implement".into(),
|
||||
},
|
||||
),
|
||||
crate::session_history::history_entry(
|
||||
Item::user_message("remember my preference"),
|
||||
WorkerHistoryProvenance::HumanInput {
|
||||
account_id: "account-1".into(),
|
||||
},
|
||||
),
|
||||
];
|
||||
let capture = SessionCapture::from_history_entries("segment", entries);
|
||||
let overview = capture.overview();
|
||||
assert_eq!(overview.len(), 1);
|
||||
assert!(matches!(
|
||||
overview[0].origin,
|
||||
WorkerHistoryProvenance::HumanInput { .. }
|
||||
));
|
||||
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
|
||||
assert!(evidence.excerpt.ends_with("remember my preference"));
|
||||
assert!(matches!(
|
||||
evidence.origin,
|
||||
WorkerHistoryProvenance::HumanInput { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_logical_ref_survives_retention_and_restore_projection() {
|
||||
let retained = crate::session_history::history_entry(
|
||||
Item::assistant_message("retained"),
|
||||
WorkerHistoryProvenance::ModelOutput {
|
||||
worker: crate::session_history::worker_subject(Default::default()),
|
||||
},
|
||||
);
|
||||
let expected_ref = SessionEntryRef::from_history_entry_id(&retained.annotation.entry_id);
|
||||
let before = SessionCapture::from_history_entries("old", vec![retained.clone()]);
|
||||
let after = SessionCapture::from_history_entries("new", vec![retained]);
|
||||
assert_eq!(before.overview()[0].id, expected_ref);
|
||||
assert_eq!(after.overview()[0].id, expected_ref);
|
||||
assert_eq!(
|
||||
after.evidence_for(expected_ref.as_str()).unwrap().entry_ref,
|
||||
expected_ref
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_contains_user_and_assistant_only() {
|
||||
let view = SessionCapture::new(
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Restore-authoritative metadata for model-visible Worker history.
|
||||
//!
|
||||
//! Agen transports this annotation without interpreting it. Session Log v2
|
||||
//! stores each item and metadata in one typed record; legacy records are
|
||||
//! retained only as explicit `LegacyUnknown` entries.
|
||||
|
||||
use agen::{HistoryEntry, Item};
|
||||
use protocol::Segment;
|
||||
use session_store::{
|
||||
LogEntry, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedWorkerSubject, SegmentId,
|
||||
SessionId,
|
||||
};
|
||||
|
||||
pub type SessionHistoryEntryId = LoggedSessionHistoryEntryId;
|
||||
pub type SessionHistoryMetadata = LoggedSessionHistoryMetadata;
|
||||
pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
|
||||
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
|
||||
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
|
||||
|
||||
pub(crate) fn worker_subject(session_id: SessionId) -> WorkerSubjectSnapshot {
|
||||
WorkerSubjectSnapshot {
|
||||
workspace_id: None,
|
||||
runtime_id: None,
|
||||
worker_id: session_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn metadata(
|
||||
origin: WorkerHistoryProvenance,
|
||||
derivation: Option<SessionHistoryDerivation>,
|
||||
) -> SessionHistoryMetadata {
|
||||
SessionHistoryMetadata {
|
||||
entry_id: SessionHistoryEntryId::new(),
|
||||
origin,
|
||||
derivation,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn history_entry(
|
||||
item: Item,
|
||||
origin: WorkerHistoryProvenance,
|
||||
) -> HistoryEntry<SessionHistoryMetadata> {
|
||||
HistoryEntry::new(item, metadata(origin, None))
|
||||
}
|
||||
|
||||
pub(crate) fn to_logged_history_entry(
|
||||
entry: &HistoryEntry<SessionHistoryMetadata>,
|
||||
) -> LoggedHistoryEntry {
|
||||
LoggedHistoryEntry {
|
||||
item: entry.item.clone().into(),
|
||||
metadata: entry.annotation.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_entry(item: Item) -> HistoryEntry<SessionHistoryMetadata> {
|
||||
HistoryEntry::new(item, SessionHistoryMetadata::legacy_unknown())
|
||||
}
|
||||
|
||||
fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadata> {
|
||||
HistoryEntry::new(Item::from(entry.item.clone()), entry.metadata.clone())
|
||||
}
|
||||
|
||||
/// Rebuild typed Worker history directly from the append-only Session Log.
|
||||
/// Missing legacy metadata is never inferred from role or plaintext.
|
||||
pub(crate) fn restore_history_entries(
|
||||
_session_id: SessionId,
|
||||
_segment_id: SegmentId,
|
||||
entries: &[LogEntry],
|
||||
) -> Result<Vec<HistoryEntry<SessionHistoryMetadata>>, String> {
|
||||
let mut history = Vec::new();
|
||||
for entry in entries {
|
||||
match entry {
|
||||
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
|
||||
history = seed.iter().map(from_logged).collect();
|
||||
}
|
||||
LogEntry::SegmentStart { history: seed, .. } => {
|
||||
history = seed
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Item::from)
|
||||
.map(legacy_entry)
|
||||
.collect();
|
||||
}
|
||||
LogEntry::AnnotatedUserInput { history: input, .. } => {
|
||||
history.extend(input.iter().map(from_logged))
|
||||
}
|
||||
LogEntry::UserInput { segments, .. } => history.push(legacy_entry(Item::user_message(
|
||||
Segment::flatten_to_text(segments),
|
||||
))),
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
|
||||
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
|
||||
history.push(legacy_entry(Item::from(item.clone())));
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
|
||||
entry.item.to_history_item(),
|
||||
entry.metadata.clone(),
|
||||
)),
|
||||
LogEntry::SystemItem { item, .. } => {
|
||||
history.push(legacy_entry(item.to_history_item()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agen::llm_client::RequestConfig;
|
||||
use session_store::LogEntry;
|
||||
|
||||
#[test]
|
||||
fn legacy_user_role_is_not_inferred_as_human_authority() {
|
||||
let entries = vec![LogEntry::UserInput {
|
||||
ts: 1,
|
||||
segments: vec![Segment::text("legacy")],
|
||||
extensions: Vec::new(),
|
||||
}];
|
||||
let restored =
|
||||
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &entries).unwrap();
|
||||
assert!(matches!(
|
||||
restored[0].annotation.origin,
|
||||
WorkerHistoryProvenance::LegacyUnknown
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
|
||||
let session_id = SessionId::now_v7();
|
||||
let projected = vec![
|
||||
history_entry(
|
||||
Item::user_message("flow instructions"),
|
||||
WorkerHistoryProvenance::FlowInstruction {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
definition_id: "coder-review".to_string(),
|
||||
definition_revision: 7,
|
||||
instance_id: "flow-instance".to_string(),
|
||||
state_id: "implement".to_string(),
|
||||
},
|
||||
),
|
||||
history_entry(
|
||||
Item::user_message("implement"),
|
||||
WorkerHistoryProvenance::LegacyUnknown,
|
||||
),
|
||||
];
|
||||
let entries = vec![
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: Vec::new(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 1,
|
||||
segments: vec![
|
||||
Segment::Flow {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
},
|
||||
Segment::text("implement"),
|
||||
],
|
||||
extensions: Vec::new(),
|
||||
history: projected.iter().map(to_logged_history_entry).collect(),
|
||||
},
|
||||
];
|
||||
let restored = restore_history_entries(session_id, SegmentId::now_v7(), &entries).unwrap();
|
||||
assert_eq!(restored, projected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_restore_preserves_logical_ids_across_reboot() {
|
||||
let session_id = SessionId::now_v7();
|
||||
let entry = history_entry(
|
||||
Item::assistant_message("persisted"),
|
||||
WorkerHistoryProvenance::ModelOutput {
|
||||
worker: worker_subject(session_id),
|
||||
},
|
||||
);
|
||||
let log = vec![LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![to_logged_history_entry(&entry)],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}];
|
||||
let first = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
|
||||
let second = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
|
||||
assert_eq!(first[0].annotation.entry_id, entry.annotation.entry_id);
|
||||
assert_eq!(second[0].annotation.entry_id, entry.annotation.entry_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacted_derivation_uses_stable_logical_entry_ids() {
|
||||
let source = history_entry(
|
||||
Item::user_message("source"),
|
||||
WorkerHistoryProvenance::LegacyUnknown,
|
||||
);
|
||||
let summary = HistoryEntry::new(
|
||||
Item::system_message("summary"),
|
||||
metadata(
|
||||
WorkerHistoryProvenance::DerivedSummary,
|
||||
Some(SessionHistoryDerivation {
|
||||
sources: vec![source.annotation.entry_id.clone()],
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
summary.annotation.derivation.unwrap().sources,
|
||||
vec![source.annotation.entry_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Parent-owned registry of direct Internal SubWorker sessions.
|
||||
//! Parent-owned registry of direct Internal Worker sessions.
|
||||
//!
|
||||
//! `SubWorkerSpawn` inserts typed `InternalWorkerSessionHandle`s; List/Send/Stop and
|
||||
//! 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();
|
||||
|
||||
@@ -499,7 +499,10 @@ impl Tool for SubWorkerSpawnTool {
|
||||
InternalWorkerVisibility::ParentClient,
|
||||
Some(child_registry.clone()),
|
||||
Some(Arc::new(move |status| {
|
||||
if status == InternalWorkerSessionStatus::Failed {
|
||||
if matches!(
|
||||
status,
|
||||
InternalWorkerSessionStatus::Failed | InternalWorkerSessionStatus::Stopped
|
||||
) {
|
||||
if let Some(registry) = registry.upgrade() {
|
||||
if let Err(error) = registry.reclaim_internal_scope(&child_name) {
|
||||
tracing::warn!(
|
||||
@@ -1249,7 +1252,7 @@ extract_threshold = 4000
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first_capture.items.iter().any(|item| {
|
||||
assert!(first_capture.entries.iter().map(|entry| &entry.item).any(|item| {
|
||||
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
|
||||
}));
|
||||
|
||||
@@ -1271,7 +1274,7 @@ extract_threshold = 4000
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(latest_capture.items.len() > first_capture.items.len());
|
||||
assert!(latest_capture.entries.len() > first_capture.entries.len());
|
||||
|
||||
fail_requests.store(true, Ordering::SeqCst);
|
||||
send.execute(
|
||||
@@ -1282,16 +1285,16 @@ extract_threshold = 4000
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.session.wait_until_idle().await,
|
||||
InternalWorkerSessionStatus::Failed
|
||||
InternalWorkerSessionStatus::Stopped
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
assert!(
|
||||
spawner_scope.snapshot().is_writable(&workspace_root),
|
||||
"Failed terminal child must release its delegated Workdir session"
|
||||
"Stopped terminal child must release its delegated Workdir session"
|
||||
);
|
||||
assert!(
|
||||
!record.workdir_delegation.is_active(),
|
||||
"failed child must revoke cloned scoped sessions"
|
||||
"stopped child must revoke cloned scoped sessions"
|
||||
);
|
||||
assert!(registry.get_internal("reviewer-child").is_some());
|
||||
|
||||
|
||||
+1600
-361
File diff suppressed because it is too large
Load Diff
@@ -163,7 +163,8 @@ async fn make_worker_with_manifest(
|
||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||
std::mem::forget(pwd_tmp);
|
||||
|
||||
let worker = Engine::new(client);
|
||||
let worker =
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
worker,
|
||||
@@ -204,7 +205,14 @@ fn system_texts_in_sink_session_start(
|
||||
) -> Vec<String> {
|
||||
let (entries, _rx) = worker.sink().subscribe_with_snapshot();
|
||||
for entry in entries.into_iter().rev() {
|
||||
if let session_store::LogEntry::SegmentStart { history, .. } = entry {
|
||||
let history = match entry {
|
||||
session_store::LogEntry::AnnotatedSegmentStart { history, .. } => history
|
||||
.into_iter()
|
||||
.map(|entry| entry.item)
|
||||
.collect::<Vec<_>>(),
|
||||
session_store::LogEntry::SegmentStart { history, .. } => history,
|
||||
_ => continue,
|
||||
};
|
||||
return history
|
||||
.into_iter()
|
||||
.filter_map(|logged| {
|
||||
@@ -226,7 +234,6 @@ fn system_texts_in_sink_session_start(
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -337,7 +344,12 @@ permission = "write"
|
||||
// New segment records forked_from pointing at the source.
|
||||
let new_entries = store.read_all(session_id, new_segment_id).unwrap();
|
||||
match &new_entries[0] {
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
session_id: seg_session,
|
||||
forked_from: Some(origin),
|
||||
..
|
||||
}
|
||||
| LogEntry::SegmentStart {
|
||||
session_id: seg_session,
|
||||
forked_from: Some(origin),
|
||||
..
|
||||
@@ -431,7 +443,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 +457,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 +551,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 +567,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()));
|
||||
@@ -662,7 +728,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",
|
||||
@@ -823,11 +889,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")
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,16 +32,29 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
|
||||
let mut items = Vec::new();
|
||||
for entry in entries {
|
||||
match entry {
|
||||
LogEntry::AnnotatedSegmentStart { history, .. } => {
|
||||
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
|
||||
}
|
||||
LogEntry::SegmentStart { history, .. } => {
|
||||
items.extend(history.into_iter().map(Item::from));
|
||||
}
|
||||
LogEntry::AnnotatedUserInput { history, .. } => {
|
||||
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
|
||||
}
|
||||
LogEntry::UserInput { segments, .. } => {
|
||||
let text = protocol::Segment::flatten_to_text(&segments);
|
||||
items.push(Item::user_message(text));
|
||||
}
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
||||
items.push(Item::from(entry.item));
|
||||
}
|
||||
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
|
||||
items.push(Item::from(item));
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
items.push(entry.item.to_history_item());
|
||||
}
|
||||
LogEntry::SystemItem { item, .. } => {
|
||||
items.push(item.to_history_item());
|
||||
}
|
||||
@@ -51,6 +64,14 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
|
||||
items
|
||||
}
|
||||
|
||||
fn system_item(entry: &LogEntry) -> Option<&session_store::SystemItem> {
|
||||
match entry {
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
|
||||
LogEntry::SystemItem { item, .. } => Some(item),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock LLM Client
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -192,7 +213,8 @@ async fn make_worker_with_pwd_and_manifest(
|
||||
let scope = manifest::Scope::writable(&pwd).unwrap();
|
||||
std::mem::forget(pwd_tmp);
|
||||
|
||||
let worker = Engine::new(client);
|
||||
let worker =
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||
let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
|
||||
let worker = Worker::new(
|
||||
manifest,
|
||||
@@ -784,13 +806,30 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
|
||||
let client = MockClient::sequential(vec![MockResponse::Hang(simple_text_events())]);
|
||||
let worker = make_worker(client).await;
|
||||
let handle = spawn_controller(worker).await;
|
||||
let mut events = handle.subscribe();
|
||||
|
||||
handle
|
||||
.send(Method::run_text("hello in-flight"))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_status(&handle, WorkerStatus::Running).await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
loop {
|
||||
if matches!(
|
||||
events.recv().await,
|
||||
Ok(Event::Status {
|
||||
status: WorkerStatus::Running,
|
||||
})
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("running status event");
|
||||
|
||||
// The Running event is the in-flight visibility fence: the committed
|
||||
// annotated input must already be available to an immediately attaching
|
||||
// subscriber rather than racing behind this status transition.
|
||||
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path())
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -804,10 +843,12 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
|
||||
// Walk the entries, find a `LogEntry::UserInput` and
|
||||
// confirm its segments flatten to our submitted text.
|
||||
let mut found = false;
|
||||
for value in entries {
|
||||
for value in &entries {
|
||||
let entry: session_store::LogEntry =
|
||||
serde_json::from_value(value).expect("LogEntry deserialise");
|
||||
if let session_store::LogEntry::UserInput { segments, .. } = entry {
|
||||
serde_json::from_value(value.clone()).expect("LogEntry deserialise");
|
||||
if let session_store::LogEntry::UserInput { segments, .. }
|
||||
| session_store::LogEntry::AnnotatedUserInput { segments, .. } = entry
|
||||
{
|
||||
let text = protocol::Segment::flatten_to_text(&segments);
|
||||
if text == "hello in-flight" {
|
||||
found = true;
|
||||
@@ -815,7 +856,10 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(found, "snapshot must carry the in-flight UserInput entry");
|
||||
assert!(
|
||||
found,
|
||||
"snapshot must carry the in-flight UserInput entry: {entries:?}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Event::Alert(_) => continue,
|
||||
@@ -1086,7 +1130,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
|
||||
_ => {}
|
||||
},
|
||||
entry = entry_rx.recv() => match entry {
|
||||
Ok(session_store::LogEntry::UserInput { segments, .. }) => {
|
||||
Ok(session_store::LogEntry::UserInput { segments, .. } | session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
|
||||
user_input_segments = Some(segments);
|
||||
if saw_turn_end {
|
||||
break;
|
||||
@@ -1317,11 +1361,8 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
|
||||
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
||||
let saw_notify_in_mirror = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
session_store::LogEntry::SystemItem {
|
||||
item: session_store::SystemItem::Notification { message, .. },
|
||||
..
|
||||
} if message == "turn finished"
|
||||
system_item(e),
|
||||
Some(session_store::SystemItem::Notification { message, .. }) if message == "turn finished"
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
@@ -1463,14 +1504,11 @@ async fn worker_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_
|
||||
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
||||
let saw_worker_event_in_mirror = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
session_store::LogEntry::SystemItem {
|
||||
item: session_store::SystemItem::WorkerEvent {
|
||||
system_item(e),
|
||||
Some(session_store::SystemItem::WorkerEvent {
|
||||
event: protocol::WorkerEvent::TurnEnded { worker_name },
|
||||
..
|
||||
},
|
||||
..
|
||||
} if worker_name == "child"
|
||||
}) if worker_name == "child"
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
@@ -1552,14 +1590,11 @@ async fn worker_event_scope_sub_delegated_while_idle_stays_control_plane_only()
|
||||
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
||||
let saw_scope_event_in_mirror = entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
session_store::LogEntry::SystemItem {
|
||||
item: session_store::SystemItem::WorkerEvent {
|
||||
system_item(entry),
|
||||
Some(session_store::SystemItem::WorkerEvent {
|
||||
event: protocol::WorkerEvent::ScopeSubDelegated { .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
})
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
@@ -2134,9 +2169,13 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
|
||||
for item in items {
|
||||
match item {
|
||||
agen::Item::ToolResult {
|
||||
call_id, summary, ..
|
||||
call_id,
|
||||
summary,
|
||||
disposition,
|
||||
..
|
||||
} if call_id == "call_orphan" => {
|
||||
assert_eq!(summary, "[Interrupted by user]");
|
||||
assert_eq!(summary, "Tool execution outcome unknown");
|
||||
assert_eq!(*disposition, agen::ToolResultDisposition::OutcomeUnknown);
|
||||
saw_synthetic_tool_result = true;
|
||||
}
|
||||
agen::Item::Message { role, content, .. } if *role == agen::Role::System => {
|
||||
@@ -2327,8 +2366,11 @@ async fn paused_cancel_abandons_resume_and_next_input_is_fresh_run() {
|
||||
assert!(
|
||||
items.iter().any(|item| matches!(
|
||||
item,
|
||||
agen::Item::ToolResult { call_id, summary, .. }
|
||||
if call_id == "call_cancelled" && summary == "[Interrupted by user]"
|
||||
agen::Item::ToolResult {
|
||||
call_id,
|
||||
disposition: agen::ToolResultDisposition::OutcomeUnknown,
|
||||
..
|
||||
} if call_id == "call_cancelled"
|
||||
)),
|
||||
"paused cancel should close orphan tool_use before future requests: {items:?}"
|
||||
);
|
||||
@@ -2373,7 +2415,8 @@ async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bo
|
||||
let entry: session_store::LogEntry =
|
||||
serde_json::from_value(value).expect("LogEntry deserialise");
|
||||
match entry {
|
||||
session_store::LogEntry::UserInput { segments, .. } => {
|
||||
session_store::LogEntry::UserInput { segments, .. }
|
||||
| session_store::LogEntry::AnnotatedUserInput { segments, .. } => {
|
||||
protocol::Segment::flatten_to_text(&segments).contains(needle)
|
||||
}
|
||||
_ => false,
|
||||
|
||||
@@ -188,7 +188,8 @@ async fn make_worker(
|
||||
let pwd = pwd_tmp.path().to_path_buf();
|
||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||
|
||||
let mut worker = Engine::new(client);
|
||||
let mut worker =
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||
worker.register_tool(big_content_tool_definition(tool_name));
|
||||
|
||||
let worker = Worker::new(
|
||||
@@ -460,7 +461,8 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() {
|
||||
// protected token budget covers the only user message). That is enough to drive
|
||||
// the failure path: at least one metric attempts to write.
|
||||
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
|
||||
let worker = Engine::new(client);
|
||||
let worker =
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
worker,
|
||||
@@ -536,7 +538,8 @@ permission = "write"
|
||||
let pwd_tmp = tempfile::tempdir().unwrap();
|
||||
let pwd = pwd_tmp.path().to_path_buf();
|
||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||
let worker = Engine::new(client);
|
||||
let worker =
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
worker,
|
||||
|
||||
@@ -130,7 +130,8 @@ async fn make_worker_with_body(
|
||||
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
|
||||
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
|
||||
|
||||
let worker = Engine::new(client);
|
||||
let worker =
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
worker,
|
||||
|
||||
@@ -357,6 +357,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
|
||||
@@ -46,6 +48,7 @@ tracing.workspace = true
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
url.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
zeroize.workspace = true
|
||||
webauthn-rs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::records::{
|
||||
TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary,
|
||||
TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView,
|
||||
TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage,
|
||||
summarize_body, truncate_body, validate_project_id,
|
||||
summarize_body, truncate_body,
|
||||
};
|
||||
use crate::store::{
|
||||
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
||||
@@ -633,7 +633,15 @@ impl SqliteWorkspaceAuthority {
|
||||
predicates.push(format!("o.updated_at<{value}"));
|
||||
}
|
||||
if let Some(value) = &query.linked_ticket_id {
|
||||
let value = bind(SqlValue::Text(value.clone()));
|
||||
let resolved = self
|
||||
.store
|
||||
.resolve_resource_reference(
|
||||
&self.workspace_id,
|
||||
WorkspaceResourceKind::Ticket,
|
||||
value,
|
||||
)?
|
||||
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))?;
|
||||
let value = bind(SqlValue::Text(resolved));
|
||||
predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})"));
|
||||
}
|
||||
let relevance_rank = if let Some(text) =
|
||||
@@ -1237,6 +1245,10 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
.into_iter()
|
||||
.map(|link| link.ticket_id)
|
||||
.collect::<Vec<_>>();
|
||||
let linked_ticket_keys = linked_tickets
|
||||
.iter()
|
||||
.map(|ticket_id| self.resource_key(WorkspaceResourceKind::Ticket, ticket_id))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let body_md = record.body_md.clone();
|
||||
let objective = ObjectiveSummary {
|
||||
resource_key: self
|
||||
@@ -1253,6 +1265,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
items.push(objective_query_item(
|
||||
objective,
|
||||
linked_tickets,
|
||||
linked_ticket_keys,
|
||||
query.query.as_deref(),
|
||||
&body_md,
|
||||
));
|
||||
@@ -1339,9 +1352,19 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> {
|
||||
validate_objective_title(&input.title)?;
|
||||
validate_objective_state(&input.state)?;
|
||||
for ticket_id in &input.linked_tickets {
|
||||
validate_project_id(ticket_id)?;
|
||||
}
|
||||
let linked_tickets = input
|
||||
.linked_tickets
|
||||
.iter()
|
||||
.map(|ticket_reference| {
|
||||
self.store
|
||||
.resolve_resource_reference(
|
||||
&self.workspace_id,
|
||||
WorkspaceResourceKind::Ticket,
|
||||
ticket_reference,
|
||||
)?
|
||||
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let now = now_rfc3339();
|
||||
let objective_id = allocate_record_id(
|
||||
unix_epoch_millis_now().map_err(|err| {
|
||||
@@ -1367,8 +1390,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
updated_at: now.clone(),
|
||||
};
|
||||
self.store.upsert_objective(&record)?;
|
||||
let links = input
|
||||
.linked_tickets
|
||||
let links = linked_tickets
|
||||
.into_iter()
|
||||
.map(|ticket_id| ObjectiveTicketLinkRecord {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
@@ -2283,12 +2305,18 @@ fn ticket_query_item(
|
||||
.iter()
|
||||
.map(|objective| objective.id.clone())
|
||||
.collect(),
|
||||
linked_objective_keys: detail
|
||||
.linked_objectives
|
||||
.iter()
|
||||
.map(|objective| objective.resource_key.clone())
|
||||
.collect(),
|
||||
relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(),
|
||||
blocker_count: detail.relations.blockers.len(),
|
||||
unresolved_blocker_count: detail.relations.blockers.len(),
|
||||
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
|
||||
evidence: detail.evidence.clone(),
|
||||
merge_request: detail.merge_request.clone(),
|
||||
current_coder: detail.current_coder.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2399,6 +2427,7 @@ fn ticket_item_after_cursor(
|
||||
fn objective_query_item(
|
||||
objective: ObjectiveSummary,
|
||||
linked_tickets: Vec<String>,
|
||||
linked_ticket_keys: Vec<String>,
|
||||
text: Option<&str>,
|
||||
body_md: &str,
|
||||
) -> ObjectiveQueryItem {
|
||||
@@ -2426,6 +2455,7 @@ fn objective_query_item(
|
||||
snippet,
|
||||
linked_ticket_count: linked_tickets.len(),
|
||||
linked_tickets,
|
||||
linked_ticket_keys,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3308,16 +3338,21 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert!(!objective.revision.is_empty());
|
||||
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
|
||||
assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
|
||||
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
|
||||
let objective_query = authority
|
||||
.query_objectives(ObjectiveQueryRequest {
|
||||
query: Some("Control plane".to_string()),
|
||||
linked_ticket_id: Some("00000000001J2".to_string()),
|
||||
linked_ticket_id: Some(linked_ticket_key.clone()),
|
||||
limit: Some(1),
|
||||
..ObjectiveQueryRequest::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(objective_query.items.len(), 1);
|
||||
assert_eq!(objective_query.items[0].linked_ticket_count, 1);
|
||||
assert_eq!(
|
||||
objective_query.items[0].linked_ticket_keys,
|
||||
vec![linked_ticket_key]
|
||||
);
|
||||
assert_eq!(objective_query.page.limit, 1);
|
||||
let body_query = authority
|
||||
.query_objectives(ObjectiveQueryRequest {
|
||||
@@ -3410,7 +3445,7 @@ VALUES ('workspace-test', 'ticket', 3);
|
||||
title: "Create Objective".to_string(),
|
||||
body_md: "Alpha body".to_string(),
|
||||
state: "active".to_string(),
|
||||
linked_tickets: vec!["00000000001J2".to_string()],
|
||||
linked_tickets: vec!["T-1".to_string()],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(created.title, "Create Objective");
|
||||
@@ -3436,14 +3471,14 @@ VALUES ('workspace-test', 'ticket', 3);
|
||||
assert_eq!(state.state, "paused");
|
||||
assert_eq!(
|
||||
authority
|
||||
.link_objective_ticket(&created.id, "00000000001J3")
|
||||
.link_objective_ticket(&created.id, "T-2")
|
||||
.unwrap()
|
||||
.linked_tickets,
|
||||
vec!["00000000001J2", "00000000001J3"]
|
||||
);
|
||||
assert_eq!(
|
||||
authority
|
||||
.unlink_objective_ticket(&created.id, "00000000001J2")
|
||||
.unlink_objective_ticket(&created.id, "T-1")
|
||||
.unwrap()
|
||||
.linked_tickets,
|
||||
vec!["00000000001J3"]
|
||||
|
||||
@@ -25,8 +25,9 @@ use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
||||
use worker_runtime::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
||||
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
|
||||
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest,
|
||||
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
|
||||
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
|
||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
WorkingDirectorySummary, WorkspaceApiRef,
|
||||
};
|
||||
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||
#[cfg(test)]
|
||||
@@ -39,11 +40,11 @@ use worker_runtime::execution::WorkerExecutionRunState;
|
||||
use worker_runtime::fs_store::FsRuntimeStoreOptions;
|
||||
use worker_runtime::http_server::{
|
||||
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
|
||||
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest,
|
||||
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
|
||||
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
|
||||
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
|
||||
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
||||
RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse,
|
||||
RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse,
|
||||
RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse,
|
||||
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
|
||||
RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
||||
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
||||
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
|
||||
};
|
||||
@@ -818,6 +819,16 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
fn authorize_working_directory_repository_access(
|
||||
&self,
|
||||
_request: WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> std::result::Result<(), Error> {
|
||||
Err(Error::InvalidInput(
|
||||
"Runtime does not support working directory Repository access authorization"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||
RuntimeList::new(Vec::new(), Vec::new())
|
||||
}
|
||||
@@ -1391,6 +1402,23 @@ impl RuntimeRegistry {
|
||||
Ok(runtime.create_working_directory(request))
|
||||
}
|
||||
|
||||
pub fn authorize_working_directory_repository_access(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
request: WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> Result<(), RuntimeRegistryError> {
|
||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||
validate_backend_identifier("working_directory_id", &request.working_directory_id)?;
|
||||
let runtime = self.runtime(runtime_id)?;
|
||||
runtime
|
||||
.authorize_working_directory_repository_access(request)
|
||||
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "working_directory_repository_access_failed".to_string(),
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_working_directories(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
@@ -3179,6 +3207,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn authorize_working_directory_repository_access(
|
||||
&self,
|
||||
request: WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> std::result::Result<(), Error> {
|
||||
self.post_json::<_, RuntimeHttpRepositoryAccessResponse>(
|
||||
"/v1/working-directories/repository-access",
|
||||
&request,
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
|
||||
}
|
||||
|
||||
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
|
||||
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
|
||||
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
|
||||
@@ -4386,7 +4426,7 @@ mod tests {
|
||||
let handle = bundle.profile_source_archive_handle.as_ref().unwrap();
|
||||
assert!(bundle.profile_source_archive.is_none());
|
||||
let response = broker
|
||||
.fetch_profile_source_archive(worker_runtime::resource::BackendResourceFetchRequest {
|
||||
.fetch_resource(worker_runtime::resource::BackendResourceFetchRequest {
|
||||
handle: handle.clone(),
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: None,
|
||||
|
||||
@@ -20,6 +20,7 @@ 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;
|
||||
@@ -112,6 +113,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}")]
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use workspace_api::{
|
||||
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary,
|
||||
ObjectiveSummary, QueryPage,
|
||||
};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const SUMMARY_BODY_LIMIT: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -387,12 +384,16 @@ pub struct TicketQueryItem {
|
||||
pub snippet: Option<String>,
|
||||
pub matching_event: Option<TicketEvidenceEvent>,
|
||||
pub linked_objective_ids: Vec<String>,
|
||||
#[ts(skip)]
|
||||
pub linked_objective_keys: Vec<String>,
|
||||
pub relation_count: usize,
|
||||
pub blocker_count: usize,
|
||||
pub unresolved_blocker_count: usize,
|
||||
pub unresolved_review_count: usize,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
#[ts(skip)]
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -435,6 +436,7 @@ pub struct ObjectiveQueryItem {
|
||||
pub snippet: Option<String>,
|
||||
pub linked_ticket_count: usize,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub linked_ticket_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -521,10 +523,6 @@ mod typescript_tests {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
|
||||
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_body(body: &str) -> String {
|
||||
let summary = body
|
||||
.lines()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user