81 Commits
Author SHA1 Message Date
Hare 374449e663 feat: add provenance-aware worker history 2026-08-27 12:51:01 +09:00
Hare 0496cd907b fix: use Ticket resource keys in handoffs 2026-08-27 12:24:19 +09:00
Hare 060f280fdf fix: allow parent commands during write delegation 2026-08-27 11:27:27 +09:00
Hare 7aaf189247 fix: scope delegated commands by cwd 2026-08-27 11:11:06 +09:00
Hare 63306cf017 feat: integrate runtime Git cache materialization 2026-08-27 08:30:38 +09:00
Hare 08be5e85e4 fix: preserve command sessions across access refresh 2026-08-27 08:09:22 +09:00
Hare 0ab15aa227 chore: merge hare/develop into develop 2026-08-26 19:44:31 +09:00
Hare 29c2fb8e06 fix: refresh repository access for worker workdirs 2026-08-26 19:27:10 +09:00
Hare 4ebc465e8d fix: constrain repository SSH commands to authorized source 2026-08-26 19:02:20 +09:00
Hare 4b132a21e9 fix: bind repository SSH secrets to one-shot resources 2026-08-26 18:28:22 +09:00
Hare b644971d45 fix: preauthorize repository access without persisting secrets 2026-08-26 17:23:43 +09:00
Hare df34533765 fix: broker read-only repository SSH operations 2026-08-26 16:48:37 +09:00
Hare 6f4efb36bb fix: order optional feature service bindings 2026-08-26 16:28:54 +09:00
Hare 108d5b14d7 fix: withhold write-capable credentials from read-only sessions 2026-08-26 16:23:48 +09:00
Hare f633b86b35 chore: integrate current develop for review 2026-08-26 16:12:48 +09:00
Hare 1873e18f8e fix: enforce attachment access transitions 2026-08-26 16:10:39 +09:00
Hare 3cdcbb47bf fix: preserve selector retries and workdir observations 2026-08-26 16:05:14 +09:00
Hare aaa9c7987c fix: reject ambiguous feature service providers 2026-08-26 16:01:06 +09:00
Hare 048007a042 test: complete tool output fixture 2026-08-26 15:47:34 +09:00
Hare cb35b40b9d feat: plan feature service installation 2026-08-26 15:47:27 +09:00
Hare 3b71fe03b4 fix: include merge request status regression in web tests 2026-08-26 15:27:19 +09:00
Hare 471db64bcc fix: preserve repository access across retries 2026-08-26 15:21:52 +09:00
Hare 5e2234763c fix: distinguish completed review requests in merge request status 2026-08-26 15:20:13 +09:00
Hare 1d140be715 feat: align merge request review workflow with selector refs 2026-08-26 15:03:26 +09:00
Hare ffb2a34ae5 fix: enforce repository access and cache boundaries 2026-08-26 14:39:37 +09:00
Hare ccf7de1a55 fix: preserve run budget across segment forks 2026-08-26 14:13:43 +09:00
Hare ccf3c80d29 fix: clear abandoned run budget before compaction 2026-08-26 14:03:07 +09:00
Hare 3a3c89e0b4 feat: materialize repositories through runtime Git cache 2026-08-26 13:54:25 +09:00
Hare 17c629136a fix: scope max turns to logical runs 2026-08-26 13:47:35 +09:00
Hare 52a5c4141f chore: merge current develop into Ticket source 2026-08-26 12:24:14 +09:00
Hare c9ba27c333 fix: update RepositoryRecord test URI access 2026-08-26 11:50:41 +09:00
Hare 46f6e2c58b fix: complete Repository access management contracts 2026-08-26 11:38:23 +09:00
Hare d1f47e5a22 fix: align Repository access DCDL fields 2026-08-26 11:06:19 +09:00
Hare 65de94bad3 feat: expose compaction as an internal worker 2026-08-26 10:39:43 +09:00
Hare 4e935c6203 test: refresh clean-build fixtures and assertions 2026-08-26 10:16:18 +09:00
Hare eac07cf5a8 fix: provide subworker control service 2026-08-26 10:16:10 +09:00
Hare 260259d461 fix: return ticket queue outcomes to web clients 2026-08-26 10:16:03 +09:00
Hare a9fb092834 feat: add Repository access settings UI 2026-08-26 08:51:23 +09:00
Hare 3a21a68792 feat: add Workspace Repository SSH secret authority 2026-08-26 08:51:15 +09:00
Hare 9d572d18bc fix: accept reserved workers during initial flow resolution 2026-08-26 07:52:04 +09:00
Hare f7852e8034 fix: remove misleading runtime capability projections 2026-08-26 06:19:33 +09:00
Hare 8c075de147 fix: allow remote runtime workdir creation 2026-08-26 05:13:21 +09:00
Hare 864367f4f5 fix: remove repository-local server configuration paths 2026-08-26 03:56:35 +09:00
Hare 33db2ea7f4 fix: unify browser origin configuration 2026-08-26 02:37:14 +09:00
Hare 8396d09891 fix: limit coder ticket comments to handoffs 2026-08-25 13:21:47 +09:00
Hare bf7171924d feat: integrate dependency queue planning 2026-08-25 13:08:09 +09:00
Hare 097c363fbc fix: block internal dependency cycles in projections 2026-08-25 12:54:47 +09:00
Hare 7dd8809e38 fix: fail closed without queue target authority 2026-08-25 12:44:03 +09:00
Hare 1749757036 fix: align queue eligibility with target authority 2026-08-25 12:24:31 +09:00
Hare 5857e6121c fix: preserve dependency queue atomicity 2026-08-25 12:05:43 +09:00
Hare a41147916b fix: confirm queue closures across clients 2026-08-25 11:51:40 +09:00
Hare cabe38db1d fix: align queue projections with dependency closure 2026-08-25 11:19:27 +09:00
Hare f079479160 feat: queue ready dependency closures atomically 2026-08-25 10:53:31 +09:00
Hare 87e160a01a test: track current migration version in dry run 2026-08-25 10:29:48 +09:00
Hare f94d829bf8 feat: integrate hare/develop 2026-08-25 10:29:41 +09:00
Hare 9a05bfa0c3 feat: allow ticket implementation cancellation 2026-08-25 09:52:21 +09:00
Hare c1d46859a3 fix: block cleanup for assigned workers 2026-08-25 09:39:33 +09:00
Hare 87ecbcb113 fix: validate non-worker ticket assignments 2026-08-25 08:09:31 +09:00
Hare 1fb2949561 fix: give reviewer write-scoped command tools 2026-08-25 04:45:17 +09:00
Hare 11be777fc0 fix: verify signed Workspace query targets 2026-08-25 01:12:24 +09:00
Hare ff94161fc0 test: authenticate workdir route fixtures 2026-08-25 00:54:28 +09:00
Hare c2ab9a950f feat: integrate Workspace API authorization 2026-08-25 00:54:13 +09:00
Hare 8e26a0f5a8 chore: merge latest develop into Ticket source
# Conflicts:
#	crates/workspace-server/src/store.rs
2026-08-24 23:24:12 +09:00
Hare d1c15ee295 fix: protect direct auth mutation routes 2026-08-24 14:58:34 +09:00
Hare 00a96234c6 fix: enforce CSRF on server mutations 2026-08-24 14:50:15 +09:00
Hare fc3b663510 fix: authorize Runtime profile archive fetches 2026-08-24 14:39:16 +09:00
Hare e4e045d059 fix: preserve Workdir provider rejection codes 2026-08-24 14:30:34 +09:00
Hare 436feaf33d fix: close remaining Workspace auth gaps 2026-08-24 14:28:44 +09:00
Hare 29f450b962 fix: map missing default Runtime to bad request 2026-08-24 14:16:38 +09:00
Hare db343893c8 fix: secure HTTPS browser session cookies 2026-08-24 14:11:51 +09:00
Hare 554906ec02 fix: treat legacy Runtime config as unconfigured 2026-08-24 14:07:42 +09:00
Hare 18c37f4842 fix: persist Workdir Runtime failure classifications 2026-08-24 13:59:52 +09:00
Hare 83382b824a fix: protect legacy Workspace API routes 2026-08-24 13:56:46 +09:00
Hare 6203316aa1 fix: defer local repository validation to runtimes 2026-08-24 13:46:02 +09:00
Hare d57b4d1d5e fix: align ticket queue projection test 2026-08-24 13:42:39 +09:00
Hare 379ae214fc feat: queue tickets with dependency context 2026-08-24 13:40:33 +09:00
Hare 163a403636 feat: support typed remote repository sources 2026-08-24 13:35:19 +09:00
Hare 53edaadc3a feat: authenticate Workspace API requests 2026-08-24 13:32:55 +09:00
Hare 3c2664c3ce feat: sign Runtime workspace requests 2026-08-24 13:32:49 +09:00
Hare d9048954a5 fix: reject unsupported Workdir runtimes before delegation 2026-08-24 13:24:59 +09:00
Hare 4f84dfd73f feat: resolve default Runtime for Workdir creation 2026-08-24 13:15:18 +09:00
155 changed files with 21765 additions and 4055 deletions
Generated
+523 -1
View File
@@ -26,6 +26,16 @@ dependencies = [
"pom", "pom",
] ]
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]] [[package]]
name = "aes" name = "aes"
version = "0.8.4" version = "0.8.4"
@@ -37,6 +47,20 @@ dependencies = [
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
] ]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "agen" name = "agen"
version = "0.2.1" version = "0.2.1"
@@ -326,6 +350,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@@ -338,6 +368,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "base64urlsafedata" name = "base64urlsafedata"
version = "0.5.5" version = "0.5.5"
@@ -349,6 +385,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bcrypt-pbkdf"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
dependencies = [
"blowfish",
"pbkdf2",
"sha2 0.10.9",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.5.3" version = "0.5.3"
@@ -403,6 +450,16 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.12.1" version = "1.12.1"
@@ -435,6 +492,12 @@ version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -495,6 +558,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -654,6 +728,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.10.2" version = "0.10.2"
@@ -937,6 +1017,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -966,6 +1058,41 @@ dependencies = [
"phf 0.11.3", "phf 0.11.3",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
@@ -1056,6 +1183,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"zeroize",
]
[[package]] [[package]]
name = "der-parser" name = "der-parser"
version = "9.0.0" version = "9.0.0"
@@ -1114,7 +1251,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.7", "crypto-common 0.1.7",
"subtle",
] ]
[[package]] [[package]]
@@ -1124,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [ dependencies = [
"block-buffer 0.12.0", "block-buffer 0.12.0",
"const-oid", "const-oid 0.10.2",
"crypto-common 0.2.1", "crypto-common 0.2.1",
] ]
@@ -1175,12 +1314,66 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"sha2 0.10.9",
"subtle",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest 0.10.7",
"ff",
"generic-array",
"group",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "embedded-io" name = "embedded-io"
version = "0.4.0" version = "0.4.0"
@@ -1284,6 +1477,22 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "filedescriptor" name = "filedescriptor"
version = "0.8.3" version = "0.8.3"
@@ -1526,6 +1735,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@@ -1568,6 +1778,16 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.33.0" version = "0.33.0"
@@ -1636,6 +1856,17 @@ dependencies = [
"memmap2", "memmap2",
] ]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.13"
@@ -1724,6 +1955,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.26.0" version = "0.26.0"
@@ -2212,6 +2452,9 @@ name = "lazy_static"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
@@ -2672,6 +2915,22 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.5",
"smallvec",
"zeroize",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.1"
@@ -2698,6 +2957,16 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -2705,6 +2974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm",
] ]
[[package]] [[package]]
@@ -2765,6 +3035,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.76" version = "0.10.76"
@@ -2818,6 +3094,44 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "p521"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
dependencies = [
"base16ct",
"ecdsa",
"elliptic-curve",
"primeorder",
"rand_core 0.6.4",
"sha2 0.10.9",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2847,6 +3161,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.10.7",
]
[[package]] [[package]]
name = "pdf-extract" name = "pdf-extract"
version = "0.10.0" version = "0.10.0"
@@ -2864,6 +3187,15 @@ dependencies = [
"unicode-normalization", "unicode-normalization",
] ]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -3009,6 +3341,27 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
@@ -3021,6 +3374,29 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "pom" name = "pom"
version = "1.1.0" version = "1.1.0"
@@ -3101,6 +3477,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -3525,6 +3910,16 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3539,6 +3934,27 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"sha2 0.10.9",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.37.0" version = "0.37.0"
@@ -3745,6 +4161,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "secrets" name = "secrets"
version = "0.1.0" version = "0.1.0"
@@ -4060,6 +4490,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.9" version = "0.3.9"
@@ -4103,6 +4543,72 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "ssh-cipher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
dependencies = [
"aes",
"aes-gcm",
"cbc",
"chacha20",
"cipher",
"ctr",
"poly1305",
"ssh-encoding",
"subtle",
]
[[package]]
name = "ssh-encoding"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
dependencies = [
"base64ct",
"pem-rfc7468",
"sha2 0.10.9",
]
[[package]]
name = "ssh-key"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"bcrypt-pbkdf",
"ed25519-dalek",
"p256",
"p384",
"p521",
"rand_core 0.6.4",
"rsa",
"sec1",
"sha2 0.10.9",
"signature",
"ssh-cipher",
"ssh-encoding",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -4934,6 +5440,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"
@@ -6131,9 +6647,12 @@ dependencies = [
"tokio-tungstenite 0.29.0", "tokio-tungstenite 0.29.0",
"toml", "toml",
"tower", "tower",
"url",
"uuid", "uuid",
"workdir", "workdir",
"worker", "worker",
"workspace-api",
"zeroize",
] ]
[[package]] [[package]]
@@ -6257,11 +6776,13 @@ dependencies = [
"project-record", "project-record",
"protocol", "protocol",
"reqwest", "reqwest",
"ring",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sha2 0.11.0", "sha2 0.11.0",
"ssh-key",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket", "ticket",
@@ -6278,6 +6799,7 @@ dependencies = [
"worker", "worker",
"worker-runtime", "worker-runtime",
"workspace-api", "workspace-api",
"zeroize",
] ]
[[package]] [[package]]
+2
View File
@@ -115,6 +115,7 @@ tar = "0.4"
rusqlite = { version = "0.37", features = ["backup", "bundled"] } rusqlite = { version = "0.37", features = ["backup", "bundled"] }
ring = "0.17.14" ring = "0.17.14"
sha2 = "0.11" sha2 = "0.11"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
tempfile = "3.27" tempfile = "3.27"
thiserror = "2.0" thiserror = "2.0"
tokio = "1.52" tokio = "1.52"
@@ -124,4 +125,5 @@ toml = "1.1"
tracing = "0.1" tracing = "0.1"
url = "2.5" url = "2.5"
uuid = "1.23" uuid = "1.23"
zeroize = "1"
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
+1 -1
View File
@@ -21,7 +21,7 @@ services:
- "8787" - "8787"
volumes: volumes:
- server-data:/server-data - server-data:/server-data
- ./docker/workspace:/workspace:ro - /etc/yoi/server.toml:/server-config/server.toml:ro
webui: webui:
image: yoi-webui:latest image: yoi-webui:latest
+5 -4
View File
@@ -21,20 +21,21 @@ agen = { version = "0.2.1", features = ["codex"] }
## Quick start ## Quick start
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns. Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), keep conversation history in your application, then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
```no_run ```no_run
use agen::{Engine, EngineError}; use agen::{Engine, EngineError, History};
use agen::llm_client::LlmClient; use agen::llm_client::LlmClient;
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> { async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
let mut history = History::new();
let output = Engine::new(client) let output = Engine::new(client)
.system_prompt("You are a concise assistant.") .system_prompt("You are a concise assistant.")
.run("Explain typed state in one sentence.") .run(&mut history, "Explain typed state in one sentence.")
.await?; .await?;
let mut engine = output.engine; let mut engine = output.engine;
let _result = engine.run("Give a Rust example.").await?; let _result = engine.run(&mut history, "Give a Rust example.").await?;
Ok(()) Ok(())
} }
``` ```
+3 -2
View File
@@ -4,7 +4,7 @@
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme}; use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
use agen::llm_client::transport::{HttpTransport, ResolvedAuth}; use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
use agen::{Engine, EngineResult}; use agen::{Engine, EngineResult, History};
use std::time::Duration; use std::time::Duration;
#[tokio::main] #[tokio::main]
@@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let base_url = scheme.default_base_url().to_string(); let base_url = scheme.default_base_url().to_string();
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap); let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history = History::new();
println!("🚀 Starting Engine..."); println!("🚀 Starting Engine...");
println!("💡 Will cancel after 2 seconds\n"); println!("💡 Will cancel after 2 seconds\n");
@@ -45,7 +46,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("📡 Sending request to LLM..."); println!("📡 Sending request to LLM...");
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await { match engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
Ok(out) => match out.result { Ok(out) => match out.result {
EngineResult::Finished => println!("✅ Task completed normally"), EngineResult::Finished => println!("✅ Task completed normally"),
EngineResult::Paused => println!("⏸️ Task paused"), EngineResult::Paused => println!("⏸️ Task paused"),
+6 -4
View File
@@ -39,7 +39,7 @@ use tracing::info;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use agen::{ use agen::{
Engine, Engine, History,
interceptor::{Interceptor, PostToolAction, ToolResultInfo}, interceptor::{Interceptor, PostToolAction, ToolResultInfo},
llm_client::{ llm_client::{
LlmClient, LlmClient,
@@ -474,9 +474,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
engine.set_interceptor(ToolResultPrinterPolicy::new(tool_call_names)); engine.set_interceptor(ToolResultPrinterPolicy::new(tool_call_names));
let mut history = History::new();
// One-shot mode // One-shot mode
if let Some(prompt) = args.prompt { if let Some(prompt) = args.prompt {
match engine.run(&prompt).await { match engine.run(&mut history, &prompt).await {
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
eprintln!("\n❌ Error: {}", e); eprintln!("\n❌ Error: {}", e);
@@ -500,7 +502,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(()); return Ok(());
} }
let mut locked = match engine.run(first_input).await { let mut locked = match engine.run(&mut history, first_input).await {
Ok(out) => out.engine, Ok(out) => out.engine,
Err(e) => { Err(e) => {
eprintln!("\n❌ Error: {}", e); eprintln!("\n❌ Error: {}", e);
@@ -525,7 +527,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
break; break;
} }
match locked.run(input).await { match locked.run(&mut history, input).await {
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
eprintln!("\n❌ Error: {}", e); eprintln!("\n❌ Error: {}", e);
+249 -103
View File
@@ -7,7 +7,7 @@ use tokio::sync::mpsc;
use tracing::{debug, info, trace, warn}; use tracing::{debug, info, trace, warn};
use crate::{ use crate::{
Item, History, HistoryEntry, Item,
callback::{ callback::{
ClosureMetaHandler, ClosureTextBlockHandler, ClosureThinkingBlockHandler, ClosureMetaHandler, ClosureTextBlockHandler, ClosureThinkingBlockHandler,
ClosureToolUseBlockHandler, TextBlockScope, ThinkingBlockScope, ToolUseBlockScope, ClosureToolUseBlockHandler, TextBlockScope, ThinkingBlockScope, ToolUseBlockScope,
@@ -91,9 +91,9 @@ pub enum EngineResult {
/// Result of [`Engine::run`] or [`Engine::resume`]. /// Result of [`Engine::run`] or [`Engine::resume`].
/// ///
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome. /// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
pub struct EngineRunOutput<C: LlmClient> { pub struct EngineRunOutput<C: LlmClient, A = ()> {
/// The Engine, now in Locked state. /// The Engine, now in Locked state.
pub engine: Engine<C, Locked>, pub engine: Engine<C, Locked, A>,
/// Outcome of the turn. /// Outcome of the turn.
pub result: EngineResult, pub result: EngineResult,
} }
@@ -113,29 +113,31 @@ const MAX_STREAM_CONTINUATIONS: u32 = 3;
/// ///
/// # State Transitions (Type-state) /// # State Transitions (Type-state)
/// ///
/// - [`Mutable`]: Initial state. System prompt, history, and tools can be freely edited. /// - [`Mutable`]: Initial state. System prompt and tools can be edited; history is caller-owned.
/// - [`Locked`]: Cache-protected state. Prefix context is immutable; only `run()` / `resume()` are available. /// - [`Locked`]: Cache-protected state. Prefix context is immutable; only `run()` / `resume()` are available.
/// ///
/// Calling `run()` on a `Mutable` Engine consumes it and returns a /// Calling `run()` on a `Mutable` Engine consumes it and returns a
/// `Locked` Engine together with the result. This ensures the /// `Locked` Engine together with the result. The engine borrows the caller's
/// cache prefix is fixed for optimal KV cache hit rate. /// [`History`](crate::History) only while running, so host annotations stay with
/// the host-owned history and are never projected to providers.
/// ///
/// ```ignore /// ```ignore
/// let mut history = History::new();
/// let mut engine = Engine::new(client) /// let mut engine = Engine::new(client)
/// .system_prompt("You are a helpful assistant."); /// .system_prompt("You are a helpful assistant.");
/// engine.register_tool(my_tool); /// engine.register_tool(my_tool);
/// ///
/// // Mutable::run() consumes self → EngineRunOutput { engine: Locked, result } /// // Mutable::run() consumes self → EngineRunOutput { engine: Locked, result }
/// let out = engine.run("Hello").await?; /// let out = engine.run(&mut history, "Hello").await?;
/// let mut engine = out.engine; /// let mut engine = out.engine;
/// ///
/// // Locked::run() borrows &mut self /// // Locked::run() borrows &mut self
/// engine.run("Follow-up").await?; /// engine.run(&mut history, "Follow-up").await?;
/// ///
/// // To edit between turns, unlock back to Mutable /// // To edit between turns, unlock back to Mutable
/// let mut engine = engine.unlock(); /// let mut engine = engine.unlock();
/// engine.truncate_history(5); /// history.truncate(5);
/// let out = engine.run("Continue").await?; /// let out = engine.run(&mut history, "Continue").await?;
/// let mut engine = out.engine; /// let mut engine = out.engine;
/// ``` /// ```
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -155,7 +157,7 @@ enum StreamCompletion {
Interrupted { reason: String }, Interrupted { reason: String },
} }
pub struct Engine<C: LlmClient, S: EngineState = Mutable> { pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
/// LLM client /// LLM client
client: C, client: C,
/// Retry policy for opening an LLM response stream. /// Retry policy for opening an LLM response stream.
@@ -175,18 +177,22 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
interceptor: Box<dyn Interceptor>, interceptor: Box<dyn Interceptor>,
/// System prompt /// System prompt
system_prompt: Option<String>, system_prompt: Option<String>,
/// Item history (owned by Engine)
history: Vec<Item>,
/// History length at lock time (only meaningful in Locked state) /// History length at lock time (only meaningful in Locked state)
locked_prefix_len: usize, locked_prefix_len: usize,
/// AgentTurn count. /// AgentTurn count across the lifetime of this Engine.
/// ///
/// Once retry (`agen-stream-continuation`) is implemented, an /// Once retry (`agen-stream-continuation`) is implemented, an
/// AgentTurn collapses N retried `LlmCall`s with identical input; /// AgentTurn collapses N retried `LlmCall`s with identical input;
/// today retry is not implemented so AgentTurn and LlmCall fire 1:1 /// today retry is not implemented so AgentTurn and LlmCall fire 1:1
/// and the increment site (the LLM-call loop) is shared. /// and the increment site (the LLM-call loop) is shared.
/// `max_turns` is interpreted as a per-`run()` AgentTurn cap.
turn_count: usize, turn_count: usize,
/// AgentTurns consumed by the currently active logical run.
///
/// A fresh [`run`](Self::run) starts at zero. Pause and Yield retain the
/// count for [`resume`](Self::resume), while terminal outcomes clear it.
/// `max_turns` is enforced against this run-scoped count rather than the
/// cumulative `turn_count` above.
active_run_turn_count: Option<usize>,
/// LlmCall count (per-Engine running counter, monotonic). Unlike /// LlmCall count (per-Engine running counter, monotonic). Unlike
/// `turn_count` this never collapses retries. /// `turn_count` this never collapses retries.
llm_call_count: usize, llm_call_count: usize,
@@ -260,14 +266,28 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
/// stable conversation identifier when the backend benefits from one. /// stable conversation identifier when the backend benefits from one.
cache_key: Option<String>, cache_key: Option<String>,
/// State marker /// State marker
_state: PhantomData<S>, _state: PhantomData<(S, A)>,
} }
impl<C: LlmClient, S: EngineState> Engine<C, S> { impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
fn reset_interruption_state(&mut self) { fn reset_interruption_state(&mut self) {
self.last_run_interrupted = false; self.last_run_interrupted = false;
} }
fn start_logical_run(&mut self) {
self.active_run_turn_count = Some(0);
}
fn ensure_logical_run(&mut self) {
self.active_run_turn_count.get_or_insert(0);
}
fn finish_logical_run(&mut self, result: &Result<EngineResult, EngineError>) {
if !matches!(result, Ok(EngineResult::Paused) | Ok(EngineResult::Yielded)) {
self.active_run_turn_count = None;
}
}
fn drain_cancel_queue(&mut self) { fn drain_cancel_queue(&mut self) {
while self.cancel_rx.try_recv().is_ok() {} while self.cancel_rx.try_recv().is_ok() {}
} }
@@ -519,11 +539,15 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
fn append_history_items( fn append_history_items(
&mut self, &mut self,
history: &mut History<A>,
items: impl IntoIterator<Item = Item>, items: impl IntoIterator<Item = Item>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<(), EngineError> { ) -> Result<(), EngineError> {
for item in items { for item in items {
self.emit_history_append(&item)?; self.emit_history_append(&item)?;
self.history.push(item); history
.append_with(item, annotate)
.map_err(EngineError::HistoryAppend)?;
} }
Ok(()) Ok(())
} }
@@ -630,9 +654,9 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
&self.client &self.client
} }
/// Get a reference to the history /// Borrow caller-owned annotated history entries.
pub fn history(&self) -> &[Item] { pub fn history<'h>(&self, history: &'h History<A>) -> &'h [HistoryEntry<A>] {
&self.history history.entries()
} }
/// Get a reference to the system prompt /// Get a reference to the system prompt
@@ -650,6 +674,23 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
self.turn_count self.turn_count
} }
/// Get the AgentTurns consumed by an interrupted logical run.
///
/// `Some` is retained only while Pause or Yield permits a later
/// [`resume`](Self::resume). Terminal outcomes return this to `None`.
pub fn active_run_turn_count(&self) -> Option<usize> {
self.active_run_turn_count
}
/// Restore the persisted turn budget of an interrupted logical run.
///
/// Session owners restore this together with the cumulative turn count and
/// history. `None` means there is no resumable logical run and the next
/// [`resume`](Self::resume) starts a fresh budget.
pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) {
self.active_run_turn_count = turn_count;
}
/// Get the current LlmCall count (per-Engine running counter, never /// Get the current LlmCall count (per-Engine running counter, never
/// collapsed by retry). /// collapsed by retry).
pub fn llm_call_count(&self) -> usize { pub fn llm_call_count(&self) -> usize {
@@ -878,20 +919,20 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
} }
/// Check for pending tool calls (for resuming from Pause) /// Check for pending tool calls (for resuming from Pause)
fn get_pending_tool_calls(&self) -> Option<Vec<ToolCall>> { fn get_pending_tool_calls(&self, history: &History<A>) -> Option<Vec<ToolCall>> {
// Find the last ToolCall items that don't have corresponding ToolResult // Find the last ToolCall items that don't have corresponding ToolResult
let mut pending_calls = Vec::new(); let mut pending_calls = Vec::new();
let mut answered_call_ids = std::collections::HashSet::new(); let mut answered_call_ids = std::collections::HashSet::new();
// First pass: collect all answered call IDs // First pass: collect all answered call IDs
for item in &self.history { for item in history.items() {
if let Item::ToolResult { call_id, .. } = item { if let Item::ToolResult { call_id, .. } = item {
answered_call_ids.insert(call_id.clone()); answered_call_ids.insert(call_id.clone());
} }
} }
// Second pass: find unanswered tool calls // Second pass: find unanswered tool calls
for item in &self.history { for item in history.items() {
if let Item::ToolCall { if let Item::ToolCall {
call_id, call_id,
name, name,
@@ -1095,20 +1136,27 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
} }
/// Internal turn execution logic /// Internal turn execution logic
async fn run_turn_loop(&mut self) -> Result<EngineResult, EngineError> { async fn run_turn_loop(
&mut self,
history: &mut History<A>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<EngineResult, EngineError> {
self.reset_interruption_state(); self.reset_interruption_state();
let tool_definitions = self.build_tool_definitions(); let tool_definitions = self.build_tool_definitions();
info!( info!(
item_count = self.history.len(), item_count = history.len(),
tool_count = tool_definitions.len(), tool_count = tool_definitions.len(),
"Starting engine run" "Starting engine run"
); );
// Resume pending tool calls from a previous Pause // Resume pending tool calls from a previous Pause
if let Some(tool_calls) = self.get_pending_tool_calls() { if let Some(tool_calls) = self.get_pending_tool_calls(history) {
info!("Resuming pending tool calls"); info!("Resuming pending tool calls");
if let Some(result) = self.execute_and_commit_tools(tool_calls).await? { if let Some(result) = self
.execute_and_commit_tools(history, annotate, tool_calls)
.await?
{
return Ok(result); return Ok(result);
} }
} }
@@ -1123,6 +1171,19 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
return Err(EngineError::Cancelled); return Err(EngineError::Cancelled);
} }
if let Some(max) = self.max_turns
&& self.active_run_turn_count.unwrap_or(0) >= max as usize
{
info!(
active_run_turn_count = self.active_run_turn_count.unwrap_or(0),
total_turn_count = self.turn_count,
max_turns = max,
"Logical run turn limit reached"
);
self.last_run_interrupted = false;
return Ok(EngineResult::LimitReached);
}
let current_turn = self.turn_count; let current_turn = self.turn_count;
if !continuing_stream { if !continuing_stream {
debug!(turn = current_turn, "Turn start"); debug!(turn = current_turn, "Turn start");
@@ -1142,13 +1203,13 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
.await .await
.map_err(EngineError::HistoryAppend)?; .map_err(EngineError::HistoryAppend)?;
if !pending.is_empty() { if !pending.is_empty() {
self.append_history_items(pending)?; self.append_history_items(history, pending, annotate)?;
} }
// Clone the history into a per-request context. Everything // Clone the history into a per-request context. Everything
// below (prune projection, interceptor hooks) mutates only // below (prune projection, interceptor hooks) mutates only
// this clone, so the persistent `self.history` stays intact. // this clone, so the caller-owned `history` stays intact.
let mut request_context = self.history.clone(); let mut request_context = history.items_cloned();
// Prune projection: if both the config and the savings // Prune projection: if both the config and the savings
// estimator are configured, drop ToolResult.content from // estimator are configured, drop ToolResult.content from
@@ -1217,7 +1278,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
return Err(EngineError::Aborted(reason)); return Err(EngineError::Aborted(reason));
} }
PreRequestAction::YieldWith(items) => { PreRequestAction::YieldWith(items) => {
self.append_history_items(items.clone())?; self.append_history_items(history, items.clone(), annotate)?;
request_context.extend(items); request_context.extend(items);
info!("Yielded by interceptor after pre-request history append"); info!("Yielded by interceptor after pre-request history append");
for cb in &self.turn_end_cbs { for cb in &self.turn_end_cbs {
@@ -1235,7 +1296,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
return Ok(EngineResult::Yielded); return Ok(EngineResult::Yielded);
} }
PreRequestAction::ContinueWith(items) => { PreRequestAction::ContinueWith(items) => {
self.append_history_items(items.clone())?; self.append_history_items(history, items.clone(), annotate)?;
request_context.extend(items); request_context.extend(items);
} }
PreRequestAction::Continue => {} PreRequestAction::Continue => {}
@@ -1295,7 +1356,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
let assistant_items = let assistant_items =
self.build_assistant_items(&reasoning_items, &text_blocks, &[]); self.build_assistant_items(&reasoning_items, &text_blocks, &[]);
if !assistant_items.is_empty() { if !assistant_items.is_empty() {
self.append_history_items(assistant_items)?; self.append_history_items(history, assistant_items, annotate)?;
} }
self.emit_llm_continuation( self.emit_llm_continuation(
current_llm_call, current_llm_call,
@@ -1314,6 +1375,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
cb(current_turn); cb(current_turn);
} }
self.turn_count += 1; self.turn_count += 1;
*self.active_run_turn_count.get_or_insert(0) += 1;
// Collect and commit assistant items. Routed through // Collect and commit assistant items. Routed through
// `append_history_items` so observers see each item as it lands. // `append_history_items` so observers see each item as it lands.
@@ -1322,16 +1384,17 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
let tool_calls = self.tool_call_collector.take_collected(); let tool_calls = self.tool_call_collector.take_collected();
let assistant_items = let assistant_items =
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls); self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
self.append_history_items(assistant_items)?; self.append_history_items(history, assistant_items, annotate)?;
if tool_calls.is_empty() { if tool_calls.is_empty() {
match self.interceptor.on_turn_end(&self.history).await { let turn_end_context = history.items_cloned();
match self.interceptor.on_turn_end(&turn_end_context).await {
TurnEndAction::Finish => { TurnEndAction::Finish => {
self.last_run_interrupted = false; self.last_run_interrupted = false;
return Ok(EngineResult::Finished); return Ok(EngineResult::Finished);
} }
TurnEndAction::ContinueWithMessages(additional) => { TurnEndAction::ContinueWithMessages(additional) => {
self.append_history_items(additional)?; self.append_history_items(history, additional, annotate)?;
continue; continue;
} }
TurnEndAction::Pause => { TurnEndAction::Pause => {
@@ -1341,21 +1404,12 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
} }
} }
if let Some(result) = self.execute_and_commit_tools(tool_calls).await? { if let Some(result) = self
.execute_and_commit_tools(history, annotate, tool_calls)
.await?
{
return Ok(result); return Ok(result);
} }
if let Some(max) = self.max_turns {
if self.turn_count >= max as usize {
info!(
turn_count = self.turn_count,
max_turns = max,
"Turn limit reached"
);
self.last_run_interrupted = false;
return Ok(EngineResult::LimitReached);
}
}
} }
} }
@@ -1607,6 +1661,8 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
/// `None` if the turn loop should continue. /// `None` if the turn loop should continue.
async fn execute_and_commit_tools( async fn execute_and_commit_tools(
&mut self, &mut self,
history: &mut History<A>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
tool_calls: Vec<ToolCall>, tool_calls: Vec<ToolCall>,
) -> Result<Option<EngineResult>, EngineError> { ) -> Result<Option<EngineResult>, EngineError> {
match self.execute_tools(tool_calls).await { match self.execute_tools(tool_calls).await {
@@ -1626,7 +1682,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
result.attachments, result.attachments,
) )
}); });
self.append_history_items(items)?; self.append_history_items(history, items, annotate)?;
Ok(None) Ok(None)
} }
Err(err) => { Err(err) => {
@@ -1637,9 +1693,9 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
} }
} }
impl<C: LlmClient> Engine<C, Mutable> { impl<C: LlmClient, A> Engine<C, Mutable, A> {
/// Create a new Engine (in Mutable state) /// Create a new annotated Engine (in Mutable state).
pub fn new(client: C) -> Self { pub fn new_annotated(client: C) -> Self {
let text_block_collector = TextBlockCollector::new(); let text_block_collector = TextBlockCollector::new();
let tool_call_collector = ToolCallCollector::new(); let tool_call_collector = ToolCallCollector::new();
let thinking_block_collector = ThinkingBlockCollector::new(); let thinking_block_collector = ThinkingBlockCollector::new();
@@ -1661,9 +1717,9 @@ impl<C: LlmClient> Engine<C, Mutable> {
tool_server: ToolServer::new().handle(), tool_server: ToolServer::new().handle(),
interceptor: Box::new(DefaultInterceptor), interceptor: Box::new(DefaultInterceptor),
system_prompt: None, system_prompt: None,
history: Vec::new(),
locked_prefix_len: 0, locked_prefix_len: 0,
turn_count: 0, turn_count: 0,
active_run_turn_count: None,
llm_call_count: 0, llm_call_count: 0,
tool_execution_batch_count: 0, tool_execution_batch_count: 0,
max_turns: None, max_turns: None,
@@ -1821,36 +1877,38 @@ impl<C: LlmClient> Engine<C, Mutable> {
} }
} }
/// Replace history during restore/rebuild without emitting append callbacks. /// Replace caller-owned history during restore/rebuild without emitting append callbacks.
/// ///
/// This is not a history-growth API. Live append paths must use /// This is not a history-growth API. Live append paths must use
/// [`append_history`](Self::append_history) so `on_history_append` observers /// [`append_history_with`](Self::append_history_with) so observers and the
/// see every inserted item. /// trusted annotation callback see every inserted item.
pub fn set_history(&mut self, items: Vec<Item>) { pub fn replace_history_entries(
self.history = items;
}
/// Append items to history after every history-append observer accepts the
/// item. This is the only public Mutable-state API for growing engine
/// history; callers that need session-log persistence must install
/// [`on_history_append`](Self::on_history_append) before calling it.
pub fn append_history(
&mut self, &mut self,
history: &mut History<A>,
entries: Vec<HistoryEntry<A>>,
) -> Vec<HistoryEntry<A>> {
history.replace_entries(entries)
}
/// Append items to caller-owned history after every observer and the trusted
/// annotation callback accepts the item.
pub fn append_history_with(
&mut self,
history: &mut History<A>,
items: impl IntoIterator<Item = Item>, items: impl IntoIterator<Item = Item>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<(), EngineError> { ) -> Result<(), EngineError> {
self.append_history_items(items) self.append_history_items(history, items, annotate)
} }
/// Truncate history without emitting append callbacks. /// Truncate caller-owned history without emitting append callbacks.
/// pub fn truncate_history(&mut self, history: &mut History<A>, len: usize) {
/// This is an edit operation, not a history-growth path. history.truncate(len);
pub fn truncate_history(&mut self, len: usize) {
self.history.truncate(len);
} }
/// Clear history /// Clear caller-owned history.
pub fn clear_history(&mut self) { pub fn clear_history(&mut self, history: &mut History<A>) {
self.history.clear(); history.clear();
} }
/// Set the turn count (for session restoration) /// Set the turn count (for session restoration)
@@ -1866,6 +1924,9 @@ impl<C: LlmClient> Engine<C, Mutable> {
/// Set the last_run_interrupted flag (for session restoration) /// Set the last_run_interrupted flag (for session restoration)
pub fn set_last_run_interrupted(&mut self, interrupted: bool) { pub fn set_last_run_interrupted(&mut self, interrupted: bool) {
self.last_run_interrupted = interrupted; self.last_run_interrupted = interrupted;
if !interrupted {
self.active_run_turn_count = None;
}
} }
/// Apply configuration (reserved for future extensions) /// Apply configuration (reserved for future extensions)
@@ -1874,19 +1935,21 @@ impl<C: LlmClient> Engine<C, Mutable> {
self self
} }
/// Execute a turn, consuming self and transitioning to Locked. /// Run the engine with one user input, appending to caller-owned history.
/// ///
/// This is the primary entry point for first use. Equivalent to /// The trusted `annotate` callback is invoked after append observers and before
/// `self.lock()` followed by `locked.run(user_input)`. /// each new item becomes live in `history`. Providers, token counters, pruners,
/// /// and interceptors receive only the `Item` projection.
/// Subsequent runs can call [`Engine::run`] directly. pub async fn run_with_annotation(
/// To edit state between turns, call [`unlock()`](Engine::unlock) first.
pub async fn run(
self, self,
history: &mut History<A>,
user_input: impl Into<String>, user_input: impl Into<String>,
) -> Result<EngineRunOutput<C>, EngineError> { annotate: &mut impl FnMut(&Item) -> Result<A, String>,
let mut locked = self.lock(); ) -> Result<EngineRunOutput<C, A>, EngineError> {
let result = locked.run(user_input).await?; let mut locked = self.lock(history);
let result = locked
.run_with_annotation(history, user_input, annotate)
.await?;
Ok(EngineRunOutput { Ok(EngineRunOutput {
engine: locked, engine: locked,
result, result,
@@ -1896,9 +1959,13 @@ impl<C: LlmClient> Engine<C, Mutable> {
/// Resume from Paused, consuming self and transitioning to Locked. /// Resume from Paused, consuming self and transitioning to Locked.
/// ///
/// Used after `unlock()` → edit → resume. /// Used after `unlock()` → edit → resume.
pub async fn resume(self) -> Result<EngineRunOutput<C>, EngineError> { pub async fn resume_with_annotation(
let mut locked = self.lock(); self,
let result = locked.resume().await?; history: &mut History<A>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<EngineRunOutput<C, A>, EngineError> {
let mut locked = self.lock(history);
let result = locked.resume_with_annotation(history, annotate).await?;
Ok(EngineRunOutput { Ok(EngineRunOutput {
engine: locked, engine: locked,
result, result,
@@ -1918,9 +1985,9 @@ impl<C: LlmClient> Engine<C, Mutable> {
/// # Panics /// # Panics
/// ///
/// Panics if a pending tool factory produces a duplicate name. /// Panics if a pending tool factory produces a duplicate name.
pub fn lock(self) -> Engine<C, Locked> { pub fn lock(self, history: &History<A>) -> Engine<C, Locked, A> {
self.tool_server.flush_pending(); self.tool_server.flush_pending();
let locked_prefix_len = self.history.len(); let locked_prefix_len = history.len();
Engine { Engine {
client: self.client, client: self.client,
retry_policy: self.retry_policy, retry_policy: self.retry_policy,
@@ -1931,9 +1998,9 @@ impl<C: LlmClient> Engine<C, Mutable> {
tool_server: self.tool_server, tool_server: self.tool_server,
interceptor: self.interceptor, interceptor: self.interceptor,
system_prompt: self.system_prompt, system_prompt: self.system_prompt,
history: self.history,
locked_prefix_len, locked_prefix_len,
turn_count: self.turn_count, turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count,
llm_call_count: self.llm_call_count, llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count, tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns, max_turns: self.max_turns,
@@ -1965,15 +2032,65 @@ impl<C: LlmClient> Engine<C, Mutable> {
} }
} }
impl<C: LlmClient> Engine<C, Locked> { fn unit_history_annotation(_: &Item) -> Result<(), String> {
Ok(())
}
impl<C: LlmClient> Engine<C, Mutable, ()> {
/// Create a new Engine (in Mutable state) using unit history annotations.
pub fn new(client: C) -> Self {
Self::new_annotated(client)
}
/// Append unit-annotated items to caller-owned history.
pub fn append_history(
&mut self,
history: &mut History<()>,
items: impl IntoIterator<Item = Item>,
) -> Result<(), EngineError> {
let mut annotate = unit_history_annotation;
self.append_history_items(history, items, &mut annotate)
}
/// Replace unit-annotated history from plain items.
pub fn set_history(&mut self, history: &mut History<()>, items: Vec<Item>) {
history.replace_items(items);
}
/// Run using unit annotations.
pub async fn run(
self,
history: &mut History<()>,
user_input: impl Into<String>,
) -> Result<EngineRunOutput<C>, EngineError> {
let mut annotate = unit_history_annotation;
self.run_with_annotation(history, user_input, &mut annotate)
.await
}
/// Resume using unit annotations.
pub async fn resume(
self,
history: &mut History<()>,
) -> Result<EngineRunOutput<C>, EngineError> {
let mut annotate = unit_history_annotation;
self.resume_with_annotation(history, &mut annotate).await
}
}
impl<C: LlmClient, A> Engine<C, Locked, A> {
/// Execute a turn /// Execute a turn
/// ///
/// Adds a new user message to history and sends a request to the LLM. /// Adds a new user message to history and sends a request to the LLM.
/// Automatically loops if there are tool calls. /// Automatically loops if there are tool calls.
pub async fn run( pub async fn run_with_annotation(
&mut self, &mut self,
history: &mut History<A>,
user_input: impl Into<String>, user_input: impl Into<String>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<EngineResult, EngineError> { ) -> Result<EngineResult, EngineError> {
// Supplying new user input abandons any paused/yielded logical run.
self.active_run_turn_count = None;
self.reset_interruption_state(); self.reset_interruption_state();
// Interceptor: on_prompt_submit // Interceptor: on_prompt_submit
let mut user_item = Item::user_message(user_input); let mut user_item = Item::user_message(user_input);
@@ -1987,21 +2104,31 @@ impl<C: LlmClient> Engine<C, Locked> {
PromptAction::Continue => Vec::new(), PromptAction::Continue => Vec::new(),
PromptAction::ContinueWith(items) => items, PromptAction::ContinueWith(items) => items,
}; };
self.append_history_items(std::iter::once(user_item))?; self.append_history_items(history, std::iter::once(user_item), annotate)?;
if !extras.is_empty() { if !extras.is_empty() {
self.append_history_items(extras)?; self.append_history_items(history, extras, annotate)?;
} }
let result = self.run_turn_loop().await; self.start_logical_run();
self.finalize_interruption(result).await let result = self.run_turn_loop(history, annotate).await;
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
} }
/// Resume execution (from Paused state) /// Resume execution (from Paused state)
/// ///
/// Resumes turn processing from current state without adding a new user message. /// Resumes turn processing from current state without adding a new user message.
pub async fn resume(&mut self) -> Result<EngineResult, EngineError> { pub async fn resume_with_annotation(
&mut self,
history: &mut History<A>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<EngineResult, EngineError> {
self.reset_interruption_state(); self.reset_interruption_state();
let result = self.run_turn_loop().await; self.ensure_logical_run();
self.finalize_interruption(result).await let result = self.run_turn_loop(history, annotate).await;
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
} }
/// Get the prefix length at lock time /// Get the prefix length at lock time
@@ -2013,7 +2140,7 @@ impl<C: LlmClient> Engine<C, Locked> {
/// ///
/// Note: After this operation, subsequent requests may not hit the cache. /// Note: After this operation, subsequent requests may not hit the cache.
/// Use only when you need to edit history. /// Use only when you need to edit history.
pub fn unlock(self) -> Engine<C, Mutable> { pub fn unlock(self) -> Engine<C, Mutable, A> {
Engine { Engine {
client: self.client, client: self.client,
retry_policy: self.retry_policy, retry_policy: self.retry_policy,
@@ -2024,9 +2151,9 @@ impl<C: LlmClient> Engine<C, Locked> {
tool_server: self.tool_server, tool_server: self.tool_server,
interceptor: self.interceptor, interceptor: self.interceptor,
system_prompt: self.system_prompt, system_prompt: self.system_prompt,
history: self.history,
locked_prefix_len: 0, locked_prefix_len: 0,
turn_count: self.turn_count, turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count,
llm_call_count: self.llm_call_count, llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count, tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns, max_turns: self.max_turns,
@@ -2058,6 +2185,25 @@ impl<C: LlmClient> Engine<C, Locked> {
} }
} }
impl<C: LlmClient> Engine<C, Locked, ()> {
/// Run another turn using unit annotations.
pub async fn run(
&mut self,
history: &mut History<()>,
user_input: impl Into<String>,
) -> Result<EngineResult, EngineError> {
let mut annotate = unit_history_annotation;
self.run_with_annotation(history, user_input, &mut annotate)
.await
}
/// Resume using unit annotations.
pub async fn resume(&mut self, history: &mut History<()>) -> Result<EngineResult, EngineError> {
let mut annotate = unit_history_annotation;
self.resume_with_annotation(history, &mut annotate).await
}
}
enum FirstStreamEvent { enum FirstStreamEvent {
Ready(ResponseStream), Ready(ResponseStream),
Empty(ResponseStream), Empty(ResponseStream),
+199
View File
@@ -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
View File
@@ -2,6 +2,7 @@
mod engine; mod engine;
mod handler; mod handler;
mod history;
mod message; mod message;
pub(crate) mod callback; pub(crate) mod callback;
@@ -24,6 +25,7 @@ pub use engine::{
ToolRegistryError, ToolRegistryError,
}; };
pub use handler::ToolUseBlockStart; pub use handler::ToolUseBlockStart;
pub use history::{History, HistoryEntry};
pub use interceptor::Interceptor; pub use interceptor::Interceptor;
pub use message::{ContentPart, Item, Message, Role}; pub use message::{ContentPart, Item, Message, Role};
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult}; pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
+1 -1
View File
@@ -19,7 +19,7 @@ mod private {
/// - Editing message history (add, delete, clear) /// - Editing message history (add, delete, clear)
/// - Registering tools and hooks /// - Registering tools and hooks
/// ///
/// Can transition to [`Locked`] state via `Engine::lock()`. /// Can transition to [`Locked`] state via `Engine::lock(&history)`.
/// ///
/// # Examples /// # Examples
/// ///
@@ -0,0 +1,85 @@
mod common;
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
use common::MockLlmClient;
fn completed_text_events(text: &str) -> Vec<Event> {
vec![
Event::text_block_start(0),
Event::text_delta(0, text),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
#[tokio::test]
async fn run_preserves_item_annotations_without_projecting_them() {
let client = MockLlmClient::new(completed_text_events("assistant reply"));
let engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
let mut history = History::<String>::new();
let mut next = 0usize;
let mut annotate = |item: &Item| {
next += 1;
let kind = match item {
Item::Message { role, .. } => match role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
},
Item::ToolCall { .. } => "tool_call",
Item::ToolResult { .. } => "tool_result",
Item::Reasoning { .. } => "reasoning",
};
Ok(format!("{next}:{kind}"))
};
let output = engine
.run_with_annotation(&mut history, "hello", &mut annotate)
.await
.unwrap();
assert!(matches!(output.result, agen::EngineResult::Finished));
assert_eq!(history.len(), 2);
assert_eq!(history.entries()[0].annotation, "1:user");
assert_eq!(history.entries()[1].annotation, "2:assistant");
assert_eq!(history.items_cloned().len(), 2);
}
#[test]
fn append_failure_does_not_make_item_live() {
let client = MockLlmClient::new(vec![]);
let mut engine = Engine::<_, agen::state::Mutable, usize>::new_annotated(client);
let mut history = History::<usize>::new();
let mut fail = |_item: &Item| Err("commit failed".to_string());
let err = engine
.append_history_with(&mut history, [Item::user_message("uncommitted")], &mut fail)
.unwrap_err();
assert!(matches!(err, EngineError::HistoryAppend(message) if message == "commit failed"));
assert!(history.is_empty());
}
#[test]
fn replacement_keeps_items_and_annotations_together() {
let mut history = History::from_entries(vec![
HistoryEntry::new(Item::user_message("old"), "old-ann".to_string()),
HistoryEntry::new(Item::user_message("second"), "second-ann".to_string()),
]);
history.truncate(1);
assert_eq!(history.entries()[0].item.as_text(), Some("old"));
assert_eq!(history.entries()[0].annotation, "old-ann");
let previous = history.replace_entries(vec![HistoryEntry::new(
Item::user_message("restored"),
"restored-ann".to_string(),
)]);
assert_eq!(previous.len(), 1);
assert_eq!(history.entries()[0].item.as_text(), Some("restored"));
assert_eq!(history.entries()[0].annotation, "restored-ann");
}
+15 -8
View File
@@ -8,11 +8,11 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use agen::Engine;
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
use agen::llm_client::retry::RetryPolicy; use agen::llm_client::retry::RetryPolicy;
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream}; use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, History};
use async_trait::async_trait; use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
@@ -58,6 +58,7 @@ async fn test_callback_llm_retry_event() {
max_attempts: 2, max_attempts: 2,
total_timeout: Duration::from_secs(1), total_timeout: Duration::from_secs(1),
}); });
let mut history: History = History::new();
let notices = Arc::new(Mutex::new(Vec::new())); let notices = Arc::new(Mutex::new(Vec::new()));
let sink = notices.clone(); let sink = notices.clone();
@@ -65,7 +66,7 @@ async fn test_callback_llm_retry_event() {
sink.lock().unwrap().push((llm_call, notice.clone())); sink.lock().unwrap().push((llm_call, notice.clone()));
}); });
let result = engine.run("retry once").await; let result = engine.run(&mut history, "retry once").await;
assert!(result.is_ok(), "engine should succeed after one retry"); assert!(result.is_ok(), "engine should succeed after one retry");
let notices = notices.lock().unwrap(); let notices = notices.lock().unwrap();
@@ -91,6 +92,7 @@ async fn test_callback_text_block_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let text_deltas = Arc::new(Mutex::new(Vec::new())); let text_deltas = Arc::new(Mutex::new(Vec::new()));
let text_completes = Arc::new(Mutex::new(Vec::new())); let text_completes = Arc::new(Mutex::new(Vec::new()));
@@ -109,7 +111,7 @@ async fn test_callback_text_block_events() {
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run("Greet me").await; let result = engine.run(&mut history, "Greet me").await;
assert!(result.is_ok(), "Engine should complete"); assert!(result.is_ok(), "Engine should complete");
let deltas = text_deltas.lock().unwrap(); let deltas = text_deltas.lock().unwrap();
@@ -137,6 +139,7 @@ async fn test_callback_tool_call_complete() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new())); let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
let tool_completes = Arc::new(Mutex::new(Vec::new())); let tool_completes = Arc::new(Mutex::new(Vec::new()));
@@ -155,7 +158,7 @@ async fn test_callback_tool_call_complete() {
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let _ = engine.run("Weather please").await; let _ = engine.run(&mut history, "Weather please").await;
let starts = tool_starts.lock().unwrap(); let starts = tool_starts.lock().unwrap();
assert_eq!(starts.len(), 1); assert_eq!(starts.len(), 1);
@@ -183,6 +186,7 @@ async fn test_callback_turn_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let turn_starts = Arc::new(Mutex::new(Vec::new())); let turn_starts = Arc::new(Mutex::new(Vec::new()));
let turn_ends = Arc::new(Mutex::new(Vec::new())); let turn_ends = Arc::new(Mutex::new(Vec::new()));
@@ -198,7 +202,7 @@ async fn test_callback_turn_events() {
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run("Do something").await; let result = engine.run(&mut history, "Do something").await;
assert!(result.is_ok()); assert!(result.is_ok());
let starts = turn_starts.lock().unwrap(); let starts = turn_starts.lock().unwrap();
@@ -254,6 +258,7 @@ async fn test_callback_tool_result_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.register_tool(fixed_tool( engine.register_tool(fixed_tool(
"fixed", "fixed",
@@ -276,7 +281,7 @@ async fn test_callback_tool_result_events() {
)); ));
}); });
let _ = engine.run("call it").await; let _ = engine.run(&mut history, "call it").await;
let observed = captured.lock().unwrap(); let observed = captured.lock().unwrap();
assert_eq!(observed.len(), 1); assert_eq!(observed.len(), 1);
@@ -330,6 +335,7 @@ async fn test_callback_tool_result_error_path() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.register_tool(erroring_tool("erroring", "boom")); engine.register_tool(erroring_tool("erroring", "boom"));
@@ -345,7 +351,7 @@ async fn test_callback_tool_result_error_path() {
)); ));
}); });
let _ = engine.run("fail it").await; let _ = engine.run(&mut history, "fail it").await;
let observed = captured.lock().unwrap(); let observed = captured.lock().unwrap();
assert_eq!(observed.len(), 1); assert_eq!(observed.len(), 1);
@@ -374,6 +380,7 @@ async fn test_callback_usage_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let usage_events = Arc::new(Mutex::new(Vec::new())); let usage_events = Arc::new(Mutex::new(Vec::new()));
@@ -383,7 +390,7 @@ async fn test_callback_usage_events() {
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let _ = engine.run("Hello").await; let _ = engine.run(&mut history, "Hello").await;
let usages = usage_events.lock().unwrap(); let usages = usage_events.lock().unwrap();
assert_eq!(usages.len(), 1); assert_eq!(usages.len(), 1);
+9 -4
View File
@@ -9,8 +9,8 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use agen::Engine;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, History};
use async_trait::async_trait; use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
@@ -134,9 +134,10 @@ async fn test_engine_simple_text_response() {
let client = MockLlmClient::from_fixture(&fixture_path).unwrap(); let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history: History = History::new();
// Send a simple message (Mutable::run consumes self, returns tuple) // Send a simple message (Mutable::run consumes self, returns tuple)
let result = engine.run("Hello").await; let result = engine.run(&mut history, "Hello").await;
assert!(result.is_ok(), "Engine should complete successfully"); assert!(result.is_ok(), "Engine should complete successfully");
} }
@@ -156,6 +157,7 @@ async fn test_engine_tool_call() {
let client = MockLlmClient::from_fixture(&fixture_path).unwrap(); let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
// Register tool // Register tool
let weather_tool = MockWeatherTool::new(); let weather_tool = MockWeatherTool::new();
@@ -163,7 +165,9 @@ async fn test_engine_tool_call() {
engine.register_tool(weather_tool.definition()); engine.register_tool(weather_tool.definition());
// Send message (Mutable::run consumes self, returns tuple) // Send message (Mutable::run consumes self, returns tuple)
let _result = engine.run("What's the weather in Tokyo?").await; let _result = engine
.run(&mut history, "What's the weather in Tokyo?")
.await;
// Verify tool was called // Verify tool was called
// Note: max_turns=1 so no request is sent after tool result // Note: max_turns=1 so no request is sent after tool result
@@ -195,9 +199,10 @@ async fn test_engine_with_programmatic_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history: History = History::new();
// Mutable::run consumes self, returns tuple // Mutable::run consumes self, returns tuple
let result = engine.run("Greet me").await; let result = engine.run(&mut history, "Greet me").await;
assert!(result.is_ok(), "Engine should complete successfully"); assert!(result.is_ok(), "Engine should complete successfully");
} }
+314 -68
View File
@@ -9,9 +9,12 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use agen::Item; use agen::Item;
use agen::interceptor::{
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, EngineError}; use agen::{Engine, EngineError, EngineResult, History};
use async_trait::async_trait; use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
@@ -39,36 +42,37 @@ fn test_mutable_set_system_prompt() {
fn test_mutable_history_manipulation() { fn test_mutable_history_manipulation() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
// Initial state is empty // Initial state is empty
assert!(engine.history().is_empty()); assert!(history.is_empty());
// Add to history // Add to history
engine engine
.append_history(vec![Item::user_message("Hello")]) .append_history(&mut history, vec![Item::user_message("Hello")])
.unwrap(); .unwrap();
engine engine
.append_history(vec![Item::assistant_message("Hi there!")]) .append_history(&mut history, vec![Item::assistant_message("Hi there!")])
.unwrap(); .unwrap();
assert_eq!(engine.history().len(), 2); assert_eq!(history.len(), 2);
// Append to history via the callback-aware API. // Append to history via the callback-aware API.
engine engine
.append_history(vec![Item::user_message("How are you?")]) .append_history(&mut history, vec![Item::user_message("How are you?")])
.unwrap(); .unwrap();
assert_eq!(engine.history().len(), 3); assert_eq!(history.len(), 3);
// Clear history // Clear history
engine.clear_history(); engine.clear_history(&mut history);
assert!(engine.history().is_empty()); assert!(history.is_empty());
// Set history // Set history
let items = vec![ let items = vec![
Item::user_message("Test"), Item::user_message("Test"),
Item::assistant_message("Response"), Item::assistant_message("Response"),
]; ];
engine.set_history(items); engine.set_history(&mut history, items);
assert_eq!(engine.history().len(), 2); assert_eq!(history.len(), 2);
} }
/// Verify that Engine can be constructed using builder pattern /// Verify that Engine can be constructed using builder pattern
@@ -76,9 +80,10 @@ fn test_mutable_history_manipulation() {
fn test_mutable_builder_pattern() { fn test_mutable_builder_pattern() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let engine = Engine::new(client).system_prompt("System prompt"); let engine = Engine::new(client).system_prompt("System prompt");
let history: History = History::new();
assert_eq!(engine.get_system_prompt(), Some("System prompt")); assert_eq!(engine.get_system_prompt(), Some("System prompt"));
assert!(engine.history().is_empty()); assert!(history.is_empty());
} }
/// Verify that multiple items can be added with append_history and callbacks fire. /// Verify that multiple items can be added with append_history and callbacks fire.
@@ -88,6 +93,7 @@ fn test_mutable_append_history() {
let observed = Arc::new(Mutex::new(Vec::new())); let observed = Arc::new(Mutex::new(Vec::new()));
let observed_for_callback = Arc::clone(&observed); let observed_for_callback = Arc::clone(&observed);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.on_history_append(move |item| { engine.on_history_append(move |item| {
if let Some(text) = item.as_text() { if let Some(text) = item.as_text() {
observed_for_callback.lock().unwrap().push(text.to_string()); observed_for_callback.lock().unwrap().push(text.to_string());
@@ -96,18 +102,21 @@ fn test_mutable_append_history() {
}); });
engine engine
.append_history(vec![Item::user_message("First")]) .append_history(&mut history, vec![Item::user_message("First")])
.unwrap(); .unwrap();
engine engine
.append_history(vec![ .append_history(
&mut history,
vec![
Item::assistant_message("Response 1"), Item::assistant_message("Response 1"),
Item::user_message("Second"), Item::user_message("Second"),
Item::assistant_message("Response 2"), Item::assistant_message("Response 2"),
]) ],
)
.unwrap(); .unwrap();
assert_eq!(engine.history().len(), 4); assert_eq!(history.len(), 4);
assert_eq!( assert_eq!(
observed.lock().unwrap().as_slice(), observed.lock().unwrap().as_slice(),
["First", "Response 1", "Second", "Response 2"] ["First", "Response 1", "Second", "Response 2"]
@@ -182,6 +191,7 @@ async fn history_append_failure_stops_before_tool_execution() {
]); ]);
let tool = CountingTool::new("count_tool"); let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.register_tool(tool.definition()); engine.register_tool(tool.definition());
engine.on_history_append(|item| { engine.on_history_append(|item| {
if item.is_tool_call() { if item.is_tool_call() {
@@ -191,15 +201,15 @@ async fn history_append_failure_stops_before_tool_execution() {
} }
}); });
let mut engine = engine.lock(); let mut engine = engine.lock(&history);
let error = engine.run("use the tool").await.unwrap_err(); let error = engine.run(&mut history, "use the tool").await.unwrap_err();
assert!( assert!(
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC") matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC")
); );
assert_eq!(tool.call_count(), 0); assert_eq!(tool.call_count(), 0);
assert_eq!(engine.history().len(), 1); assert_eq!(history.len(), 1);
assert_eq!(engine.history()[0].as_text(), Some("use the tool")); assert_eq!(history.entries()[0].item.as_text(), Some("use the tool"));
} }
// ============================================================================= // =============================================================================
@@ -211,21 +221,22 @@ async fn history_append_failure_stops_before_tool_execution() {
fn test_lock_transition() { fn test_lock_transition() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.set_system_prompt("System"); engine.set_system_prompt("System");
engine engine
.append_history(vec![Item::user_message("Hello")]) .append_history(&mut history, vec![Item::user_message("Hello")])
.unwrap(); .unwrap();
engine engine
.append_history(vec![Item::assistant_message("Hi")]) .append_history(&mut history, vec![Item::assistant_message("Hi")])
.unwrap(); .unwrap();
// Lock // Lock
let locked_engine = engine.lock(); let locked_engine = engine.lock(&history);
// History and system prompt are still accessible in Locked state // History and system prompt are still accessible in Locked state
assert_eq!(locked_engine.get_system_prompt(), Some("System")); assert_eq!(locked_engine.get_system_prompt(), Some("System"));
assert_eq!(locked_engine.history().len(), 2); assert_eq!(history.len(), 2);
assert_eq!(locked_engine.locked_prefix_len(), 2); assert_eq!(locked_engine.locked_prefix_len(), 2);
} }
@@ -234,21 +245,22 @@ fn test_lock_transition() {
fn test_unlock_transition() { fn test_unlock_transition() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine engine
.append_history(vec![Item::user_message("Hello")]) .append_history(&mut history, vec![Item::user_message("Hello")])
.unwrap(); .unwrap();
let locked_engine = engine.lock(); let locked_engine = engine.lock(&history);
// Unlock // Unlock
let mut engine = locked_engine.unlock(); let mut engine = locked_engine.unlock();
// History operations are available again in Mutable state // History operations are available again in Mutable state
engine engine
.append_history(vec![Item::assistant_message("Hi")]) .append_history(&mut history, vec![Item::assistant_message("Hi")])
.unwrap(); .unwrap();
engine.clear_history(); engine.clear_history(&mut history);
assert!(engine.history().is_empty()); assert!(history.is_empty());
} }
// ============================================================================= // =============================================================================
@@ -269,20 +281,20 @@ async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history: History = History::new();
// Execute (Mutable::run consumes self, returns EngineRunOutput) // Execute (Mutable::run consumes self, returns EngineRunOutput)
let out = engine.run("Hi there").await?; let _out = engine.run(&mut history, "Hi there").await?;
let engine = out.engine;
// History is updated // History is updated
let history = engine.history(); let entries = history.entries();
assert_eq!(history.len(), 2); // user + assistant assert_eq!(history.len(), 2); // user + assistant
// User message // User message
assert_eq!(history[0].as_text(), Some("Hi there")); assert_eq!(entries[0].item.as_text(), Some("Hi there"));
// Assistant message // Assistant message
assert_eq!(history[1].as_text(), Some("Hello, I'm an assistant!")); assert_eq!(entries[1].item.as_text(), Some("Hello, I'm an assistant!"));
Ok(()) Ok(())
} }
@@ -313,35 +325,36 @@ async fn test_locked_multi_turn_history_accumulation() {
]); ]);
let engine = Engine::new(client).system_prompt("You are helpful."); let engine = Engine::new(client).system_prompt("You are helpful.");
let mut history: History = History::new();
// Lock (after setting system prompt) // Lock (after setting system prompt)
let mut locked_engine = engine.lock(); let mut locked_engine = engine.lock(&history);
assert_eq!(locked_engine.locked_prefix_len(), 0); // No items yet assert_eq!(locked_engine.locked_prefix_len(), 0); // No items yet
// Turn 1 // Turn 1
let result1 = locked_engine.run("Hello!").await; let result1 = locked_engine.run(&mut history, "Hello!").await;
assert!(result1.is_ok()); assert!(result1.is_ok());
assert_eq!(locked_engine.history().len(), 2); // user + assistant assert_eq!(history.len(), 2); // user + assistant
// Turn 2 // Turn 2
let result2 = locked_engine.run("Can you help me?").await; let result2 = locked_engine.run(&mut history, "Can you help me?").await;
assert!(result2.is_ok()); assert!(result2.is_ok());
assert_eq!(locked_engine.history().len(), 4); // 2 * (user + assistant) assert_eq!(history.len(), 4); // 2 * (user + assistant)
// Verify history contents // Verify history contents
let history = locked_engine.history(); let entries = history.entries();
// Turn 1 user message // Turn 1 user message
assert_eq!(history[0].as_text(), Some("Hello!")); assert_eq!(entries[0].item.as_text(), Some("Hello!"));
// Turn 1 assistant message // Turn 1 assistant message
assert_eq!(history[1].as_text(), Some("Nice to meet you!")); assert_eq!(entries[1].item.as_text(), Some("Nice to meet you!"));
// Turn 2 user message // Turn 2 user message
assert_eq!(history[2].as_text(), Some("Can you help me?")); assert_eq!(entries[2].item.as_text(), Some("Can you help me?"));
// Turn 2 assistant message // Turn 2 assistant message
assert_eq!(history[3].as_text(), Some("I can help with that.")); assert_eq!(entries[3].item.as_text(), Some("I can help with that."));
} }
/// Verify that locked_prefix_len correctly records history length at lock time /// Verify that locked_prefix_len correctly records history length at lock time
@@ -367,26 +380,36 @@ async fn test_locked_prefix_len_tracking() {
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
// Add items beforehand // Add items beforehand
engine engine
.append_history(vec![Item::user_message("Pre-existing message 1")]) .append_history(
&mut history,
vec![Item::user_message("Pre-existing message 1")],
)
.unwrap(); .unwrap();
engine engine
.append_history(vec![Item::assistant_message("Pre-existing response 1")]) .append_history(
&mut history,
vec![Item::assistant_message("Pre-existing response 1")],
)
.unwrap(); .unwrap();
assert_eq!(engine.history().len(), 2); assert_eq!(history.len(), 2);
// Lock // Lock
let mut locked_engine = engine.lock(); let mut locked_engine = engine.lock(&history);
assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time
// Execute turn // Execute turn
locked_engine.run("New message").await.unwrap(); locked_engine
.run(&mut history, "New message")
.await
.unwrap();
// History grows but locked_prefix_len remains unchanged // History grows but locked_prefix_len remains unchanged
assert_eq!(locked_engine.history().len(), 4); // 2 + 2 assert_eq!(history.len(), 4); // 2 + 2
assert_eq!(locked_engine.locked_prefix_len(), 2); // Unchanged assert_eq!(locked_engine.locked_prefix_len(), 2); // Unchanged
} }
@@ -413,18 +436,19 @@ async fn test_turn_count_increment() -> Result<(), EngineError> {
]); ]);
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history: History = History::new();
assert_eq!(engine.turn_count(), 0); assert_eq!(engine.turn_count(), 0);
assert_eq!(engine.llm_call_count(), 0); assert_eq!(engine.llm_call_count(), 0);
// First run consumes Mutable, returns EngineRunOutput // First run consumes Mutable, returns EngineRunOutput
let mut engine = engine.run("First").await?.engine; let mut engine = engine.run(&mut history, "First").await?.engine;
assert_eq!(engine.turn_count(), 1); assert_eq!(engine.turn_count(), 1);
// Retry not yet implemented → AgentTurn:LlmCall is 1:1. // Retry not yet implemented → AgentTurn:LlmCall is 1:1.
assert_eq!(engine.llm_call_count(), 1); assert_eq!(engine.llm_call_count(), 1);
// Subsequent runs on Locked take &mut self // Subsequent runs on Locked take &mut self
engine.run("Second").await?; engine.run(&mut history, "Second").await?;
assert_eq!(engine.turn_count(), 2); assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.llm_call_count(), 2); assert_eq!(engine.llm_call_count(), 2);
@@ -444,28 +468,29 @@ async fn test_unlock_edit_relock() {
]]); ]]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine engine
.append_history(vec![ .append_history(
Item::user_message("Hello"), &mut history,
Item::assistant_message("Hi"), vec![Item::user_message("Hello"), Item::assistant_message("Hi")],
]) )
.unwrap(); .unwrap();
// Lock -> Unlock // Lock -> Unlock
let locked = engine.lock(); let locked = engine.lock(&history);
assert_eq!(locked.locked_prefix_len(), 2); assert_eq!(locked.locked_prefix_len(), 2);
let mut unlocked = locked.unlock(); let mut unlocked = locked.unlock();
// Edit history // Edit history
unlocked.clear_history(); unlocked.clear_history(&mut history);
unlocked unlocked
.append_history(vec![Item::user_message("Fresh start")]) .append_history(&mut history, vec![Item::user_message("Fresh start")])
.unwrap(); .unwrap();
// Re-lock // Re-lock
let relocked = unlocked.lock(); let relocked = unlocked.lock(&history);
assert_eq!(relocked.history().len(), 1); assert_eq!(history.len(), 1);
assert_eq!(relocked.locked_prefix_len(), 1); assert_eq!(relocked.locked_prefix_len(), 1);
} }
@@ -508,19 +533,23 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let tool_a = CountingTool::new("tool_a"); let tool_a = CountingTool::new("tool_a");
engine.register_tool(tool_a.definition()); engine.register_tool(tool_a.definition());
let mut locked = engine.lock(); let mut locked = engine.lock(&history);
locked.run("first").await.expect("first run"); locked.run(&mut history, "first").await.expect("first run");
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once"); assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
let mut unlocked = locked.unlock(); let mut unlocked = locked.unlock();
let tool_b = CountingTool::new("tool_b"); let tool_b = CountingTool::new("tool_b");
unlocked.register_tool(tool_b.definition()); unlocked.register_tool(tool_b.definition());
let mut relocked = unlocked.lock(); let mut relocked = unlocked.lock(&history);
relocked.run("second").await.expect("second run"); relocked
.run(&mut history, "second")
.await
.expect("second run");
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again"); assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once"); assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
@@ -535,8 +564,9 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
fn test_system_prompt_preserved_in_locked_state() { fn test_system_prompt_preserved_in_locked_state() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let engine = Engine::new(client).system_prompt("Important system prompt"); let engine = Engine::new(client).system_prompt("Important system prompt");
let history: History = History::new();
let locked = engine.lock(); let locked = engine.lock(&history);
assert_eq!(locked.get_system_prompt(), Some("Important system prompt")); assert_eq!(locked.get_system_prompt(), Some("Important system prompt"));
let unlocked = locked.unlock(); let unlocked = locked.unlock();
@@ -551,13 +581,229 @@ fn test_system_prompt_preserved_in_locked_state() {
fn test_system_prompt_change_after_unlock() { fn test_system_prompt_change_after_unlock() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let engine = Engine::new(client).system_prompt("Original prompt"); let engine = Engine::new(client).system_prompt("Original prompt");
let history: History = History::new();
let locked = engine.lock(); let locked = engine.lock(&history);
let mut unlocked = locked.unlock(); let mut unlocked = locked.unlock();
unlocked.set_system_prompt("New prompt"); unlocked.set_system_prompt("New prompt");
assert_eq!(unlocked.get_system_prompt(), Some("New prompt")); assert_eq!(unlocked.get_system_prompt(), Some("New prompt"));
let relocked = unlocked.lock(); let relocked = unlocked.lock(&history);
assert_eq!(relocked.get_system_prompt(), Some("New prompt")); assert_eq!(relocked.get_system_prompt(), Some("New prompt"));
} }
fn completed_text_events() -> Vec<Event> {
vec![
Event::text_block_start(0),
Event::text_delta(0, "done"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
struct YieldOnce {
calls: AtomicUsize,
}
#[async_trait]
impl Interceptor for YieldOnce {
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreRequestAction::Yield
} else {
PreRequestAction::Continue
}
}
}
struct PauseToolOnce {
calls: AtomicUsize,
}
#[async_trait]
impl Interceptor for PauseToolOnce {
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreToolAction::Pause
} else {
PreToolAction::Continue
}
}
}
struct ContinueTurnOnce {
calls: AtomicUsize,
}
#[async_trait]
impl Interceptor for ContinueTurnOnce {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
} else {
TurnEndAction::Finish
}
}
}
#[tokio::test]
async fn max_turns_is_scoped_to_each_fresh_run() {
let responses = vec![completed_text_events(), completed_text_events()];
let mut engine = Engine::new(MockLlmClient::with_responses(responses));
let mut history: History = History::new();
engine.set_max_turns(Some(1));
let mut engine = engine.lock(&history);
assert_eq!(
engine.run(&mut history, "first").await.unwrap(),
EngineResult::Finished
);
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(
engine.run(&mut history, "second").await.unwrap(),
EngineResult::Finished
);
assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.active_run_turn_count(), None);
}
#[tokio::test]
async fn yielded_resume_keeps_the_same_unspent_turn_budget() {
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
let mut history: History = History::new();
engine.set_max_turns(Some(1));
engine.set_interceptor(YieldOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock(&history);
assert_eq!(
engine.run(&mut history, "start").await.unwrap(),
EngineResult::Yielded
);
assert_eq!(engine.turn_count(), 0);
assert_eq!(engine.active_run_turn_count(), Some(0));
assert_eq!(
engine.resume(&mut history).await.unwrap(),
EngineResult::Finished
);
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
}
#[tokio::test]
async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
let events = vec![
Event::tool_use_start(0, "call_1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(MockLlmClient::new(events));
let mut history: History = History::new();
engine.set_max_turns(Some(1));
engine.register_tool(tool.definition());
engine.set_interceptor(PauseToolOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock(&history);
assert_eq!(
engine.run(&mut history, "call it").await.unwrap(),
EngineResult::Paused
);
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), Some(1));
assert_eq!(tool.call_count(), 0);
assert_eq!(
engine.resume(&mut history).await.unwrap(),
EngineResult::LimitReached
);
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(tool.call_count(), 1, "the consumed turn's tool still runs");
}
#[tokio::test]
async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
let tool_events = vec![
Event::tool_use_start(0, "call_1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![tool_events, completed_text_events()]);
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.set_max_turns(Some(1));
engine.register_tool(tool.definition());
engine.set_interceptor(PauseToolOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock(&history);
assert_eq!(
engine.run(&mut history, "pause").await.unwrap(),
EngineResult::Paused
);
assert_eq!(engine.active_run_turn_count(), Some(1));
assert_eq!(
engine.run(&mut history, "replace").await.unwrap(),
EngineResult::Finished
);
assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(tool.call_count(), 1, "pending-tool semantics are unchanged");
}
#[tokio::test]
async fn interceptor_continuation_consumes_the_logical_run_budget() {
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
let mut history: History = History::new();
engine.set_max_turns(Some(1));
engine.set_interceptor(ContinueTurnOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock(&history);
assert_eq!(
engine.run(&mut history, "start").await.unwrap(),
EngineResult::LimitReached
);
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.llm_call_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
}
#[tokio::test]
async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
let mut history: History = History::new();
engine.set_max_turns(Some(1));
engine.set_turn_count(7);
engine.set_last_run_interrupted(true);
engine.set_active_run_turn_count(Some(1));
let mut engine = engine.lock(&history);
assert_eq!(
engine.resume(&mut history).await.unwrap(),
EngineResult::LimitReached
);
assert_eq!(engine.turn_count(), 7);
assert_eq!(engine.llm_call_count(), 0);
assert_eq!(engine.active_run_turn_count(), None);
}
+21 -9
View File
@@ -6,12 +6,12 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use agen::Engine;
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo}; use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{ use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
}; };
use agen::{Engine, History};
use async_trait::async_trait; use async_trait::async_trait;
mod common; mod common;
@@ -145,6 +145,7 @@ async fn test_parallel_tool_execution() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let tool1 = SlowTool::new("slow_tool_1", 100); let tool1 = SlowTool::new("slow_tool_1", 100);
let tool2 = SlowTool::new("slow_tool_2", 100); let tool2 = SlowTool::new("slow_tool_2", 100);
let tool3 = SlowTool::new("slow_tool_3", 100); let tool3 = SlowTool::new("slow_tool_3", 100);
@@ -159,7 +160,7 @@ async fn test_parallel_tool_execution() {
let start = Instant::now(); let start = Instant::now();
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let _result = engine.run("Run all tools").await; let _result = engine.run(&mut history, "Run all tools").await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
// Verify all tools were called // Verify all tools were called
@@ -205,13 +206,14 @@ async fn test_tool_execution_context_order_and_batch_id() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let contexts = Arc::new(Mutex::new(Vec::new())); let contexts = Arc::new(Mutex::new(Vec::new()));
engine.register_tool(ContextRecordingTool::new("record_a", contexts.clone()).definition()); engine.register_tool(ContextRecordingTool::new("record_a", contexts.clone()).definition());
engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition()); engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition());
engine.register_tool(ContextRecordingTool::new("record_c", contexts.clone()).definition()); engine.register_tool(ContextRecordingTool::new("record_c", contexts.clone()).definition());
let _ = engine.run("record contexts").await; let _ = engine.run(&mut history, "record contexts").await;
let mut contexts = contexts.lock().unwrap().clone(); let mut contexts = contexts.lock().unwrap().clone();
contexts.sort_by_key(|ctx| ctx.call_index); contexts.sort_by_key(|ctx| ctx.call_index);
@@ -256,11 +258,12 @@ async fn test_tool_execution_context_batch_id_changes_between_batches() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let contexts = Arc::new(Mutex::new(Vec::new())); let contexts = Arc::new(Mutex::new(Vec::new()));
engine.register_tool(ContextRecordingTool::new("record", contexts.clone()).definition()); engine.register_tool(ContextRecordingTool::new("record", contexts.clone()).definition());
let _ = engine.run("record batches").await; let _ = engine.run(&mut history, "record batches").await;
let contexts = contexts.lock().unwrap().clone(); let contexts = contexts.lock().unwrap().clone();
assert_eq!(contexts.len(), 2); assert_eq!(contexts.len(), 2);
@@ -298,6 +301,7 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let executed_contexts = Arc::new(Mutex::new(Vec::new())); let executed_contexts = Arc::new(Mutex::new(Vec::new()));
let pre_contexts = Arc::new(Mutex::new(Vec::new())); let pre_contexts = Arc::new(Mutex::new(Vec::new()));
let post_contexts = Arc::new(Mutex::new(Vec::new())); let post_contexts = Arc::new(Mutex::new(Vec::new()));
@@ -344,7 +348,9 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
post_contexts: post_contexts.clone(), post_contexts: post_contexts.clone(),
}); });
let _ = engine.run("record skipped and synthetic contexts").await; let _ = engine
.run(&mut history, "record skipped and synthetic contexts")
.await;
let mut pre_contexts = pre_contexts.lock().unwrap().clone(); let mut pre_contexts = pre_contexts.lock().unwrap().clone();
pre_contexts.sort_by_key(|ctx| ctx.call_index); pre_contexts.sort_by_key(|ctx| ctx.call_index);
@@ -389,6 +395,7 @@ async fn test_before_tool_call_skip() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let allowed_tool = SlowTool::new("allowed_tool", 10); let allowed_tool = SlowTool::new("allowed_tool", 10);
let blocked_tool = SlowTool::new("blocked_tool", 10); let blocked_tool = SlowTool::new("blocked_tool", 10);
@@ -416,7 +423,7 @@ async fn test_before_tool_call_skip() {
engine.set_interceptor(BlockingPolicy); engine.set_interceptor(BlockingPolicy);
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let _result = engine.run("Test hook").await; let _result = engine.run(&mut history, "Test hook").await;
// allowed_tool is called, but blocked_tool is not // allowed_tool is called, but blocked_tool is not
assert_eq!( assert_eq!(
@@ -457,6 +464,7 @@ async fn test_post_tool_call_modification() {
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
#[derive(Clone)] #[derive(Clone)]
struct SimpleTool; struct SimpleTool;
@@ -503,7 +511,7 @@ async fn test_post_tool_call_modification() {
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run("Test modification").await; let result = engine.run(&mut history, "Test modification").await;
assert!(result.is_ok(), "Engine should complete"); assert!(result.is_ok(), "Engine should complete");
@@ -540,6 +548,7 @@ async fn test_before_tool_call_synthetic_result_committed() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let blocked_tool = SlowTool::new("blocked_tool", 10); let blocked_tool = SlowTool::new("blocked_tool", 10);
let blocked_clone = blocked_tool.clone(); let blocked_clone = blocked_tool.clone();
engine.register_tool(blocked_tool.definition()); engine.register_tool(blocked_tool.definition());
@@ -558,10 +567,13 @@ async fn test_before_tool_call_synthetic_result_committed() {
engine.set_interceptor(SyntheticPolicy); engine.set_interceptor(SyntheticPolicy);
let result = engine.run("Test synthetic result").await.unwrap(); let _result = engine
.run(&mut history, "Test synthetic result")
.await
.unwrap();
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run"); assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
assert!(result.engine.history().iter().any(|item| matches!( assert!(history.items().any(|item| matches!(
item, item,
agen::Item::ToolResult { agen::Item::ToolResult {
call_id, call_id,
+23 -19
View File
@@ -13,12 +13,12 @@
mod common; mod common;
use agen::Engine;
use agen::Item; use agen::Item;
use agen::llm_client::event::{ use agen::llm_client::event::{
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus, BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
StatusEvent, StatusEvent,
}; };
use agen::{Engine, History};
use common::MockLlmClient; use common::MockLlmClient;
fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> { fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> {
@@ -65,15 +65,15 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
]); ]);
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let out = engine.run("question?").await.expect("run ok"); let mut history: History = History::new();
let engine = out.engine; let _out = engine.run(&mut history, "question?").await.expect("run ok");
let history = engine.history(); let entries = history.entries();
// user / reasoning / assistant_message // user / reasoning / assistant_message
assert_eq!(history.len(), 3, "history: {history:?}"); assert_eq!(history.len(), 3, "history: {history:?}");
assert!(matches!(history[0], Item::Message { .. })); assert!(matches!(entries[0].item, Item::Message { .. }));
match &history[1] { match &entries[1].item {
Item::Reasoning { Item::Reasoning {
text, signature, .. text, signature, ..
} => { } => {
@@ -82,7 +82,7 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
} }
other => panic!("expected Reasoning, got {other:?}"), other => panic!("expected Reasoning, got {other:?}"),
} }
assert_eq!(history[2].as_text(), Some("Here's the answer")); assert_eq!(entries[2].item.as_text(), Some("Here's the answer"));
} }
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が /// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
@@ -109,11 +109,11 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
]); ]);
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let out = engine.run("q").await.expect("run ok"); let mut history: History = History::new();
let engine = out.engine; let _out = engine.run(&mut history, "q").await.expect("run ok");
let history = engine.history(); let entries = history.entries();
match &history[1] { match &entries[1].item {
Item::Reasoning { Item::Reasoning {
text, text,
summary, summary,
@@ -155,13 +155,13 @@ async fn reasoning_precedes_text_in_assistant_burst() {
})); }));
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let out = engine.run("q").await.expect("run ok"); let mut history: History = History::new();
let engine = out.engine; let _out = engine.run(&mut history, "q").await.expect("run ok");
let history = engine.history(); let entries = history.entries();
// user / reasoning(先頭) / assistant_message // user / reasoning(先頭) / assistant_message
assert!(matches!(history[1], Item::Reasoning { .. })); assert!(matches!(entries[1].item, Item::Reasoning { .. }));
assert_eq!(history[2].as_text(), Some("intermediate")); assert_eq!(entries[2].item.as_text(), Some("intermediate"));
} }
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に /// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
@@ -207,14 +207,18 @@ async fn injected_reasoning_survives_into_outgoing_request() {
}; };
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
// resume: 既存 history を流し込む // resume: 既存 history を流し込む
engine.set_history(vec![ engine.set_history(
&mut history,
vec![
Item::user_message("prior question"), Item::user_message("prior question"),
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"), Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
Item::assistant_message("prior answer"), Item::assistant_message("prior answer"),
]); ],
);
let _ = engine.run("follow up").await.expect("run ok"); let _ = engine.run(&mut history, "follow up").await.expect("run ok");
let req = captured let req = captured
.lock() .lock()
+3 -2
View File
@@ -1,4 +1,4 @@
use agen::Engine; use agen::{Engine, History};
use agen::llm_client::capability::{ use agen::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport, CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
}; };
@@ -22,7 +22,8 @@ fn main() {
cap, cap,
); );
let engine = Engine::new(client); let engine = Engine::new(client);
let mut locked = engine.lock(); let history = History::new();
let mut locked = engine.lock(&history);
let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused")); let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused"));
let _ = locked.register_tool(def); let _ = locked.register_tool(def);
} }
@@ -1,8 +1,8 @@
error[E0599]: no method named `register_tool` found for struct `Engine<HttpTransport<AnthropicScheme>, Locked>` in the current scope error[E0599]: no method named `register_tool` found for struct `Engine<HttpTransport<AnthropicScheme>, Locked>` in the current scope
--> tests/ui/locked_register_tool.rs:27:20 --> tests/ui/locked_register_tool.rs:28:20
| |
27 | let _ = locked.register_tool(def); 28 | let _ = locked.register_tool(def);
| ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>` | ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>`
| |
= note: the method was found for = note: the method was found for
- `Engine<C>` - `Engine<C, Mutable, A>`
+7 -1
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use workspace_api::{RepositoryObservedStatus, RepositorySource};
const DEFAULT_WORKSPACE_LIMIT: usize = 200; const DEFAULT_WORKSPACE_LIMIT: usize = 200;
@@ -44,8 +45,13 @@ pub struct CreateBackendWorkspaceRepositoryRecord {
pub repository_id: String, pub repository_id: String,
pub name: String, pub name: String,
pub kind: String, pub kind: String,
pub uri: String, pub provider: Option<String>,
pub source: RepositorySource,
pub default_ref: Option<String>, pub default_ref: Option<String>,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
pub observed_at: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
+9 -5
View File
@@ -26,7 +26,7 @@ struct BackendWorkerLaunchOptions {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct BackendWorkerLaunchRuntime { struct BackendWorkerLaunchRuntime {
runtime_id: String, runtime_id: String,
can_spawn_worker: bool, worker_creation_available: bool,
working_directory_required: bool, working_directory_required: bool,
} }
@@ -261,7 +261,7 @@ impl BackendWorkspaceProductClient {
let runtime = options let runtime = options
.runtimes .runtimes
.iter() .iter()
.find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required) .find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
.ok_or_else(|| { .ok_or_else(|| {
BackendWorkspaceClientError::InvalidTarget( BackendWorkspaceClientError::InvalidTarget(
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker" "Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
@@ -473,8 +473,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
.map_err(ticket_client_error) .map_err(ticket_client_error)
} }
fn queue_ready(&self, id: TicketIdOrSlug, _queued_by: &str) -> ticket::Result<()> { fn queue_ready(
self.send_unit::<()>( &self,
id: TicketIdOrSlug,
_queued_by: &str,
) -> ticket::Result<ticket::TicketQueueOutcome> {
self.send_json::<(), _>(
Method::POST, Method::POST,
&format!( &format!(
"/tickets/{}/workflow/queue", "/tickets/{}/workflow/queue",
@@ -773,7 +777,7 @@ mod tests {
let (base_url, requests, handle) = response_sequence_server(vec![ let (base_url, requests, handle) = response_sequence_server(vec![
( (
"200 OK", "200 OK",
r#"{"runtimes":[{"runtime_id":"embedded","can_spawn_worker":true,"working_directory_required":false}]}"#, r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#,
), ),
( (
"200 OK", "200 OK",
+26 -2
View File
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
match slug { match slug {
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource { CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG, slug: CODER_REVIEW_FLOW_SLUG,
revision: 3, revision: 4,
path: "builtin/flows/coder-review.dcdl", path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE, content: CODER_REVIEW_FLOW_SOURCE,
}), }),
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] { pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource { const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG, slug: CODER_REVIEW_FLOW_SLUG,
revision: 3, revision: 4,
path: "builtin/flows/coder-review.dcdl", path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE, content: CODER_REVIEW_FLOW_SOURCE,
}]; }];
@@ -46,6 +46,30 @@ pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn coder_review_flow_uses_current_selector_ref_review_contract() {
let source = builtin_flow_source(CODER_REVIEW_FLOW_SLUG).expect("coder review Flow");
for required in [
"OpenMergeRequest",
"ShowMergeRequest",
"ReviewMergeRequest",
"CompleteMergeRequest",
"existing Merge Request `selector_from`",
"Target-only movement does not invalidate",
] {
assert!(source.content.contains(required), "missing {required}");
}
for stale in [
"MergeRequestOpen",
"MergeRequestShow",
"MergeRequestReview",
"MergeRequestComplete",
"new immutable revision",
] {
assert!(!source.content.contains(stale), "stale contract {stale}");
}
}
#[test] #[test]
fn every_builtin_flow_compiles_and_matches_catalog_identity() { fn every_builtin_flow_compiles_and_matches_catalog_identity() {
assert!(!builtin_flow_sources().is_empty()); assert!(!builtin_flow_sources().is_empty());
+4 -1
View File
@@ -9,7 +9,7 @@
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef}; use crate::schema::{EvidenceKind, EvidenceOrigin, SourceEvidenceRef, SourceRef};
/// Current flat staging schema version. /// Current flat staging schema version.
pub const STAGING_SCHEMA_VERSION: u32 = 2; pub const STAGING_SCHEMA_VERSION: u32 = 2;
@@ -80,6 +80,8 @@ pub struct StagingEvidence {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_range: Option<[u64; 2]>, pub entry_range: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<EvidenceOrigin>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excerpt: Option<String>, pub excerpt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>, pub summary: Option<String>,
@@ -159,6 +161,7 @@ mod tests {
id: "E001".into(), id: "E001".into(),
kind: EvidenceKind::new(EvidenceKind::MESSAGE), kind: EvidenceKind::new(EvidenceKind::MESSAGE),
entry_range: Some([10, 12]), entry_range: Some([10, 12]),
origin: None,
excerpt: Some("extract candidate taxonomy".into()), excerpt: Some("extract candidate taxonomy".into()),
summary: Some("User and assistant discussed staging kinds".into()), summary: Some("User and assistant discussed staging kinds".into()),
}; };
+37
View File
@@ -67,6 +67,40 @@ impl EvidenceKind {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceOriginKind {
HumanInput,
WorkerInput,
FlowInstruction,
BackendInstruction,
ModelOutput,
ToolOutput,
DerivedSummary,
LegacyUnknown,
}
/// Bounded origin snapshot attached to extraction evidence. This is audit
/// metadata only and cannot authorize Workspace operations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EvidenceOrigin {
pub kind: EvidenceOriginKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_revision: Option<u64>,
}
/// Host-resolved source/evidence metadata for an individual staging claim. /// Host-resolved source/evidence metadata for an individual staging claim.
/// ///
/// This deliberately stores only bounded anchor metadata: stable ids, entry /// This deliberately stores only bounded anchor metadata: stable ids, entry
@@ -86,6 +120,9 @@ pub struct SourceEvidenceRef {
/// Host-assigned evidence id within the referenced evidence set. /// Host-assigned evidence id within the referenced evidence set.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_id: Option<String>, pub evidence_id: Option<String>,
/// Trusted typed origin snapshot for this logical evidence entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<EvidenceOrigin>,
/// Extensible evidence kind tag. /// Extensible evidence kind tag.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_kind: Option<EvidenceKind>, pub evidence_kind: Option<EvidenceKind>,
+4 -1
View File
@@ -10,7 +10,10 @@ mod decision;
mod request; mod request;
mod summary; mod summary;
pub use common::{EvidenceKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter}; pub use common::{
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, Frontmatter, SourceEvidenceRef, SourceRef,
split_frontmatter,
};
pub use decision::{DecisionFrontmatter, DecisionStatus}; pub use decision::{DecisionFrontmatter, DecisionStatus};
pub use request::RequestFrontmatter; pub use request::RequestFrontmatter;
pub use summary::SummaryFrontmatter; pub use summary::SummaryFrontmatter;
+27 -5
View File
@@ -416,7 +416,7 @@ impl MergeRequestStore {
let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?; let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?;
if conflict { if conflict {
return Err(MergeRequestError::Conflict( return Err(MergeRequestError::Conflict(
"Ticket already has an open Merge Request".into(), "Ticket already has an open Merge Request; use ShowMergeRequest and advance the existing selector_from with a normal non-force push instead of opening a replacement Merge Request or adding a revision".into(),
)); ));
} }
let now = i.now.to_rfc3339(); let now = i.now.to_rfc3339();
@@ -575,12 +575,16 @@ impl MergeRequestStore {
)); ));
}; };
if subject != i.current_subject_ref { if subject != i.current_subject_ref {
let reason = format!(
"selector_from moved from requested subject {subject} to current subject {}; fresh review of the exact current source ref is required",
i.current_subject_ref
);
let e = ReviewCancelledEvent { let e = ReviewCancelledEvent {
event_id: Uuid::now_v7().to_string(), event_id: Uuid::now_v7().to_string(),
sequence: next_seq(&t, &ws, &mr)?, sequence: next_seq(&t, &ws, &mr)?,
request_event_id: req, request_event_id: req,
subject_ref: subject, subject_ref: subject,
reason: "selector_from moved before submission".into(), reason,
created_at: i.now, created_at: i.now,
}; };
insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?; insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?;
@@ -667,9 +671,27 @@ impl MergeRequestStore {
} }
match (&i.current_subject_ref, &review) { match (&i.current_subject_ref, &review) {
(None, _) => b.push("selector_from could not be resolved".into()), (None, _) => b.push("selector_from could not be resolved".into()),
(Some(_), None) => b.push("current source ref has no valid review".into()), (Some(subject_ref), None) => {
(_, Some(r)) if r.decision == ReviewDecision::RequestChanges => { let previous_review_subject = mr.thread.iter().rev().find_map(|event| match event {
b.push("current source ref requests changes".into()) MergeRequestThreadEvent::ReviewRequested(value) => {
Some(value.subject_ref.as_str())
}
MergeRequestThreadEvent::Review(value) => Some(value.subject_ref.as_str()),
_ => None,
});
match previous_review_subject.filter(|previous| *previous != subject_ref) {
Some(previous) => b.push(format!(
"selector_from moved from reviewed/requested subject {previous} to current subject {subject_ref}; request a fresh review for this exact source ref (selector_to movement alone does not invalidate source approval)"
)),
None => b.push(format!(
"current source ref {subject_ref} has no valid review; request a fresh review for this exact source ref"
)),
}
}
(Some(subject_ref), Some(r)) if r.decision == ReviewDecision::RequestChanges => {
b.push(format!(
"current source ref {subject_ref} requests changes; advance the existing selector_from with a normal non-force push, then request a fresh review for the exact new source ref"
))
} }
_ => {} _ => {}
} }
+72 -3
View File
@@ -181,16 +181,85 @@ fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_retur
.is_err() .is_err()
); );
let mr = s.get("W", "T").unwrap(); let mr = s.get("W", "T").unwrap();
let cancellation = mr.thread.iter().find_map(|event| match event {
MergeRequestThreadEvent::ReviewCancelled(value) => Some(value),
_ => None,
});
assert!( assert!(
mr.thread cancellation
.iter() .as_ref()
.any(|e| matches!(e, MergeRequestThreadEvent::ReviewCancelled(_))) .is_some_and(|value| value.reason.contains("selector_from moved")
&& value.reason.contains("fresh review"))
); );
assert_eq!( assert_eq!(
mr.effective_review("source-a").map(|r| &r.event_id), mr.effective_review("source-a").map(|r| &r.event_id),
Some(&approved.event_id) Some(&approved.event_id)
); );
} }
#[test]
fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_only_approval() {
let (_d, s) = fixture();
open(&s);
let first = approve(&s, "source-1", "one");
let stale = s
.readiness(ReadinessCheck {
ticket_id: "T".into(),
current_subject_ref: Some("source-2".into()),
auth: auth(),
})
.unwrap();
assert!(!stale.ready);
assert!(stale.review.is_none());
assert!(stale.blockers.iter().any(|blocker| {
blocker.contains("selector_from moved from reviewed/requested subject source-1")
&& blocker.contains("current subject source-2")
&& blocker.contains("fresh review")
}));
assert_eq!(
s.get("W", "T")
.unwrap()
.effective_review("source-1")
.map(|review| &review.event_id),
Some(&first.event_id)
);
let second = approve(&s, "source-2", "two");
let ready = s
.readiness(ReadinessCheck {
ticket_id: "T".into(),
current_subject_ref: Some("source-2".into()),
auth: auth(),
})
.unwrap();
assert!(ready.ready);
assert_eq!(
ready.review.as_ref().map(|review| &review.event_id),
Some(&second.event_id)
);
// The target can move from target-1 to target-2 without changing selector_from
// or invalidating the exact-source approval. Completion consumes refreshed
// integration evidence for the current target pair.
let merged = s
.complete(CompleteMergeRequest {
operation_id: "target-moved".into(),
ticket_id: "T".into(),
current_subject_ref: "source-2".into(),
target_ref_before: "target-2".into(),
target_ref_after: "integrated-target-2".into(),
approval_event_id: second.event_id,
strategy: MergeStrategy::FastForward,
resolution: ConflictResolution::None,
auth: auth(),
now: at(5),
})
.unwrap();
assert_eq!(merged.approved_source_ref, "source-2");
assert_eq!(merged.target_ref_before, "target-2");
assert_eq!(merged.target_ref_after, "integrated-target-2");
}
#[test] #[test]
fn review_revocation_invalidates_readiness() { fn review_revocation_invalidates_readiness() {
let (_d, s) = fixture(); let (_d, s) = fixture();
+85 -28
View File
@@ -281,11 +281,44 @@ impl Method {
/// Presentation category for an Internal Worker exposed through its parent's /// Presentation category for an Internal Worker exposed through its parent's
/// protocol stream. Internal Workers never become independently addressable /// protocol stream. Internal Workers never become independently addressable
/// protocol subjects. /// protocol subjects.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum InternalWorkerKind { pub enum InternalWorkerKind {
SubWorker, SubWorker,
Service { kind: String },
}
/// Stable parent-owned lifecycle for one compaction run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct CompactionLifecycle {
pub schema_version: u32,
pub compaction_id: String,
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub internal_worker: Option<InternalWorkerRef>,
pub state: CompactionLifecycleState,
/// Milliseconds since the Unix epoch.
pub started_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ended_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_segment_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompactionLifecycleState {
Running,
Done,
Failed,
Interrupted,
} }
/// Stable presentation identity for one parent-owned Internal Worker session. /// Stable presentation identity for one parent-owned Internal Worker session.
@@ -607,23 +640,18 @@ pub enum Event {
/// This is not part of LLM history or prompt context; clients may display it /// This is not part of LLM history or prompt context; clients may display it
/// briefly as operational status. /// briefly as operational status.
MemoryWorker(MemoryWorkerEvent), MemoryWorker(MemoryWorkerEvent),
/// Worker has started compacting the current session. /// Worker has started compacting the current session, or bound the run to its
/// /// observable Internal Worker. Revisions upsert one stable lifecycle item.
/// Fired immediately before a compaction run. Success is signalled by CompactStart {
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`. lifecycle: CompactionLifecycle,
/// Broadcast to all clients; not replayed to late subscribers.
CompactStart,
/// Compaction completed and the session was rotated.
///
/// `new_segment_id` is the UUID of the freshly created session that
/// replaced the old history.
CompactDone {
#[cfg_attr(feature = "typescript", ts(type = "string"))]
new_segment_id: uuid::Uuid,
}, },
/// Compaction failed. The session is unchanged. /// Compaction completed and the session was rotated.
CompactDone {
lifecycle: CompactionLifecycle,
},
/// Compaction failed or was cancelled. The session is unchanged.
CompactFailed { CompactFailed {
error: String, lifecycle: CompactionLifecycle,
}, },
Shutdown, Shutdown,
} }
@@ -1377,7 +1405,7 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "completions"); assert_eq!(parsed["event"], "completions");
assert_eq!(parsed["data"]["kind"], "file"); assert_eq!(parsed["data"]["kind"], "file");
assert_eq!(parsed["data"]["entries"][0]["value"], "clear"); assert_eq!(parsed["data"]["entries"][0]["value"], "src/main.rs");
// is_dir defaults to false on inbound payloads that omit it. // is_dir defaults to false on inbound payloads that omit it.
let inbound = let inbound =
@@ -1732,45 +1760,74 @@ mod tests {
assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64); assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64);
} }
fn test_compaction_lifecycle(state: CompactionLifecycleState) -> CompactionLifecycle {
CompactionLifecycle {
schema_version: 2,
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
revision: 1,
internal_worker: None,
state,
started_at_ms: 1_700_000_000_000,
ended_at_ms: None,
summary: None,
error: None,
new_segment_id: None,
}
}
#[test] #[test]
fn event_compact_start_roundtrip() { fn event_compact_start_roundtrip() {
let event = Event::CompactStart; let event = Event::CompactStart {
lifecycle: test_compaction_lifecycle(CompactionLifecycleState::Running),
};
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
assert_eq!(json, r#"{"event":"compact_start"}"#); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "compact_start");
assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, Event::CompactStart)); assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1));
} }
#[test] #[test]
fn event_compact_done_roundtrip() { fn event_compact_done_roundtrip() {
let id = uuid::Uuid::parse_str("0192f0e8-4d84-7d6e-a000-000000000001").unwrap(); let id = uuid::Uuid::parse_str("0192f0e8-4d84-7d6e-a000-000000000001").unwrap();
let event = Event::CompactDone { new_segment_id: id }; let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Done);
lifecycle.new_segment_id = Some(id.to_string());
lifecycle.summary = Some("accepted summary".into());
let event = Event::CompactDone { lifecycle };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "compact_done"); assert_eq!(parsed["event"], "compact_done");
assert_eq!( assert_eq!(
parsed["data"]["new_segment_id"], parsed["data"]["lifecycle"]["new_segment_id"],
"0192f0e8-4d84-7d6e-a000-000000000001" "0192f0e8-4d84-7d6e-a000-000000000001"
); );
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Event::CompactDone { new_segment_id } => assert_eq!(new_segment_id, id), Event::CompactDone { lifecycle } => {
assert_eq!(
lifecycle.new_segment_id.as_deref(),
Some(id.to_string().as_str())
)
}
other => panic!("expected CompactDone, got {other:?}"), other => panic!("expected CompactDone, got {other:?}"),
} }
} }
#[test] #[test]
fn event_compact_failed_roundtrip() { fn event_compact_failed_roundtrip() {
let event = Event::CompactFailed { let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Failed);
error: "provider 429".into(), lifecycle.error = Some("provider 429".into());
}; let event = Event::CompactFailed { lifecycle };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "compact_failed"); assert_eq!(parsed["event"], "compact_failed");
assert_eq!(parsed["data"]["error"], "provider 429"); assert_eq!(parsed["data"]["lifecycle"]["error"], "provider 429");
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Event::CompactFailed { error } => assert_eq!(error, "provider 429"), Event::CompactFailed { lifecycle } => {
assert_eq!(lifecycle.error.as_deref(), Some("provider 429"))
}
other => panic!("expected CompactFailed, got {other:?}"), other => panic!("expected CompactFailed, got {other:?}"),
} }
} }
+7 -5
View File
@@ -4,11 +4,11 @@ use ts_rs::{Config, TS};
use crate::{ use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream, Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
WorkerStatus, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, WorkerStatus,
subscription::{ subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame, EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest, SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -53,6 +53,8 @@ pub fn generated_protocol_types() -> String {
push_decl::<CommandStreamSlice>(&cfg, &mut output); push_decl::<CommandStreamSlice>(&cfg, &mut output);
push_decl::<CommandSnapshot>(&cfg, &mut output); push_decl::<CommandSnapshot>(&cfg, &mut output);
push_decl::<CommandEvent>(&cfg, &mut output); push_decl::<CommandEvent>(&cfg, &mut output);
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
push_decl::<CompactionLifecycle>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output); push_decl::<ScopeRule>(&cfg, &mut output);
push_decl::<CompletionEntry>(&cfg, &mut output); push_decl::<CompletionEntry>(&cfg, &mut output);
push_decl::<RewindTargetId>(&cfg, &mut output); push_decl::<RewindTargetId>(&cfg, &mut output);
+180
View File
@@ -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()
}
+6
View File
@@ -32,6 +32,7 @@
pub mod event_trace; pub mod event_trace;
pub mod fs_store; pub mod fs_store;
pub mod history;
pub mod logged_item; pub mod logged_item;
pub mod segment; pub mod segment;
pub mod segment_log; pub mod segment_log;
@@ -44,6 +45,11 @@ pub use agen::UsageRecord;
pub use agen::llm_client::types::{ContentPart, Item, Role}; pub use agen::llm_client::types::{ContentPart, Item, Role};
pub use event_trace::{TraceEntry, TracePayload}; pub use event_trace::{TraceEntry, TracePayload};
pub use fs_store::FsStore; pub use fs_store::FsStore;
pub use history::{
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
LoggedWorkerSubject, legacy_logged_history, legacy_segment_history,
};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use segment::{ pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_history_item, SegmentStartState, append_entry, append_system_item, classify_history_item,
+2
View File
@@ -307,6 +307,7 @@ pub fn save_run_completed(
segment_id: SegmentId, segment_id: SegmentId,
result: EngineResult, result: EngineResult,
interrupted: bool, interrupted: bool,
active_run_turn_count: Option<usize>,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
append_entry( append_entry(
store, store,
@@ -316,6 +317,7 @@ pub fn save_run_completed(
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
interrupted, interrupted,
result, result,
active_run_turn_count,
}, },
) )
} }
+209 -2
View File
@@ -14,6 +14,7 @@ use agen::{EngineResult, UsageRecord};
use protocol::{InvokeKind, Segment}; use protocol::{InvokeKind, Segment};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
use crate::logged_item::LoggedItem; use crate::logged_item::LoggedItem;
use crate::system_item::SystemItem; use crate::system_item::SystemItem;
@@ -70,6 +71,20 @@ pub enum LogEntry {
compacted_from: Option<SegmentOrigin>, compacted_from: Option<SegmentOrigin>,
}, },
/// Schema-v2 segment seed. Retained entries keep their stable logical
/// identity and origin across fork/compaction/restore.
AnnotatedSegmentStart {
ts: u64,
session_id: crate::SessionId,
system_prompt: Option<String>,
config: RequestConfig,
history: Vec<LoggedHistoryEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from: Option<SegmentOrigin>,
#[serde(default, skip_serializing_if = "Option::is_none")]
compacted_from: Option<SegmentOrigin>,
},
/// IDLE → active marker. Records the start of a new self-driving /// IDLE → active marker. Records the start of a new self-driving
/// cycle (Invoke range). The range extends implicitly until the /// cycle (Invoke range). The range extends implicitly until the
/// next `Invoke` entry; this entry carries the trigger only — the /// next `Invoke` entry; this entry carries the trigger only — the
@@ -105,14 +120,37 @@ pub enum LogEntry {
extensions: Vec<SessionExtension>, extensions: Vec<SessionExtension>,
}, },
/// Schema-v2 user submission with its exact model-visible entries. Typed
/// Flow instructions and caller-attributed input remain separate entries.
AnnotatedUserInput {
ts: u64,
segments: Vec<Segment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
history: Vec<LoggedHistoryEntry>,
},
/// Schema-v2 model output and metadata committed as one journal record.
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
/// One assistant-side item appended to history — assistant message, /// One assistant-side item appended to history — assistant message,
/// reasoning, or tool call. Singular: one entry per history item so /// reasoning, or tool call. Singular: one entry per history item so
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1. /// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
AssistantItem { ts: u64, item: LoggedItem }, AssistantItem { ts: u64, item: LoggedItem },
/// Schema-v2 tool output and metadata committed as one journal record.
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
/// One tool-execution result appended to history. /// One tool-execution result appended to history.
ToolResult { ts: u64, item: LoggedItem }, ToolResult { ts: u64, item: LoggedItem },
/// Schema-v2 typed system event and model-visible metadata committed
/// together.
AnnotatedSystemItem {
ts: u64,
entry: LoggedSystemHistoryEntry,
},
/// One typed agent-injected system item: notification, child-Worker /// One typed agent-injected system item: notification, child-Worker
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each /// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
/// `SystemItem` carries kind metadata that the LLM /// `SystemItem` carries kind metadata that the LLM
@@ -125,11 +163,16 @@ pub enum LogEntry {
TurnEnd { ts: u64, turn_count: usize }, TurnEnd { ts: u64, turn_count: usize },
/// `run()` / `resume()` が `EngineResult` で正常終了した。 /// `run()` / `resume()` が `EngineResult` で正常終了した。
/// Audit-only metadata: replay は `interrupted` のみ反映する。 /// Replay restores both interruption state and any resumable logical-run
/// turn budget.
RunCompleted { RunCompleted {
ts: u64, ts: u64,
interrupted: bool, interrupted: bool,
result: EngineResult, result: EngineResult,
/// AgentTurns consumed by a paused/yielded logical run. Terminal
/// outcomes persist `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
active_run_turn_count: Option<usize>,
}, },
/// `run()` / `resume()` が `EngineError` で終了した。 /// `run()` / `resume()` が `EngineError` で終了した。
@@ -141,6 +184,15 @@ pub enum LogEntry {
message: String, message: String,
}, },
/// Restores an active logical-run budget at a segment boundary, notably
/// after compaction replaced the segment that held the original Invoke and
/// RunCompleted entries.
ActiveRunCheckpoint {
ts: u64,
active_turn_count: usize,
total_turn_count: usize,
},
/// A paused interrupted turn was explicitly abandoned without calling /// A paused interrupted turn was explicitly abandoned without calling
/// `run()` or `resume()` again. Replay clears the interrupted marker so /// `run()` or `resume()` again. Replay clears the interrupted marker so
/// the restored Worker is idle and future user input starts a normal new turn. /// the restored Worker is idle and future user input starts a normal new turn.
@@ -209,6 +261,8 @@ pub struct RestoredState {
pub config: RequestConfig, pub config: RequestConfig,
pub history: Vec<Item>, pub history: Vec<Item>,
pub turn_count: usize, pub turn_count: usize,
/// AgentTurns consumed by the active paused/yielded logical run.
pub active_run_turn_count: Option<usize>,
pub last_run_interrupted: bool, pub last_run_interrupted: bool,
/// Number of entries replayed. `0` means the segment log was empty. /// Number of entries replayed. `0` means the segment log was empty.
/// Writers track their own append count via the same counter so /// Writers track their own append count via the same counter so
@@ -238,6 +292,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
config: RequestConfig::default(), config: RequestConfig::default(),
history: Vec::new(), history: Vec::new(),
turn_count: 0, turn_count: 0,
active_run_turn_count: None,
last_run_interrupted: false, last_run_interrupted: false,
entries_count: 0, entries_count: 0,
usage_history: Vec::new(), usage_history: Vec::new(),
@@ -261,10 +316,27 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.config = config.clone(); state.config = config.clone();
state.history = history.iter().cloned().map(Item::from).collect(); state.history = history.iter().cloned().map(Item::from).collect();
} }
LogEntry::AnnotatedSegmentStart {
session_id,
system_prompt,
config,
history,
..
} => {
state.session_id = Some(*session_id);
state.system_prompt = system_prompt.clone();
state.config = config.clone();
state.history = history
.iter()
.cloned()
.map(|entry| Item::from(entry.item))
.collect();
}
LogEntry::Invoke { .. } => { LogEntry::Invoke { .. } => {
// A terminal run record below clears or refines this. If the // A terminal run record below clears or refines this. If the
// log ends first, restore must treat the turn as interrupted. // log ends first, restore must treat the turn as interrupted.
state.last_run_interrupted = true; state.last_run_interrupted = true;
state.active_run_turn_count = Some(0);
} }
LogEntry::UserInput { LogEntry::UserInput {
segments, segments,
@@ -280,6 +352,29 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
.map(|extension| (extension.domain.clone(), extension.payload.clone())), .map(|extension| (extension.domain.clone(), extension.payload.clone())),
); );
} }
LogEntry::AnnotatedUserInput {
segments,
extensions,
history,
..
} => {
state
.history
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
state.user_segments.push(segments.clone());
state.extensions.extend(
extensions
.iter()
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => {
state.history.push(Item::from(entry.item.clone()));
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
state.history.push(entry.item.to_history_item());
}
LogEntry::AssistantItem { item, .. } => { LogEntry::AssistantItem { item, .. } => {
state.history.push(Item::from(item.clone())); state.history.push(Item::from(item.clone()));
} }
@@ -290,16 +385,44 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.history.push(item.to_history_item()); state.history.push(item.to_history_item());
} }
LogEntry::TurnEnd { turn_count, .. } => { LogEntry::TurnEnd { turn_count, .. } => {
if let Some(active_turn_count) = &mut state.active_run_turn_count {
*active_turn_count += turn_count.saturating_sub(state.turn_count);
}
state.turn_count = *turn_count; state.turn_count = *turn_count;
} }
LogEntry::RunCompleted { interrupted, .. } => { LogEntry::RunCompleted {
interrupted,
result,
active_run_turn_count,
..
} => {
state.last_run_interrupted = *interrupted; state.last_run_interrupted = *interrupted;
if *interrupted && matches!(result, EngineResult::Paused | EngineResult::Yielded) {
// Legacy entries omit the explicit field; retain the
// Invoke/TurnEnd-derived count in that case.
if let Some(turn_count) = active_run_turn_count {
state.active_run_turn_count = Some(*turn_count);
}
} else {
state.active_run_turn_count = None;
}
} }
LogEntry::RunErrored { interrupted, .. } => { LogEntry::RunErrored { interrupted, .. } => {
state.last_run_interrupted = *interrupted; state.last_run_interrupted = *interrupted;
state.active_run_turn_count = None;
}
LogEntry::ActiveRunCheckpoint {
active_turn_count,
total_turn_count,
..
} => {
state.active_run_turn_count = Some(*active_turn_count);
state.turn_count = *total_turn_count;
state.last_run_interrupted = true;
} }
LogEntry::PausedTurnAbandoned { .. } => { LogEntry::PausedTurnAbandoned { .. } => {
state.last_run_interrupted = false; state.last_run_interrupted = false;
state.active_run_turn_count = None;
} }
LogEntry::ConfigChanged { config, .. } => { LogEntry::ConfigChanged { config, .. } => {
state.config = config.clone(); state.config = config.clone();
@@ -397,6 +520,7 @@ mod tests {
ts: 3200, ts: 3200,
interrupted: false, interrupted: false,
result: EngineResult::Finished, result: EngineResult::Finished,
active_run_turn_count: None,
}, },
]); ]);
assert_eq!(state.history.len(), 2); assert_eq!(state.history.len(), 2);
@@ -695,10 +819,93 @@ mod tests {
ts: 100, ts: 100,
interrupted: true, interrupted: true,
result: EngineResult::Paused, result: EngineResult::Paused,
active_run_turn_count: Some(1),
}, },
LogEntry::PausedTurnAbandoned { ts: 200 }, LogEntry::PausedTurnAbandoned { ts: 200 },
]); ]);
assert!(!state.last_run_interrupted); assert!(!state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, None);
}
#[test]
fn replay_restores_active_run_budget_across_compaction_checkpoint() {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
LogEntry::ActiveRunCheckpoint {
ts: 100,
active_turn_count: 3,
total_turn_count: 9,
},
]);
assert_eq!(state.turn_count, 9);
assert_eq!(state.active_run_turn_count, Some(3));
assert!(state.last_run_interrupted);
}
#[test]
fn legacy_interrupted_run_derives_budget_from_invoke_and_turn_end() {
let entry: LogEntry = serde_json::from_value(serde_json::json!({
"kind": "run_completed",
"ts": 300,
"interrupted": true,
"result": "paused"
}))
.expect("legacy run-completed entry");
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
LogEntry::Invoke {
ts: 100,
trigger: InvokeKind::UserSend,
},
LogEntry::TurnEnd {
ts: 200,
turn_count: 2,
},
entry,
]);
assert_eq!(state.active_run_turn_count, Some(2));
assert!(state.last_run_interrupted);
}
#[test]
fn non_resumable_interruption_clears_the_active_run_budget() {
let state = collect_state(&[
LogEntry::Invoke {
ts: 100,
trigger: InvokeKind::UserSend,
},
LogEntry::TurnEnd {
ts: 200,
turn_count: 2,
},
LogEntry::RunCompleted {
ts: 300,
interrupted: true,
result: EngineResult::LimitReached,
active_run_turn_count: None,
},
]);
assert!(state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, None);
} }
#[test] #[test]
@@ -20,7 +20,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
const SESSION_SCHEMA_VERSION: u32 = 1; const SESSION_SCHEMA_VERSION: u32 = 2;
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
const SESSION_FILE: &str = "session.json"; const SESSION_FILE: &str = "session.json";
const SEGMENTS_DIR: &str = "segments"; const SEGMENTS_DIR: &str = "segments";
@@ -44,16 +45,23 @@ impl WorkerSessionStore {
fs::create_dir_all(root.join(SEGMENTS_DIR))?; fs::create_dir_all(root.join(SEGMENTS_DIR))?;
let session_id = match fs::read(root.join(SESSION_FILE)) { let session_id = match fs::read(root.join(SESSION_FILE)) {
Ok(bytes) => { Ok(bytes) => {
let manifest: SessionManifest = serde_json::from_slice(&bytes)?; let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
if manifest.schema_version != SESSION_SCHEMA_VERSION { match manifest.schema_version {
SESSION_SCHEMA_VERSION => {}
LEGACY_SESSION_SCHEMA_VERSION => {
validate_legacy_segment_logs(&root)?;
manifest.schema_version = SESSION_SCHEMA_VERSION;
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
}
version => {
return Err(StoreError::Corrupt { return Err(StoreError::Corrupt {
line: 0, line: 0,
message: format!( message: format!(
"unsupported Worker Session schema version {}, expected {}", "unsupported Worker Session schema version {version}, expected {SESSION_SCHEMA_VERSION}"
manifest.schema_version, SESSION_SCHEMA_VERSION
), ),
}); });
} }
}
Some(manifest.session_id) Some(manifest.session_id)
} }
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
@@ -278,6 +286,37 @@ impl Store for WorkerSessionStore {
} }
} }
fn validate_legacy_segment_logs(root: &Path) -> Result<(), StoreError> {
let segments = root.join(SEGMENTS_DIR);
if !segments.exists() {
return Ok(());
}
for entry in fs::read_dir(&segments)? {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if !name.ends_with(".jsonl") || name.ends_with(".trace.jsonl") {
continue;
}
let contents = fs::read_to_string(&path)?;
for (line_index, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
serde_json::from_str::<LogEntry>(line).map_err(|error| StoreError::Corrupt {
line: line_index + 1,
message: format!(
"cannot migrate legacy Worker Session log {}: {error}",
path.display()
),
})?;
}
}
Ok(())
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> { fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
let mut bytes = serde_json::to_vec_pretty(value)?; let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n'); bytes.push(b'\n');
@@ -405,6 +444,54 @@ mod tests {
assert_eq!(store.list_sessions().unwrap(), vec![session_id]); assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
} }
#[test]
fn schema_v1_logs_are_validated_and_promoted_to_v2() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
WorkerSessionStore::new(root.path())
.unwrap()
.create_segment(session_id, segment_id, &[])
.unwrap();
let manifest_path = root.path().join(SESSION_FILE);
let mut manifest: SessionManifest =
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
manifest.schema_version = LEGACY_SESSION_SCHEMA_VERSION;
atomic_write_json(&manifest_path, &manifest).unwrap();
let reopened = WorkerSessionStore::new(root.path()).unwrap();
assert_eq!(reopened.session_id().unwrap(), Some(session_id));
let migrated: SessionManifest =
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
assert_eq!(migrated.schema_version, SESSION_SCHEMA_VERSION);
}
#[test]
fn schema_v1_migration_rejects_corrupt_log_before_manifest_update() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let manifest = SessionManifest {
schema_version: LEGACY_SESSION_SCHEMA_VERSION,
session_id,
};
atomic_write_json(&root.path().join(SESSION_FILE), &manifest).unwrap();
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
fs::write(
root.path().join(SEGMENTS_DIR).join("broken.jsonl"),
"{not-json}\n",
)
.unwrap();
let error = match WorkerSessionStore::new(root.path()) {
Ok(_) => panic!("corrupt legacy Session log must reject migration"),
Err(error) => error,
};
assert!(matches!(error, StoreError::Corrupt { .. }));
let persisted: SessionManifest =
serde_json::from_slice(&fs::read(root.path().join(SESSION_FILE)).unwrap()).unwrap();
assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION);
}
#[test] #[test]
fn reopen_preserves_session_and_segment_ids() { fn reopen_preserves_session_and_segment_ids() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
@@ -51,6 +51,7 @@ fn round_trip_write_and_read() {
ts: 3200, ts: 3200,
interrupted: false, interrupted: false,
result: EngineResult::Finished, result: EngineResult::Finished,
active_run_turn_count: None,
}, },
]; ];
+64 -28
View File
@@ -1,12 +1,13 @@
mod common; mod common;
use std::ops::{Deref, DerefMut};
use std::sync::Arc; use std::sync::Arc;
use agen::Engine;
use agen::interceptor::{Interceptor, TurnEndAction}; use agen::interceptor::{Interceptor, TurnEndAction};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::types::{Item, RequestConfig}; use agen::llm_client::types::{Item, RequestConfig};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, History};
use async_trait::async_trait; use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state}; use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
@@ -94,15 +95,47 @@ fn make_store() -> (tempfile::TempDir, FsStore) {
(dir, store) (dir, store)
} }
struct TestWorker {
engine: Engine<MockLlmClient>,
history: History,
}
impl TestWorker {
fn new(engine: Engine<MockLlmClient>) -> Self {
Self {
engine,
history: History::new(),
}
}
fn history(&self) -> Vec<Item> {
self.history.items_cloned()
}
}
impl Deref for TestWorker {
type Target = Engine<MockLlmClient>;
fn deref(&self) -> &Self::Target {
&self.engine
}
}
impl DerefMut for TestWorker {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.engine
}
}
/// Run a worker turn and persist via session-store functions. /// Run a worker turn and persist via session-store functions.
/// Takes ownership of the worker (needed for lock/unlock) and returns it. /// Takes ownership of the worker (needed for lock/unlock) and returns it.
async fn run_and_persist( async fn run_and_persist(
worker: Engine<MockLlmClient>, mut worker: TestWorker,
store: &FsStore, store: &FsStore,
session_id: session_store::SessionId, session_id: session_store::SessionId,
segment_id: session_store::SegmentId, segment_id: session_store::SegmentId,
input: &str, input: &str,
) -> (Engine<MockLlmClient>, agen::EngineResult) { ) -> (TestWorker, agen::EngineResult) {
// Mirror Worker's run-entry contract: log the user input as segments // Mirror Worker's run-entry contract: log the user input as segments
// before the worker pushes its flattened user_message; save_delta // before the worker pushes its flattened user_message; save_delta
// skips the resulting user_message item to avoid double-write. // skips the resulting user_message item to avoid double-write.
@@ -114,13 +147,14 @@ async fn run_and_persist(
) )
.unwrap(); .unwrap();
let history_before = worker.history().len(); let history_before = worker.history.len();
let mut locked = worker.lock(); let mut locked = worker.engine.lock(&worker.history);
let result = locked.run(input).await; let result = locked.run(&mut worker.history, input).await;
let worker = locked.unlock(); worker.engine = locked.unlock();
let new_items = &worker.history()[history_before..]; let projected = worker.history();
let new_items = &projected[history_before..];
session_store::save_delta(store, session_id, segment_id, new_items).unwrap(); session_store::save_delta(store, session_id, segment_id, new_items).unwrap();
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap(); session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
@@ -132,6 +166,7 @@ async fn run_and_persist(
segment_id, segment_id,
r.clone(), r.clone(),
worker.last_run_interrupted(), worker.last_run_interrupted(),
worker.active_run_turn_count(),
) )
.unwrap(); .unwrap();
} }
@@ -159,14 +194,14 @@ async fn run_and_persist(
async fn session_run_logs_entries() { async fn session_run_logs_entries() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let worker = Engine::new(client); let worker = TestWorker::new(Engine::new(client));
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -203,7 +238,7 @@ async fn session_run_logs_entries() {
async fn session_restore_round_trip() { async fn session_restore_round_trip() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
worker.set_system_prompt("You are helpful."); worker.set_system_prompt("You are helpful.");
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
@@ -211,7 +246,7 @@ async fn session_restore_round_trip() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -242,7 +277,7 @@ async fn session_restore_round_trip() {
async fn session_run_with_tool_call() { async fn session_run_with_tool_call() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::with_responses(tool_call_events()); let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition()); worker.register_tool(weather_tool_definition());
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
@@ -250,7 +285,7 @@ async fn session_run_with_tool_call() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -276,7 +311,7 @@ async fn session_resume_after_pause() {
// First run: tool call with pause policy → Paused // First run: tool call with pause policy → Paused
let client = MockLlmClient::with_responses(tool_call_events()); let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition()); worker.register_tool(weather_tool_definition());
worker.set_interceptor(PausePolicy); worker.set_interceptor(PausePolicy);
@@ -285,7 +320,7 @@ async fn session_resume_after_pause() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -309,13 +344,14 @@ async fn session_resume_after_pause() {
// Restore state and verify // Restore state and verify
let state = session_store::restore(&store, sid, segid).unwrap(); let state = session_store::restore(&store, sid, segid).unwrap();
assert!(state.last_run_interrupted); assert!(state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, Some(2));
} }
#[tokio::test] #[tokio::test]
async fn session_fork_creates_new_session() { async fn session_fork_creates_new_session() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
worker.set_system_prompt("System prompt"); worker.set_system_prompt("System prompt");
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
@@ -323,7 +359,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -336,7 +372,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -357,14 +393,14 @@ async fn session_fork_creates_new_session() {
async fn session_fork_at_truncates_within_session() { async fn session_fork_at_truncates_within_session() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let worker = Engine::new(client); let worker = TestWorker::new(Engine::new(client));
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -402,14 +438,14 @@ async fn session_fork_at_truncates_within_session() {
async fn session_config_changed_logged() { async fn session_config_changed_logged() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -435,14 +471,14 @@ async fn session_auto_forks_on_conflict() {
// Create a segment // Create a segment
let client_a = MockLlmClient::new(simple_text_events()); let client_a = MockLlmClient::new(simple_text_events());
let worker_a = Engine::new(client_a); let worker_a = TestWorker::new(Engine::new(client_a));
let (sid, original_segid) = session_store::create_segment( let (sid, original_segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker_a.get_system_prompt(), system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(), config: worker_a.request_config(),
history: worker_a.history(), history: &worker_a.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -468,7 +504,7 @@ async fn session_auto_forks_on_conflict() {
SegmentStartState { SegmentStartState {
system_prompt: worker_a.get_system_prompt(), system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(), config: worker_a.request_config(),
history: worker_a.history(), history: &worker_a.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -520,14 +556,14 @@ async fn session_auto_forks_on_conflict() {
async fn nested_past_fork_leaves_ancestors_immutable() { async fn nested_past_fork_leaves_ancestors_immutable() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let worker = Engine::new(client); let worker = TestWorker::new(Engine::new(client));
let (sid, root_segid) = session_store::create_segment( let (sid, root_segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
+1103 -198
View File
File diff suppressed because it is too large Load Diff
+20 -6
View File
@@ -142,8 +142,8 @@ const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \ The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
state_changed event, effective target, and planning -> ready transition atomically."; state_changed event, effective target, and planning -> ready transition atomically.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \ const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \ Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
and rejects unresolved blocking relations."; requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \ const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \ Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \ as the implementation acceptance step: implementation side effects should happen only after that \
@@ -316,7 +316,11 @@ impl TicketBackend for TicketToolBackend {
self.backend.mark_ready(id, request) self.backend.mark_ready(id, request)
} }
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> { fn queue_ready(
&self,
id: TicketIdOrSlug,
queued_by: &str,
) -> TicketResult<crate::TicketQueueOutcome> {
self.backend.queue_ready(id, queued_by) self.backend.queue_ready(id, queued_by)
} }
@@ -1219,12 +1223,22 @@ impl Tool for TicketQueueTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?; let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
let queued_by = default_author(); let queued_by = default_author();
self.backend let outcome = self
.backend
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by) .queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
.map_err(|error| backend_error("TicketQueue", error))?; .map_err(|error| backend_error("TicketQueue", error))?;
Ok(json_output( Ok(json_output(
format!("Queued ticket {} for Orchestrator", params.ticket), format!(
json!({ "ticket": params.ticket, "state": "queued", "queued_by": queued_by, "ok": true }), "Queued {} ticket(s) for Orchestrator",
outcome.queued_tickets.len()
),
json!({
"ticket": outcome.requested_ticket,
"queued_tickets": outcome.queued_tickets,
"state": "queued",
"queued_by": queued_by,
"ok": true
}),
)) ))
} }
} }
+8 -3
View File
@@ -131,10 +131,11 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
assert!( assert!(
msg.contains("outside allowed read scope") msg.contains("outside allowed read scope")
|| msg.contains("outside allowed write scope") || msg.contains("outside allowed write scope")
|| msg.contains("outside allowed scope")
|| msg.contains("has not been read"), || msg.contains("has not been read"),
"symlink escape not rejected: {msg}" "symlink escape not rejected: {msg}"
); );
if !msg.contains("has not been read") { if msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope") {
assert!( assert!(
msg.contains("add the symlink target"), msg.contains("add the symlink target"),
"symlink escape diagnostic should include remediation: {msg}" "symlink escape diagnostic should include remediation: {msg}"
@@ -233,12 +234,16 @@ async fn absolute_path_is_rejected() {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(format!("{err}").contains("invalid Workdir path")); let msg = format!("{err}");
assert!(
msg.contains("invalid logical filesystem path"),
"absolute path was not rejected as invalid: {msg}"
);
} }
#[tokio::test] #[tokio::test]
async fn directory_target_is_rejected_for_read() { async fn directory_target_is_rejected_for_read() {
let (dir, _spill, reg) = setup(); let (_dir, _spill, reg) = setup();
let read = reg.get("Read"); let read = reg.get("Read");
let err = read let err = read
.execute(&json!({ "file_path": "." }).to_string(), Default::default()) .execute(&json!({ "file_path": "." }).to_string(), Default::default())
+6 -3
View File
@@ -191,7 +191,7 @@ async fn write_then_grep_finds_content() {
#[tokio::test] #[tokio::test]
async fn glob_finds_written_files() { async fn glob_finds_written_files() {
let (dir, _spill, reg) = setup(); let (_dir, _spill, reg) = setup();
let write = reg.get("Write"); let write = reg.get("Write");
let glob = reg.get("Glob"); let glob = reg.get("Glob");
@@ -229,7 +229,10 @@ async fn absolute_path_is_rejected() {
.await; .await;
// Absolute paths are rejected at the logical WorkdirSession boundary. // Absolute paths are rejected at the logical WorkdirSession boundary.
let msg = format!("{err}"); let msg = format!("{err}");
assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}"); assert!(
msg.contains("invalid logical filesystem path"),
"unexpected: {msg}"
);
} }
#[tokio::test] #[tokio::test]
@@ -340,7 +343,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
)); ));
let a = dir.path().join("a.txt"); let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt"); let _b = dir.path().join("b.txt");
std::fs::write(&a, "one\n").unwrap(); std::fs::write(&a, "one\n").unwrap();
// Read `a` — should appear in recency. // Read `a` — should appear in recency.
+48 -13
View File
@@ -1342,13 +1342,20 @@ impl App {
} }
} }
} }
Event::CompactStart => { Event::CompactStart { .. } => {
if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming { self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(), started_at: Instant::now(),
})); }));
} }
Event::CompactDone { new_segment_id } => { }
Event::CompactDone { lifecycle } => {
self.session_context_tokens = 0; self.session_context_tokens = 0;
let new_segment_id = lifecycle
.new_segment_id
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok())
.unwrap_or_default();
if let Some(evt) = self.last_streaming_compact_mut() { if let Some(evt) = self.last_streaming_compact_mut() {
let elapsed_secs = match evt { let elapsed_secs = match evt {
CompactEvent::Streaming { started_at } => { CompactEvent::Streaming { started_at } => {
@@ -1367,7 +1374,10 @@ impl App {
})); }));
} }
} }
Event::CompactFailed { error } => { Event::CompactFailed { lifecycle } => {
let error = lifecycle
.error
.unwrap_or_else(|| "compaction failed".to_string());
if let Some(evt) = self.last_streaming_compact_mut() { if let Some(evt) = self.last_streaming_compact_mut() {
let elapsed_secs = match evt { let elapsed_secs = match evt {
CompactEvent::Streaming { started_at } => { CompactEvent::Streaming { started_at } => {
@@ -2486,7 +2496,7 @@ fn event_is_stale_after_rewind(event: &Event) -> bool {
event, event,
Event::Alert(_) Event::Alert(_)
| Event::MemoryWorker(_) | Event::MemoryWorker(_)
| Event::CompactStart | Event::CompactStart { .. }
| Event::CompactDone { .. } | Event::CompactDone { .. }
| Event::CompactFailed { .. } | Event::CompactFailed { .. }
| Event::SegmentRotated { .. } | Event::SegmentRotated { .. }
@@ -4076,13 +4086,34 @@ mod completion_flow_tests {
} }
} }
fn test_compaction_lifecycle(
state: protocol::CompactionLifecycleState,
) -> protocol::CompactionLifecycle {
protocol::CompactionLifecycle {
schema_version: 2,
compaction_id: "compaction-test".into(),
revision: 1,
internal_worker: None,
state,
started_at_ms: 1,
ended_at_ms: None,
summary: None,
error: None,
new_segment_id: None,
}
}
#[test] #[test]
fn compact_done_replaces_live_block() { fn compact_done_replaces_live_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap(); let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
app.handle_worker_event(Event::CompactStart); app.handle_worker_event(Event::CompactStart {
app.handle_worker_event(Event::CompactDone { new_segment_id: id }); lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
lifecycle.new_segment_id = Some(id.to_string());
app.handle_worker_event(Event::CompactDone { lifecycle });
assert_eq!(compact_block_count(&app), 1); assert_eq!(compact_block_count(&app), 1);
assert!(matches!( assert!(matches!(
@@ -4098,10 +4129,12 @@ mod completion_flow_tests {
fn compact_failed_replaces_live_block() { fn compact_failed_replaces_live_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(Event::CompactStart); app.handle_worker_event(Event::CompactStart {
app.handle_worker_event(Event::CompactFailed { lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
error: "provider 429".into(),
}); });
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
lifecycle.error = Some("provider 429".into());
app.handle_worker_event(Event::CompactFailed { lifecycle });
assert_eq!(compact_block_count(&app), 1); assert_eq!(compact_block_count(&app), 1);
assert!(matches!( assert!(matches!(
@@ -4117,7 +4150,9 @@ mod completion_flow_tests {
fn shutdown_marks_live_compact_incomplete() { fn shutdown_marks_live_compact_incomplete() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(Event::CompactStart); app.handle_worker_event(Event::CompactStart {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
app.handle_worker_event(Event::Shutdown); app.handle_worker_event(Event::Shutdown);
assert!(app.quit); assert!(app.quit);
@@ -4208,9 +4243,9 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.session_context_tokens = 42_000; app.session_context_tokens = 42_000;
app.handle_worker_event(Event::CompactDone { let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
new_segment_id: uuid::Uuid::nil(), lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
}); app.handle_worker_event(Event::CompactDone { lifecycle });
assert_eq!(app.session_context_tokens, 0); assert_eq!(app.session_context_tokens, 0);
} }
+179 -51
View File
@@ -4,6 +4,7 @@ use std::fmt;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use client::ticket_role::{ use client::ticket_role::{
@@ -580,6 +581,7 @@ pub(crate) enum IntakeRegistryUpdate {
pub(crate) struct ReadyTicketPlanningReturnRequest { pub(crate) struct ReadyTicketPlanningReturnRequest {
workspace_root: PathBuf, workspace_root: PathBuf,
ticket_id: String, ticket_id: String,
ticket_key: String,
user_instruction: String, user_instruction: String,
followup: ReadyTicketPlanningReturnFollowup, followup: ReadyTicketPlanningReturnFollowup,
} }
@@ -2041,11 +2043,18 @@ impl DashboardApp {
return None; return None;
}; };
let ticket_id = ticket.id.clone(); let ticket_id = ticket.id.clone();
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
Ok(ticket_key) => ticket_key.to_string(),
Err(error) => {
self.notice = Some(error);
return None;
}
};
let mut context = let mut context =
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone())); context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(format!( context.user_instruction = Some(format!(
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions." "Continue Intake for existing Ticket {ticket_key}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
)); ));
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) { let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
Ok(store) => store, Ok(store) => store,
@@ -2058,7 +2067,7 @@ impl DashboardApp {
Ok(Some(claim)) => { Ok(Some(claim)) => {
let status = local_claim_status_for_pod(&claim.worker_name, &self.list); let status = local_claim_status_for_pod(&claim.worker_name, &self.list);
self.notice = Some(existing_ticket_claim_notice( self.notice = Some(existing_ticket_claim_notice(
&ticket_id, &ticket_key,
&claim.worker_name, &claim.worker_name,
status, status,
)); ));
@@ -2086,7 +2095,7 @@ impl DashboardApp {
self.sending = true; self.sending = true;
self.notice = Some(format!( self.notice = Some(format!(
"Launching Ticket Intake for {} as {}…", "Launching Ticket Intake for {} as {}…",
ticket_id, planned.worker_name ticket_key, planned.worker_name
)); ));
Some(IntakeLaunchRequest { Some(IntakeLaunchRequest {
context, context,
@@ -2157,10 +2166,17 @@ impl DashboardApp {
return None; return None;
}; };
let ticket_id = ticket.id.clone(); let ticket_id = ticket.id.clone();
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
Ok(ticket_key) => ticket_key.to_string(),
Err(error) => {
self.notice = Some(error);
return None;
}
};
if ticket.workflow_state != TicketWorkflowState::Ready { if ticket.workflow_state != TicketWorkflowState::Ready {
self.notice = Some(format!( self.notice = Some(format!(
"Ticket {} is {}; expected ready before returning to planning.", "Ticket {} is {}; expected ready before returning to planning.",
ticket_id, ticket_key,
ticket.workflow_state.as_str() ticket.workflow_state.as_str()
)); ));
return None; return None;
@@ -2212,7 +2228,7 @@ impl DashboardApp {
TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake); TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone())); context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction( context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction(
&ticket_id, &ticket_key,
&user_instruction, &user_instruction,
)); ));
let peer_registration = self.prepare_intake_peer_registration(&mut context); let peer_registration = self.prepare_intake_peer_registration(&mut context);
@@ -2236,11 +2252,12 @@ impl DashboardApp {
self.sending = true; self.sending = true;
self.notice = Some(format!( self.notice = Some(format!(
"Returning ready Ticket {} to planning for refinement…", "Returning ready Ticket {} to planning for refinement…",
ticket_id ticket_key
)); ));
Some(ReadyTicketPlanningReturnRequest { Some(ReadyTicketPlanningReturnRequest {
workspace_root, workspace_root,
ticket_id, ticket_id,
ticket_key,
user_instruction, user_instruction,
followup, followup,
}) })
@@ -3917,21 +3934,35 @@ fn bounded_refinement_instruction(input: &str) -> String {
.to_string() .to_string()
} }
fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str) -> String { fn required_ticket_handoff_key(resource_key: Option<&str>) -> Result<&str, String> {
let resource_key = resource_key.ok_or_else(|| {
"Ticket handoff is unavailable because the canonical T-* resource key is missing. Refresh the panel and retry."
.to_string()
})?;
let sequence = resource_key.strip_prefix("T-").filter(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
});
sequence.map(|_| resource_key).ok_or_else(|| {
"Ticket handoff is unavailable because the canonical T-* resource key is invalid. Refresh the panel and retry."
.to_string()
})
}
fn build_ready_ticket_refinement_thread_body(ticket_key: &str, instruction: &str) -> String {
format!( format!(
"Panel returned ready Ticket {ticket_id} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n" "Panel returned ready Ticket {ticket_key} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
) )
} }
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String { fn build_ready_ticket_refinement_launch_instruction(ticket_key: &str, instruction: &str) -> String {
format!( format!(
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}" "Continue Ticket Intake / requirements sync for existing Ticket {ticket_key}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
) )
} }
fn build_ready_ticket_refinement_notify(ticket_id: &str, instruction: &str) -> String { fn build_ready_ticket_refinement_notify(ticket_key: &str, instruction: &str) -> String {
format!( format!(
"Ticket {ticket_id} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}" "Ticket {ticket_key} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
) )
} }
@@ -3960,10 +3991,12 @@ async fn dispatch_ready_ticket_planning_return(
let ticket = backend let ticket = backend
.show(id.clone()) .show(id.clone())
.map_err(|error| TicketActionError::Ticket(error.to_string()))?; .map_err(|error| TicketActionError::Ticket(error.to_string()))?;
let ticket_key =
required_ticket_handoff_key(Some(&request.ticket_key)).map_err(TicketActionError::Stale)?;
if ticket.meta.workflow_state != TicketWorkflowState::Ready { if ticket.meta.workflow_state != TicketWorkflowState::Ready {
return Err(TicketActionError::Stale(format!( return Err(TicketActionError::Stale(format!(
"Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.", "Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.",
ticket.meta.id, ticket_key,
ticket.meta.workflow_state.as_str() ticket.meta.workflow_state.as_str()
))); )));
} }
@@ -3972,7 +4005,7 @@ async fn dispatch_ready_ticket_planning_return(
TicketWorkflowState::Planning.as_str(), TicketWorkflowState::Planning.as_str(),
"panel_return_to_planning", "panel_return_to_planning",
MarkdownText::from(build_ready_ticket_refinement_thread_body( MarkdownText::from(build_ready_ticket_refinement_thread_body(
&ticket.meta.id, ticket_key,
&request.user_instruction, &request.user_instruction,
)), )),
); );
@@ -3986,7 +4019,7 @@ async fn dispatch_ready_ticket_planning_return(
ReadyTicketPlanningReturnOutcome { ReadyTicketPlanningReturnOutcome {
notice: format!( notice: format!(
"Ticket {} returned to planning for refinement; launching Ticket Intake…", "Ticket {} returned to planning for refinement; launching Ticket Intake…",
ticket.meta.id ticket_key
), ),
followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request), followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request),
} }
@@ -3996,19 +4029,19 @@ async fn dispatch_ready_ticket_planning_return(
socket_path, socket_path,
} => { } => {
let message = let message =
build_ready_ticket_refinement_notify(&ticket.meta.id, &request.user_instruction); build_ready_ticket_refinement_notify(ticket_key, &request.user_instruction);
match send_notify_only(&socket_path, message, true).await { match send_notify_only(&socket_path, message, true).await {
Ok(()) => ReadyTicketPlanningReturnOutcome { Ok(()) => ReadyTicketPlanningReturnOutcome {
notice: format!( notice: format!(
"Ticket {} returned to planning for refinement; notified live Intake Worker {}.", "Ticket {} returned to planning for refinement; notified live Intake Worker {}.",
ticket.meta.id, worker_name ticket_key, worker_name
), ),
followup: ReadyTicketPlanningReturnAfterMutation::None, followup: ReadyTicketPlanningReturnAfterMutation::None,
}, },
Err(error) => ReadyTicketPlanningReturnOutcome { Err(error) => ReadyTicketPlanningReturnOutcome {
notice: bounded_panel_diagnostic(format!( notice: bounded_panel_diagnostic(format!(
"Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}", "Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}",
ticket.meta.id, worker_name, error ticket_key, worker_name, error
)), )),
followup: ReadyTicketPlanningReturnAfterMutation::None, followup: ReadyTicketPlanningReturnAfterMutation::None,
}, },
@@ -4019,7 +4052,7 @@ async fn dispatch_ready_ticket_planning_return(
ReadyTicketPlanningReturnOutcome { ReadyTicketPlanningReturnOutcome {
notice: format!( notice: format!(
"Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…", "Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…",
ticket.meta.id, worker_name ticket_key, worker_name
), ),
followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request), followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request),
} }
@@ -4028,7 +4061,7 @@ async fn dispatch_ready_ticket_planning_return(
ReadyTicketPlanningReturnOutcome { ReadyTicketPlanningReturnOutcome {
notice: bounded_panel_diagnostic(format!( notice: bounded_panel_diagnostic(format!(
"Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.", "Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.",
ticket.meta.id, worker_name ticket_key, worker_name
)), )),
followup: ReadyTicketPlanningReturnAfterMutation::None, followup: ReadyTicketPlanningReturnAfterMutation::None,
} }
@@ -4125,7 +4158,10 @@ async fn dispatch_ticket_action(
let config = TicketConfig::load_workspace(&request.workspace_root) let config = TicketConfig::load_workspace(&request.workspace_root)
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?; .map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
let backend = LocalTicketBackend::new(config.backend_root()) let backend = LocalTicketBackend::new(config.backend_root())
.with_record_language(config.ticket_record_language()); .with_record_language(config.ticket_record_language())
.with_target_authority(Arc::new(DashboardTicketTargetAuthority {
workspace_root: request.workspace_root.clone(),
}));
if request.action == NextUserAction::Close { if request.action == NextUserAction::Close {
return dispatch_panel_close(&backend, &request.ticket_id); return dispatch_panel_close(&backend, &request.ticket_id);
} }
@@ -4201,17 +4237,32 @@ async fn dispatch_panel_queue(
"root-ticket-state-after-orchestration-merge", "root-ticket-state-after-orchestration-merge",
&preflight.root_top_level, &preflight.root_top_level,
)?; )?;
backend let queue_outcome = backend
.queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel") .queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel")
.map_err(|error| TicketActionError::Ticket(error.to_string()))?; .map_err(|error| TicketActionError::Ticket(error.to_string()))?;
let expected_queue_tickets = preflight
.queue_tickets
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
let actual_queue_tickets = queue_outcome
.queued_tickets
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
if actual_queue_tickets != expected_queue_tickets {
return Err(TicketActionError::Stale(format!(
"Queue dependency plan changed after confirmation for Ticket {ticket_id}; reload and retry"
)));
}
let commit = commit_panel_queue_ticket_record(&preflight)?; let commit = commit_panel_queue_ticket_record(&preflight)?;
let sync = sync_panel_queue_to_orchestration(&preflight, &commit)?; let sync = sync_panel_queue_to_orchestration(&preflight, &commit)?;
verify_panel_queue_synced(&preflight, &commit)?; verify_panel_queue_synced(&preflight, &commit)?;
let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await; let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await;
Ok(TicketActionOutcome { Ok(TicketActionOutcome {
notice: format!( notice: format!(
"Queued Ticket {}; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.", "Queued Ticket closure [{}]; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
ticket_id, queue_outcome.queued_tickets.join(", "),
commit.sha, commit.sha,
root_merge.sentence(), root_merge.sentence(),
sync.sentence(), sync.sentence(),
@@ -4220,12 +4271,52 @@ async fn dispatch_panel_queue(
}) })
} }
struct DashboardTicketTargetAuthority {
workspace_root: PathBuf,
}
impl ticket::TicketTargetAuthority for DashboardTicketTargetAuthority {
fn resolve_target(
&self,
_workspace_id: &str,
repository_id: Option<&str>,
ref_selector: Option<&str>,
) -> ticket::Result<ticket::ResolvedTicketTarget> {
let repository_id = repository_id.unwrap_or("main");
if repository_id != "main" {
return Err(ticket::TicketError::UnknownTargetRepository(
repository_id.to_string(),
));
}
let ref_selector = ref_selector.unwrap_or("HEAD");
git_capture(
&self.workspace_root,
&[
"rev-parse",
"--verify",
&format!("{ref_selector}^{{commit}}"),
],
"resolve Queue Ticket target",
)
.map_err(|reason| ticket::TicketError::InvalidTargetSelector {
repository_id: repository_id.to_string(),
selector: ref_selector.to_string(),
reason,
})?;
Ok(ticket::ResolvedTicketTarget {
repository_id: repository_id.to_string(),
ref_selector: ref_selector.to_string(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct PanelQueueHandoffPreflight { struct PanelQueueHandoffPreflight {
ticket_id: String, ticket_id: String,
root_top_level: PathBuf, root_top_level: PathBuf,
orchestration: OrchestrationWorktreeLayout, orchestration: OrchestrationWorktreeLayout,
ticket_record_dir: PathBuf, queue_tickets: Vec<String>,
ticket_record_dirs: Vec<PathBuf>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -4393,27 +4484,53 @@ fn prepare_panel_queue_handoff(
&root_top_level, &root_top_level,
)?; )?;
let ticket_record_dir = backend.root().join(ticket_id); let dependency_check = backend
.dependency_check(TicketIdOrSlug::Id(ticket_id.to_owned()))
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
if !dependency_check.queue_guard.can_queue_for_orchestrator {
return Err(queue_check_failed(
"dependency-queue-plan",
ticket_id,
&root_top_level,
dependency_check
.queue_guard
.blocked_reason
.or(dependency_check.queue_guard.reason)
.unwrap_or_else(|| "Queue dependency validation failed".to_string()),
));
}
let queue_tickets = dependency_check.queue_tickets;
let mut ticket_record_dirs = Vec::with_capacity(queue_tickets.len());
for queue_ticket in &queue_tickets {
let ticket_record_dir = backend.root().join(queue_ticket);
if !ticket_record_dir.join("item.md").is_file() { if !ticket_record_dir.join("item.md").is_file() {
return Err(queue_check_failed( return Err(queue_check_failed(
"target-ticket-record", "target-ticket-record",
ticket_id, &queue_ticket,
&ticket_record_dir, &ticket_record_dir,
"target Ticket item.md is missing".to_string(), "Queue Ticket item.md is missing".to_string(),
)); ));
} }
let clean_stage = if queue_ticket == ticket_id {
"root-ticket-clean"
} else {
"queue-dependency-clean"
};
ensure_git_path_clean( ensure_git_path_clean(
"root-ticket-clean", clean_stage,
ticket_id, &queue_ticket,
&root_top_level, &root_top_level,
&ticket_record_dir, &ticket_record_dir,
)?; )?;
ticket_record_dirs.push(ticket_record_dir);
}
Ok(PanelQueueHandoffPreflight { Ok(PanelQueueHandoffPreflight {
ticket_id: ticket_id.to_string(), ticket_id: ticket_id.to_string(),
root_top_level, root_top_level,
orchestration, orchestration,
ticket_record_dir, queue_tickets,
ticket_record_dirs,
}) })
} }
@@ -4504,37 +4621,36 @@ fn sync_orchestration_to_root_before_queue(
fn commit_panel_queue_ticket_record( fn commit_panel_queue_ticket_record(
preflight: &PanelQueueHandoffPreflight, preflight: &PanelQueueHandoffPreflight,
) -> Result<PanelQueueCommit, TicketActionError> { ) -> Result<PanelQueueCommit, TicketActionError> {
let ticket_rel = path_relative_to_root( let ticket_rels = preflight
.ticket_record_dirs
.iter()
.map(|ticket_record_dir| {
path_relative_to_root(
&preflight.root_top_level, &preflight.root_top_level,
&preflight.ticket_record_dir, ticket_record_dir,
"target-ticket-record", "target-ticket-record",
&preflight.ticket_id, &preflight.ticket_id,
)?; )
})
.collect::<Result<Vec<_>, _>>()?;
let mut add = Command::new("git"); let mut add = Command::new("git");
add.arg("-C") add.arg("-C")
.arg(&preflight.root_top_level) .arg(&preflight.root_top_level)
.arg("add") .arg("add")
.arg("--") .arg("--")
.arg(&ticket_rel); .args(&ticket_rels);
run_git_command(add, "stage Queue Ticket record").map_err(|message| { run_git_command(add, "stage Queue Ticket records").map_err(|message| {
queue_check_failed( queue_check_failed(
"queue-commit-stage", "queue-commit-stage",
&preflight.ticket_id, &preflight.ticket_id,
&preflight.ticket_record_dir, &preflight.root_top_level,
message, message,
) )
})?; })?;
let ticket_rel_string = git_path_string(&ticket_rel);
let staged = git_capture( let staged = git_capture(
&preflight.root_top_level, &preflight.root_top_level,
&[ &["diff", "--cached", "--name-only"],
"diff",
"--cached",
"--name-only",
"--",
ticket_rel_string.as_str(),
],
"list staged Queue Ticket files", "list staged Queue Ticket files",
) )
.map_err(|message| { .map_err(|message| {
@@ -4545,19 +4661,31 @@ fn commit_panel_queue_ticket_record(
message, message,
) )
})?; })?;
let allowed = ticket_rels
.iter()
.map(|path| format!("{}/", git_path_string(path).trim_end_matches('/')))
.collect::<Vec<_>>();
let staged_paths = staged let staged_paths = staged
.lines() .lines()
.filter(|line| !line.trim().is_empty()) .filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if staged_paths.is_empty() { if staged_paths.is_empty()
|| staged_paths
.iter()
.any(|path| !allowed.iter().any(|root| path.starts_with(root)))
{
return Err(queue_check_failed( return Err(queue_check_failed(
"queue-commit-pathscope", "queue-commit-pathscope",
&preflight.ticket_id, &preflight.ticket_id,
&preflight.ticket_record_dir, &preflight.root_top_level,
"Queue mutation produced no staged Ticket record changes".to_string(), "Queue mutation staged no Ticket records or included files outside the confirmed dependency closure"
.to_string(),
)); ));
} }
let message = format!("ticket: queue {}", preflight.ticket_id); let message = format!(
"chore: queue Ticket dependency closure {}",
preflight.ticket_id
);
let mut commit = Command::new("git"); let mut commit = Command::new("git");
commit commit
.arg("-C") .arg("-C")
@@ -4567,8 +4695,8 @@ fn commit_panel_queue_ticket_record(
.arg("-m") .arg("-m")
.arg(message) .arg(message)
.arg("--") .arg("--")
.arg(&ticket_rel); .args(&ticket_rels);
run_git_command(commit, "commit Queue Ticket record").map_err(|message| { run_git_command(commit, "commit Queue Ticket records").map_err(|message| {
queue_check_failed( queue_check_failed(
"queue-commit-create", "queue-commit-create",
&preflight.ticket_id, &preflight.ticket_id,
+1 -1
View File
@@ -462,7 +462,7 @@ pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
.as_ref() .as_ref()
.and_then(|ticket| ticket.blocked_reason.as_deref()) .and_then(|ticket| ticket.blocked_reason.as_deref())
{ {
parts.push(format!("Gate: waiting for {blocked_reason}")); parts.push(format!("Dependencies: {blocked_reason}"));
} else { } else {
parts.push("Gate: clear".to_string()); parts.push("Gate: clear".to_string());
} }
+31 -8
View File
@@ -390,6 +390,7 @@ fn planning_return_request(
ReadyTicketPlanningReturnRequest { ReadyTicketPlanningReturnRequest {
workspace_root: temp.path().to_path_buf(), workspace_root: temp.path().to_path_buf(),
ticket_id, ticket_id,
ticket_key: "T-482".to_string(),
user_instruction: instruction.to_string(), user_instruction: instruction.to_string(),
followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
worker_name: "stale-intake".to_string(), worker_name: "stale-intake".to_string(),
@@ -494,6 +495,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
}; };
assert_eq!(request.ticket_id, "20260608-000123-ready"); assert_eq!(request.ticket_id, "20260608-000123-ready");
assert_eq!(request.ticket_key, "T-1");
assert_eq!(request.user_instruction, "clarify expected behavior"); assert_eq!(request.user_instruction, "clarify expected behavior");
assert!(matches!( assert!(matches!(
request.followup, request.followup,
@@ -515,6 +517,7 @@ async fn planning_return_with_launch_followup_changes_state_before_launch_follow
let request = ReadyTicketPlanningReturnRequest { let request = ReadyTicketPlanningReturnRequest {
workspace_root: temp.path().to_path_buf(), workspace_root: temp.path().to_path_buf(),
ticket_id: ticket_id.clone(), ticket_id: ticket_id.clone(),
ticket_key: "T-482".to_string(),
user_instruction: "launch intake after state change".to_string(), user_instruction: "launch intake after state change".to_string(),
followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest { followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake), context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake),
@@ -1846,24 +1849,23 @@ fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() {
} }
#[test] #[test]
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() { fn ready_ticket_with_dependency_context_keeps_queue_action_available() {
let mut row = panel_test_ticket_row( let mut row = panel_test_ticket_row(
"00001WAITING", "00001WAITING",
"Ready but gated", "Ready with dependency context",
ActionPriority::Background, ActionPriority::ReadyForQueue,
NextUserAction::Wait, NextUserAction::Queue,
"ready", "ready",
); );
row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string());
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string()); row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
let lines = panel_row_lines(&row, true, 160); let lines = panel_row_lines(&row, true, 160);
let detail = &lines[1]; let detail = &lines[1];
let detail_line = plain_line(&detail); let detail_line = plain_line(&detail);
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on")); assert!(detail_line.contains("Dependencies: BLOCKER-1 via depends_on"));
assert!(detail_line.contains("Action: queue disabled")); assert!(detail_line.contains("Action: Queue"));
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1")); assert!(!detail_line.contains("Queue disabled"));
} }
#[test] #[test]
@@ -3426,6 +3428,27 @@ fn ticket_action_error_records_f2_diagnostic_details() {
assert!(!app.panel_diagnostic_open); assert!(!app.panel_diagnostic_open);
} }
#[test]
fn ready_ticket_refinement_projection_uses_only_canonical_resource_key() {
const INTERNAL_ID: &str = "00001KZVNXFNK";
let thread = build_ready_ticket_refinement_thread_body("T-482", "Clarify rollback.");
let launch = build_ready_ticket_refinement_launch_instruction("T-482", "Clarify rollback.");
let notify = build_ready_ticket_refinement_notify("T-482", "Clarify rollback.");
for projection in [&thread, &launch, &notify] {
assert!(projection.contains("T-482"));
assert!(!projection.contains(INTERNAL_ID));
}
}
#[test]
fn ticket_handoff_fails_closed_without_canonical_resource_key() {
assert_eq!(required_ticket_handoff_key(Some("T-482")), Ok("T-482"));
for invalid in [None, Some(""), Some("00001KZVNXFNK"), Some("T-key")] {
assert!(required_ticket_handoff_key(invalid).is_err());
}
}
fn plain_line(line: &Line<'_>) -> String { fn plain_line(line: &Line<'_>) -> String {
line.spans line.spans
.iter() .iter()
+10 -12
View File
@@ -2203,7 +2203,7 @@ mod tests {
} }
#[test] #[test]
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate() { fn workspace_panel_blocks_ready_ticket_with_planning_relation() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
@@ -2235,12 +2235,7 @@ mod tests {
assert_eq!(row.kind, PanelRowKind::Ticket); assert_eq!(row.kind, PanelRowKind::Ticket);
assert_eq!(row.next_action, Some(NextUserAction::Wait)); assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_eq!(row.priority, ActionPriority::Background); assert_eq!(row.priority, ActionPriority::Background);
assert!( assert!(row.disabled_reason.is_some());
row.disabled_reason
.as_deref()
.unwrap()
.contains("Queue disabled: waiting for")
);
assert!( assert!(
row.ticket row.ticket
.as_ref() .as_ref()
@@ -2253,7 +2248,7 @@ mod tests {
} }
#[test] #[test]
fn workspace_panel_allows_ready_ticket_when_relation_prerequisite_is_queued() { fn workspace_panel_queues_ready_ticket_when_relation_prerequisite_is_queued() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
@@ -2286,13 +2281,16 @@ mod tests {
assert_eq!(row.next_action, Some(NextUserAction::Queue)); assert_eq!(row.next_action, Some(NextUserAction::Queue));
assert_eq!(row.priority, ActionPriority::ReadyForQueue); assert_eq!(row.priority, ActionPriority::ReadyForQueue);
assert!(row.disabled_reason.is_none()); assert!(row.disabled_reason.is_none());
assert!(row.ticket.as_ref().unwrap().blocked_reason.is_none());
assert!( assert!(
row.key_hint row.ticket
.as_deref() .as_ref()
.unwrap() .unwrap()
.contains("Queue allowed: prerequisites are already queued/in progress") .blocked_reason
.as_deref()
.unwrap_or_default()
.contains(&dependency.id)
); );
assert!(row.key_hint.as_deref().unwrap().contains("Queue targets:"));
assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id)); assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id));
} }
+168 -28
View File
@@ -251,14 +251,8 @@ impl DelegatingWorkdirSession {
self.ensure_path(path, WorkdirDelegationPermission::Write) self.ensure_path(path, WorkdirDelegationPermission::Write)
} }
fn ensure_command(&self, starting: bool) -> Result<(), WorkdirError> { fn ensure_command(&self) -> Result<(), WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")?; self.ensure_capability(WorkdirSessionCapability::Command, "command execution")
if starting && self.has_active_write_lease() {
return Err(WorkdirError::Denied(
"command execution is denied while a child holds a write delegation".into(),
));
}
Ok(())
} }
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> { fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
@@ -281,20 +275,6 @@ impl DelegatingWorkdirSession {
} }
} }
fn has_active_write_lease(&self) -> bool {
let mut leases = self
.child_write_leases
.lock()
.expect("workdir delegation lease mutex poisoned");
leases.retain(|_, lease| lease.validity.upgrade().is_some_and(|v| v.is_active()));
leases.values().any(|lease| {
lease
.rules
.iter()
.any(|rule| rule.permission == WorkdirDelegationPermission::Write)
})
}
fn validate_delegation_rules( fn validate_delegation_rules(
&self, &self,
rules: &[WorkdirDelegationRule], rules: &[WorkdirDelegationRule],
@@ -311,7 +291,10 @@ impl DelegatingWorkdirSession {
if !self.capabilities.supports(WorkdirSessionCapability::Read) if !self.capabilities.supports(WorkdirSessionCapability::Read)
|| (writable || (writable
&& (!self.capabilities.supports(WorkdirSessionCapability::Write) && (!self.capabilities.supports(WorkdirSessionCapability::Write)
|| !self.capabilities.supports(WorkdirSessionCapability::Edit))) || !self.capabilities.supports(WorkdirSessionCapability::Edit)
|| !self
.capabilities
.supports(WorkdirSessionCapability::Command)))
{ {
return Err(WorkdirError::Denied( return Err(WorkdirError::Denied(
"parent workdir session cannot delegate the requested capabilities".into(), "parent workdir session cannot delegate the requested capabilities".into(),
@@ -342,6 +325,7 @@ impl DelegatingWorkdirSession {
if writable { if writable {
delegated.push(WorkdirSessionCapability::Write); delegated.push(WorkdirSessionCapability::Write);
delegated.push(WorkdirSessionCapability::Edit); delegated.push(WorkdirSessionCapability::Edit);
delegated.push(WorkdirSessionCapability::Command);
} }
Ok(WorkdirSessionCapabilities::from_capabilities(delegated)) Ok(WorkdirSessionCapabilities::from_capabilities(delegated))
} }
@@ -499,12 +483,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
} }
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> { async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
self.ensure_command(true)?; self.ensure_command()?;
self.source.start_command(request).await self.source.start_command(request).await
} }
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> { async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
self.ensure_command(false)?; self.ensure_command()?;
self.source.command_status(handle).await self.source.command_status(handle).await
} }
@@ -512,12 +496,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
&self, &self,
request: CommandOutputRequest, request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> { ) -> Result<CommandOutput, WorkdirError> {
self.ensure_command(false)?; self.ensure_command()?;
self.source.command_output(request).await self.source.command_output(request).await
} }
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
self.ensure_command(false)?; self.ensure_command()?;
self.source.cancel_command(handle).await self.source.cancel_command(handle).await
} }
@@ -749,6 +733,31 @@ mod tests {
} }
} }
async fn run_command(
session: &WorkdirSessionHandle,
command: impl Into<String>,
tool_call_id: impl Into<String>,
) -> CommandOutput {
let handle = session
.start_command(CommandRequest {
command: command.into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some(tool_call_id.into()),
})
.await
.unwrap();
session
.command_output(CommandOutputRequest {
handle,
cursor: 0,
limit: 1024,
wait: true,
})
.await
.unwrap()
}
#[tokio::test] #[tokio::test]
async fn delegation_capable_session_forwards_command_telemetry() { async fn delegation_capable_session_forwards_command_telemetry() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -842,6 +851,18 @@ mod tests {
); );
assert!(child.scoped_session.subscribe_command_events().is_none()); assert!(child.scoped_session.subscribe_command_events().is_none());
assert!(child.scoped_session.command_snapshot().is_empty()); assert!(child.scoped_session.command_snapshot().is_empty());
assert!(matches!(
child
.scoped_session
.start_command(CommandRequest {
command: "printf denied".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("read-only-command".into()),
})
.await,
Err(WorkdirError::Denied(_))
));
} }
#[cfg(unix)] #[cfg(unix)]
@@ -920,7 +941,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn write_lease_blocks_parent_region_until_release() { async fn write_lease_keeps_typed_parent_writes_exclusive_without_blocking_commands() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("leased")).unwrap(); fs::create_dir_all(root.path().join("leased")).unwrap();
fs::create_dir_all(root.path().join("other")).unwrap(); fs::create_dir_all(root.path().join("other")).unwrap();
@@ -929,6 +950,30 @@ mod tests {
.delegate(request("leased", WorkdirDelegationPermission::Write)) .delegate(request("leased", WorkdirDelegationPermission::Write))
.await .await
.unwrap(); .unwrap();
assert!(
child
.capabilities
.supports(WorkdirSessionCapability::Command)
);
let child_output = run_command(
&child.scoped_session,
"printf child-command",
"delegated-child-command",
)
.await;
assert_eq!(child_output.content, "child-command");
let parent_output = run_command(
&parent,
"printf parent-write > leased/from-command; printf parent-command",
"parent-command-during-child-write",
)
.await;
assert_eq!(parent_output.status, CommandStatus::Completed);
assert_eq!(parent_output.content, "parent-command");
assert_eq!(
fs::read_to_string(root.path().join("leased/from-command")).unwrap(),
"parent-write"
);
assert!(matches!( assert!(matches!(
parent.write(write("leased/file", "parent")).await, parent.write(write("leased/file", "parent")).await,
@@ -941,6 +986,18 @@ mod tests {
.await .await
.unwrap(); .unwrap();
child.release(); child.release();
assert!(matches!(
child
.scoped_session
.start_command(CommandRequest {
command: "printf revoked".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("revoked-child-command".into()),
})
.await,
Err(WorkdirError::SessionClosed)
));
parent parent
.write(write("leased/parent", "parent")) .write(write("leased/parent", "parent"))
.await .await
@@ -992,6 +1049,78 @@ mod tests {
)); ));
} }
#[tokio::test]
async fn nested_write_leases_do_not_block_command_capable_ancestors() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("docs/sub")).unwrap();
let root_session = session(root.path());
let child = root_session
.delegate(request("docs", WorkdirDelegationPermission::Write))
.await
.unwrap();
let nested = child
.scoped_session
.delegate(request("docs/sub", WorkdirDelegationPermission::Write))
.await
.unwrap();
for (session, label) in [
(&root_session, "root"),
(&child.scoped_session, "child"),
(&nested.scoped_session, "nested"),
] {
let output = run_command(
session,
format!("printf {label}"),
format!("{label}-command-during-nested-write"),
)
.await;
assert_eq!(output.status, CommandStatus::Completed);
assert_eq!(output.content, label);
}
assert!(matches!(
root_session.write(write("docs/root", "blocked")).await,
Err(WorkdirError::Denied(_))
));
assert!(matches!(
child
.scoped_session
.write(write("sub/child", "blocked"))
.await,
Err(WorkdirError::Denied(_))
));
nested
.scoped_session
.write(write("nested", "allowed"))
.await
.unwrap();
nested.release();
child.release();
}
#[tokio::test]
async fn reapplied_write_delegation_chain_forwards_command_lifecycle() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("delegated")).unwrap();
let applied = apply_delegation_chain(
session(root.path()),
[request("delegated", WorkdirDelegationPermission::Write)],
)
.await
.unwrap();
let output = run_command(
&applied.scoped_session,
"printf reapplied",
"reapplied-command",
)
.await;
assert_eq!(output.status, CommandStatus::Completed);
assert_eq!(output.content, "reapplied");
}
#[tokio::test] #[tokio::test]
async fn applied_chain_cannot_replace_outer_provider_attenuation() { async fn applied_chain_cannot_replace_outer_provider_attenuation() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1036,6 +1165,17 @@ mod tests {
.unwrap(); .unwrap();
parent.close().await.unwrap(); parent.close().await.unwrap();
assert!(matches!(
parent
.start_command(CommandRequest {
command: "printf closed".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("closed-parent-command".into()),
})
.await,
Err(WorkdirError::SessionClosed)
));
assert!(matches!( assert!(matches!(
child.scoped_session.read(read("a")).await, child.scoped_session.read(read("a")).await,
Err(WorkdirError::SessionClosed) Err(WorkdirError::SessionClosed)
+3 -1
View File
@@ -28,7 +28,9 @@ pub use fs_operation::{
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
}; };
pub use local::{LocalWorkdirSession, SymlinkInfo, direct_symlink, first_symlink}; pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
};
pub use operation::*; pub use operation::*;
/// Persistent, opaque identity of one materialized Workdir. /// Persistent, opaque identity of one materialized Workdir.
+69 -2
View File
@@ -8,7 +8,8 @@
//! `LocalWorkdirSession` is cheap to clone (`Arc` inside). Tool-specific session //! `LocalWorkdirSession` is cheap to clone (`Arc` inside). Tool-specific session
//! state, such as read-before-edit tracking, remains owned by the tool layer. //! state, such as read-before-edit tracking, remains owned by the tool layer.
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::fmt::Debug;
#[cfg(test)] #[cfg(test)]
use std::io::Write as _; use std::io::Write as _;
use std::io::{Read as _, Seek as _, SeekFrom}; use std::io::{Read as _, Seek as _, SeekFrom};
@@ -228,6 +229,8 @@ struct LocalWorkdirSessionInner {
next_command_id: AtomicU64, next_command_id: AtomicU64,
commands: Mutex<HashMap<String, LocalCommand>>, commands: Mutex<HashMap<String, LocalCommand>>,
command_telemetry: CommandTelemetry, command_telemetry: CommandTelemetry,
command_environment: BTreeMap<String, String>,
resources: StdMutex<Vec<Arc<dyn WorkdirSessionResource>>>,
} }
impl Drop for LocalWorkdirSessionInner { impl Drop for LocalWorkdirSessionInner {
@@ -242,6 +245,9 @@ impl Drop for LocalWorkdirSessionInner {
} }
} }
pub trait WorkdirSessionResource: Debug + Send + Sync {}
impl<T> WorkdirSessionResource for T where T: Debug + Send + Sync {}
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside). /// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
/// ///
/// The wrapped [`SharedScope`] is shared with every clone of this /// The wrapped [`SharedScope`] is shared with every clone of this
@@ -318,6 +324,26 @@ impl LocalWorkdirSession {
cwd: PathBuf, cwd: PathBuf,
scope: SharedScope, scope: SharedScope,
capabilities: WorkdirSessionCapabilities, capabilities: WorkdirSessionCapabilities,
) -> Self {
Self::materialized_bound_with_environment(
workdir,
root,
cwd,
scope,
capabilities,
BTreeMap::new(),
Vec::new(),
)
}
pub fn materialized_bound_with_environment(
workdir: Workdir,
root: PathBuf,
cwd: PathBuf,
scope: SharedScope,
capabilities: WorkdirSessionCapabilities,
command_environment: BTreeMap<String, String>,
resources: Vec<Arc<dyn WorkdirSessionResource>>,
) -> Self { ) -> Self {
Self { Self {
inner: Arc::new(LocalWorkdirSessionInner { inner: Arc::new(LocalWorkdirSessionInner {
@@ -331,6 +357,8 @@ impl LocalWorkdirSession {
next_command_id: AtomicU64::new(1), next_command_id: AtomicU64::new(1),
commands: Mutex::new(HashMap::new()), commands: Mutex::new(HashMap::new()),
command_telemetry: CommandTelemetry::new(), command_telemetry: CommandTelemetry::new(),
command_environment,
resources: StdMutex::new(resources),
}), }),
} }
} }
@@ -669,9 +697,18 @@ impl WorkdirSession for LocalWorkdirSession {
let (completion_tx, completion) = watch::channel(false); let (completion_tx, completion) = watch::channel(false);
let command_id = handle.0.clone(); let command_id = handle.0.clone();
let telemetry = self.inner.command_telemetry.clone(); let telemetry = self.inner.command_telemetry.clone();
let command_environment = self.inner.command_environment.clone();
let (cancel, cancel_rx) = watch::channel(false); let (cancel, cancel_rx) = watch::channel(false);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let output = run_command(cwd, request, command_id, telemetry, cancel_rx).await; let output = run_command(
cwd,
request,
command_id,
telemetry,
command_environment,
cancel_rx,
)
.await;
let _ = completion_tx.send(true); let _ = completion_tx.send(true);
output output
}); });
@@ -840,6 +877,9 @@ impl WorkdirSession for LocalWorkdirSession {
LocalCommand::Completed(_) => {} LocalCommand::Completed(_) => {}
} }
} }
if let Ok(mut resources) = self.inner.resources.lock() {
resources.clear();
}
Ok(()) Ok(())
} }
} }
@@ -909,6 +949,7 @@ async fn run_command(
request: CommandRequest, request: CommandRequest,
command_id: String, command_id: String,
telemetry: CommandTelemetry, telemetry: CommandTelemetry,
command_environment: BTreeMap<String, String>,
mut cancel: watch::Receiver<bool>, mut cancel: watch::Receiver<bool>,
) -> Result<CommandOutput, WorkdirError> { ) -> Result<CommandOutput, WorkdirError> {
let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
@@ -925,6 +966,7 @@ async fn run_command(
.arg("-c") .arg("-c")
.arg(&request.command) .arg(&request.command)
.current_dir(&cwd) .current_dir(&cwd)
.envs(command_environment)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::from(stdout_file)) .stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file)) .stderr(Stdio::from(stderr_file))
@@ -2319,6 +2361,31 @@ mod tests {
assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None))); assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None)));
} }
#[tokio::test]
async fn closing_session_releases_runtime_resources() {
#[derive(Debug)]
struct Resource(Arc<AtomicBool>);
impl Drop for Resource {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
let dir = TempDir::new().unwrap();
let released = Arc::new(AtomicBool::new(false));
let session = LocalWorkdirSession::materialized_bound_with_environment(
Workdir::new("resource-session"),
dir.path().to_path_buf(),
dir.path().to_path_buf(),
SharedScope::new(Scope::writable(dir.path()).unwrap()),
WorkdirSessionCapabilities::ALL,
BTreeMap::from([("SSH_AUTH_SOCK".to_string(), "test-socket".to_string())]),
vec![Arc::new(Resource(released.clone()))],
);
WorkdirSession::close(&session).await.unwrap();
assert!(released.load(Ordering::Acquire));
}
#[tokio::test] #[tokio::test]
async fn provider_cancels_active_command() { async fn provider_cancels_active_command() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
+25
View File
@@ -30,6 +30,8 @@ impl RuntimeWorkerRef {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum MaterializerKind { pub enum MaterializerKind {
#[default] #[default]
RuntimeGitCache,
/// Legacy persisted value from the pre-cache local `git worktree` materializer.
LocalGitWorktree, LocalGitWorktree,
} }
@@ -109,6 +111,8 @@ pub struct WorkingDirectoryProvenance {
pub creation_selector: Option<String>, pub creation_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_ref: Option<String>, pub creation_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_tree: Option<String>,
pub materializer_kind: MaterializerKind, pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>, pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
@@ -122,6 +126,10 @@ pub struct WorkingDirectoryCurrentObservation {
pub current_selector: Option<String>, pub current_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ref: Option<String>, pub current_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at_epoch_seconds: Option<u64>,
pub status: WorkingDirectoryStatusKind, pub status: WorkingDirectoryStatusKind,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>, pub cleanliness: Option<String>,
@@ -141,9 +149,15 @@ pub struct WorkingDirectorySummary {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_ref: Option<String>, pub creation_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_selector: Option<String>, pub current_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ref: Option<String>, pub current_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at_epoch_seconds: Option<u64>,
pub materializer_kind: MaterializerKind, pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>, pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
@@ -166,6 +180,7 @@ impl WorkingDirectorySummary {
WorkingDirectoryProvenance { WorkingDirectoryProvenance {
creation_selector: self.creation_selector.clone(), creation_selector: self.creation_selector.clone(),
creation_ref: self.creation_ref.clone(), creation_ref: self.creation_ref.clone(),
creation_tree: self.creation_tree.clone(),
materializer_kind: self.materializer_kind.clone(), materializer_kind: self.materializer_kind.clone(),
cleanup_target: self.cleanup_target.clone(), cleanup_target: self.cleanup_target.clone(),
} }
@@ -175,6 +190,8 @@ impl WorkingDirectorySummary {
WorkingDirectoryCurrentObservation { WorkingDirectoryCurrentObservation {
current_selector: self.current_selector.clone(), current_selector: self.current_selector.clone(),
current_ref: self.current_ref.clone(), current_ref: self.current_ref.clone(),
current_tree: self.current_tree.clone(),
observed_at_epoch_seconds: self.observed_at_epoch_seconds,
status: self.status.clone(), status: self.status.clone(),
cleanliness: self.cleanliness.clone(), cleanliness: self.cleanliness.clone(),
primary_worker_id: self.primary_worker_id.clone(), primary_worker_id: self.primary_worker_id.clone(),
@@ -211,6 +228,7 @@ pub struct WorkingDirectoryListResponse {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkingDirectoryDetailResponse { pub struct WorkingDirectoryDetailResponse {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String,
pub item: WorkingDirectorySummary, pub item: WorkingDirectorySummary,
pub diagnostics: Vec<WorkingDirectoryDiagnostic>, pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
} }
@@ -246,8 +264,11 @@ mod tests {
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
creation_selector: Some("develop".to_string()), creation_selector: Some("develop".to_string()),
creation_ref: Some("abc123".to_string()), creation_ref: Some("abc123".to_string()),
creation_tree: Some("tree123".to_string()),
current_selector: Some("work/ticket".to_string()), current_selector: Some("work/ticket".to_string()),
current_ref: Some("def456".to_string()), current_ref: Some("def456".to_string()),
current_tree: Some("tree456".to_string()),
observed_at_epoch_seconds: Some(1_777_777_777),
materializer_kind: MaterializerKind::LocalGitWorktree, materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: Some(WorkingDirectoryCleanupTarget { cleanup_target: Some(WorkingDirectoryCleanupTarget {
kind: "git_worktree".to_string(), kind: "git_worktree".to_string(),
@@ -268,8 +289,11 @@ mod tests {
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
creation_selector: None, creation_selector: None,
creation_ref: None, creation_ref: None,
creation_tree: None,
current_selector: None, current_selector: None,
current_ref: Some("987fed".to_string()), current_ref: Some("987fed".to_string()),
current_tree: None,
observed_at_epoch_seconds: None,
materializer_kind: MaterializerKind::LocalGitWorktree, materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: None, cleanup_target: None,
status: WorkingDirectoryStatusKind::Active, status: WorkingDirectoryStatusKind::Active,
@@ -306,6 +330,7 @@ mod tests {
let detail = WorkingDirectoryDetailResponse { let detail = WorkingDirectoryDetailResponse {
workspace_id: decoded.workspace_id.clone(), workspace_id: decoded.workspace_id.clone(),
runtime_id: "arcadia".to_string(),
item: decoded.items[0].clone(), item: decoded.items[0].clone(),
diagnostics: decoded.diagnostics.clone(), diagnostics: decoded.diagnostics.clone(),
}; };
+3
View File
@@ -41,9 +41,12 @@ tar.workspace = true
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
toml.workspace = true toml.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
zeroize.workspace = true
tower = { workspace = true, features = ["util"], optional = true } tower = { workspace = true, features = ["util"], optional = true }
worker.workspace = true worker.workspace = true
workspace-api = { path = "../workspace-api" }
workdir.workspace = true workdir.workspace = true
[dev-dependencies] [dev-dependencies]
+259
View File
@@ -3,6 +3,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom}; use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey}; use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt; use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -14,6 +15,11 @@ pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-pro
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1"; const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1."; const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
pub const WORKER_REMOVE_PERMISSION: &str = "workspace:worker-remove"; pub const WORKER_REMOVE_PERMISSION: &str = "workspace:worker-remove";
pub const RUNTIME_REQUEST_SOURCE_PROOF_HEADER: &str = "x-yoi-runtime-request-proof";
pub const WORKSPACE_REQUEST_PERMISSION: &str = "workspace:request";
pub const BACKEND_RESOURCE_FETCH_PERMISSION: &str = "workspace:resource-fetch";
const RUNTIME_REQUEST_SOURCE_PROOF_PREFIX: &str = "yoi-runtime-request-v1";
const RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-runtime-request-v1.";
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum RuntimeAuthError { pub enum RuntimeAuthError {
@@ -33,6 +39,10 @@ pub enum RuntimeAuthError {
InvalidTokenFormat, InvalidTokenFormat,
#[error("malformed capability token claims: {0}")] #[error("malformed capability token claims: {0}")]
MalformedClaims(#[from] serde_json::Error), MalformedClaims(#[from] serde_json::Error),
#[error("runtime request proof contains an invalid `{0}` claim")]
InvalidClaim(&'static str),
#[error("runtime request proof does not match the HTTP request")]
ClaimMismatch,
#[error("unknown token issuer `{0}`")] #[error("unknown token issuer `{0}`")]
UnknownIssuer(String), UnknownIssuer(String),
#[error("invalid token signature")] #[error("invalid token signature")]
@@ -224,6 +234,162 @@ pub fn verify_capability_token(
}) })
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims {
pub iss: String,
pub aud: String,
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
pub permission: String,
pub method: String,
pub path: String,
pub body_digest: String,
pub iat: i64,
pub exp: i64,
pub jti: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeRequestSourceSigner {
identity_id: String,
private_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeRequestSourceExpectation<'a> {
pub identity_id: &'a str,
pub audience: &'a str,
pub workspace_id: &'a str,
pub worker_id: Option<&'a str>,
pub permission: &'a str,
pub method: &'a str,
pub path: &'a str,
pub body_digest: &'a str,
pub now_unix: i64,
}
pub fn request_body_digest(body: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(body))
}
impl RuntimeRequestSourceSigner {
pub fn from_identity(identity: &RuntimeIdentityMaterial) -> Self {
Self {
identity_id: identity.identity_id.clone(),
private_key: identity.private_key.clone(),
}
}
#[allow(clippy::too_many_arguments)]
pub fn issue(
&self,
audience: &str,
workspace_id: &str,
worker_id: Option<&str>,
permission: &str,
method: &str,
path: &str,
body: &[u8],
now_unix: i64,
ttl_seconds: u64,
) -> Result<String, RuntimeAuthError> {
for (name, value) in [
("audience", audience),
("workspace_id", workspace_id),
("permission", permission),
("method", method),
("path", path),
] {
if value.trim().is_empty() {
return Err(RuntimeAuthError::InvalidClaim(name));
}
}
if worker_id.is_some_and(str::is_empty) {
return Err(RuntimeAuthError::InvalidClaim("worker_id"));
}
let ttl_seconds = i64::try_from(ttl_seconds).unwrap_or(i64::MAX);
let claims = RuntimeRequestSourceClaims {
iss: self.identity_id.clone(),
aud: audience.to_owned(),
workspace_id: workspace_id.to_owned(),
worker_id: worker_id.map(str::to_owned),
permission: permission.to_owned(),
method: method.to_owned(),
path: path.to_owned(),
body_digest: request_body_digest(body),
iat: now_unix,
exp: now_unix.saturating_add(ttl_seconds),
jti: new_token_id()?,
};
let payload = serde_json::to_vec(&claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let private = decode_private_key(&self.private_key)?;
let key_pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref());
Ok(format!(
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}"
))
}
}
pub fn decode_runtime_request_source_claims(
proof: &str,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?;
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let payload = URL_SAFE_NO_PAD.decode(payload)?;
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
}
pub fn verify_runtime_request_source(
proof: &str,
public_key: &str,
expected: &RuntimeRequestSourceExpectation<'_>,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?;
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let signature = URL_SAFE_NO_PAD.decode(signature)?;
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let public_key = decode_public_key(public_key)?;
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
let claims = decode_runtime_request_source_claims(proof)?;
if claims.iss != expected.identity_id
|| claims.aud != expected.audience
|| claims.workspace_id != expected.workspace_id
|| claims.worker_id.as_deref() != expected.worker_id
|| claims.permission != expected.permission
|| claims.method != expected.method
|| claims.path != expected.path
|| claims.body_digest != expected.body_digest
{
return Err(RuntimeAuthError::ClaimMismatch);
}
if claims.iat > expected.now_unix || claims.exp < expected.now_unix {
return Err(RuntimeAuthError::Expired);
}
Ok(claims)
}
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = proof.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerMutationSourceClaims { pub struct WorkerMutationSourceClaims {
pub iss: String, pub iss: String,
@@ -592,6 +758,99 @@ mod tests {
)); ));
} }
#[test]
fn runtime_request_source_proof_binds_request_and_rejects_spoofed_signature() {
let trusted = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
let signer = RuntimeRequestSourceSigner::from_identity(&trusted);
let body = br#"{"ticket":"T-1"}"#;
let proof = signer
.issue(
"server-main",
"workspace-a",
Some("worker-7"),
WORKSPACE_REQUEST_PERMISSION,
"POST",
"/api/w/workspace-a/tickets/comment",
body,
90,
10,
)
.unwrap();
let expected = RuntimeRequestSourceExpectation {
identity_id: "runtime-main",
audience: "server-main",
workspace_id: "workspace-a",
worker_id: Some("worker-7"),
permission: WORKSPACE_REQUEST_PERMISSION,
method: "POST",
path: "/api/w/workspace-a/tickets/comment",
body_digest: &request_body_digest(body),
now_unix: 99,
};
let claims = verify_runtime_request_source(&proof, &trusted.public_key, &expected).unwrap();
assert_eq!(claims.iss, "runtime-main");
let changed_body = RuntimeRequestSourceExpectation {
body_digest: &request_body_digest(br#"{"ticket":"T-2"}"#),
..expected.clone()
};
assert!(matches!(
verify_runtime_request_source(&proof, &trusted.public_key, &changed_body),
Err(RuntimeAuthError::ClaimMismatch)
));
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
assert!(matches!(
verify_runtime_request_source(&proof, &spoofed.public_key, &expected),
Err(RuntimeAuthError::InvalidSignature)
));
}
#[test]
fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() {
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
let proof = RuntimeRequestSourceSigner::from_identity(&runtime)
.issue(
"server-main",
"workspace-a",
None,
BACKEND_RESOURCE_FETCH_PERMISSION,
"POST",
"/api/runtime/v1/workspaces/workspace-a/resources/fetch",
b"{}",
90,
10,
)
.unwrap();
let digest = request_body_digest(b"{}");
let expected = RuntimeRequestSourceExpectation {
identity_id: "runtime-main",
audience: "server-main",
workspace_id: "workspace-a",
worker_id: None,
permission: BACKEND_RESOURCE_FETCH_PERMISSION,
method: "POST",
path: "/api/runtime/v1/workspaces/workspace-a/resources/fetch",
body_digest: &digest,
now_unix: 99,
};
assert!(verify_runtime_request_source(&proof, &runtime.public_key, &expected).is_ok());
let wrong_workspace = RuntimeRequestSourceExpectation {
workspace_id: "workspace-b",
..expected.clone()
};
assert!(matches!(
verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace),
Err(RuntimeAuthError::ClaimMismatch)
));
let expired = RuntimeRequestSourceExpectation {
now_unix: 101,
..expected
};
assert!(matches!(
verify_runtime_request_source(&proof, &runtime.public_key, &expired),
Err(RuntimeAuthError::Expired)
));
}
#[test] #[test]
fn capability_token_verifies_signature_audience_expiry_and_permission() { fn capability_token_verifies_signature_audience_expiry_and_permission() {
let server = RuntimeIdentityMaterial::generate("server-main").unwrap(); let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
+74 -4
View File
@@ -2,7 +2,6 @@ use crate::identity::{RuntimeWorkerRef, WorkerId, WorkerRef};
use crate::interaction::WorkerInput; use crate::interaction::WorkerInput;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef}; use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf;
fn is_false(value: &bool) -> bool { fn is_false(value: &bool) -> bool {
!*value !*value
@@ -85,9 +84,9 @@ impl std::ops::Deref for RepositorySelector {
pub struct WorkingDirectoryRepository { pub struct WorkingDirectoryRepository {
pub id: String, pub id: String,
pub provider: String, pub provider: String,
pub uri: String, pub source: workspace_api::RepositorySource,
#[serde(default, skip_serializing_if = "Option::is_none")] pub source_revision: u64,
pub local_path: Option<PathBuf>, pub source_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>, pub selector: Option<RepositorySelector>,
} }
@@ -98,6 +97,74 @@ pub use workdir::workspace::{
WorkingDirectorySummary, WorkingDirectorySummary,
}; };
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SensitiveString(String);
impl SensitiveString {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl Drop for SensitiveString {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.0);
}
}
impl Default for SensitiveString {
fn default() -> Self {
Self(String::new())
}
}
impl std::fmt::Debug for SensitiveString {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("[REDACTED]")
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess {
pub credential_id: String,
pub credential_revision: u64,
pub host_trust_id: String,
pub host_trust_revision: u64,
pub access: workspace_api::RepositoryAccessMode,
pub expires_at_epoch_seconds: u64,
pub repository_id: String,
pub repository_source_fingerprint: String,
pub repository_uri: String,
pub secret_resource: crate::resource::BackendResourceHandle,
#[serde(skip, default)]
pub private_key: SensitiveString,
#[serde(skip, default)]
pub known_hosts_entry: SensitiveString,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryMaterializationContext {
pub workspace_id: String,
pub runtime_id: String,
pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String,
#[serde(default)]
pub cache_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRepositoryAccessRequest {
pub working_directory_id: String,
pub materialization: RepositoryMaterializationContext,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRequest { pub struct WorkingDirectoryRequest {
pub repository: WorkingDirectoryRepository, pub repository: WorkingDirectoryRepository,
@@ -107,6 +174,9 @@ pub struct WorkingDirectoryRequest {
/// Backend can create canonical registry rows before materialization. /// Backend can create canonical registry rows before materialization.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_workdir_id: Option<String>, pub backend_workdir_id: Option<String>,
/// Backend-authored, operation-scoped repository access and cache identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub materialization: Option<RepositoryMaterializationContext>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+21 -1
View File
@@ -1,4 +1,6 @@
use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus}; use crate::catalog::{
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
};
use crate::config_bundle::ConfigBundle; use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError; use crate::error::RuntimeError;
use crate::identity::WorkerRef; use crate::identity::WorkerRef;
@@ -319,6 +321,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
)) ))
} }
fn authorize_working_directory_repository_access(
&self,
_request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_repository_access_unsupported",
"Worker execution backend does not support Repository access authorization",
))
}
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> { fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
Vec::new() Vec::new()
} }
@@ -454,6 +466,14 @@ impl WorkerExecutionBackendRef {
self.backend.create_working_directory(request) self.backend.create_working_directory(request)
} }
pub(crate) fn authorize_working_directory_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
self.backend
.authorize_working_directory_repository_access(request)
}
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> { pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
self.backend.list_working_directories() self.backend.list_working_directories()
} }
+53 -2
View File
@@ -12,7 +12,8 @@ use crate::auth::{
}; };
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary, ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkspaceApiRef,
}; };
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary}; use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
use crate::error::RuntimeError; use crate::error::RuntimeError;
@@ -203,6 +204,10 @@ fn runtime_http_router_with_optional_auth(
"/v1/working-directories", "/v1/working-directories",
get(list_working_directories).post(create_working_directory), get(list_working_directories).post(create_working_directory),
) )
.route(
"/v1/working-directories/repository-access",
post(authorize_working_directory_repository_access),
)
.route( .route(
"/v1/working-directories/{working_directory_id}/sessions", "/v1/working-directories/{working_directory_id}/sessions",
post(open_workdir_session), post(open_workdir_session),
@@ -335,6 +340,11 @@ pub struct RuntimeHttpWorkingDirectoriesResponse {
pub working_directories: Vec<WorkingDirectoryStatus>, pub working_directories: Vec<WorkingDirectoryStatus>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpRepositoryAccessResponse {
pub authorized: bool,
}
/// Working directory response used by create/detail/delete endpoints. /// Working directory response used by create/detail/delete endpoints.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkingDirectoryResponse { pub struct RuntimeHttpWorkingDirectoryResponse {
@@ -513,6 +523,29 @@ async fn list_workers(
Ok(Json(RuntimeHttpWorkersResponse { workers })) Ok(Json(RuntimeHttpWorkersResponse { workers }))
} }
async fn authorize_working_directory_repository_access(
State(state): State<RuntimeHttpState>,
Extension(auth): Extension<RuntimeAuthContext>,
body: Result<Json<WorkingDirectoryRepositoryAccessRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpRepositoryAccessResponse> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
if request.materialization.workspace_id != auth.workspace_id {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"working_directory_materialization_workspace_mismatch",
"Repository access authority does not match the authenticated Workspace",
));
}
state
.runtime
.authorize_working_directory_repository_access_from_resource(request)
.await
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpRepositoryAccessResponse {
authorized: true,
}))
}
async fn list_working_directories( async fn list_working_directories(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> { ) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
@@ -527,12 +560,23 @@ async fn list_working_directories(
async fn create_working_directory( async fn create_working_directory(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
Extension(auth): Extension<RuntimeAuthContext>,
body: Result<Json<WorkingDirectoryRequest>, JsonRejection>, body: Result<Json<WorkingDirectoryRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> { ) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?; let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
if let Some(materialization) = request.materialization.as_ref()
&& materialization.workspace_id != auth.workspace_id
{
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"working_directory_materialization_workspace_mismatch",
"Repository materialization authority does not match the authenticated Workspace",
));
}
let working_directory = state let working_directory = state
.runtime .runtime
.create_working_directory(request) .create_working_directory_from_resource(request)
.await
.map_err(RuntimeHttpRestError::runtime)?; .map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkingDirectoryResponse { Ok(Json(RuntimeHttpWorkingDirectoryResponse {
working_directory, working_directory,
@@ -1559,6 +1603,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path == "/v1/workers" && *method == Method::POST { if path == "/v1/workers" && *method == Method::POST {
return Some("workers:create"); return Some("workers:create");
} }
if path == "/v1/working-directories/repository-access" && *method == Method::POST {
return Some("workdirs:operate");
}
if path.starts_with("/v1/workdir-sessions") if path.starts_with("/v1/workdir-sessions")
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions")) || (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
{ {
@@ -2220,6 +2267,10 @@ mod tests {
#[test] #[test]
fn workdir_routes_require_dedicated_operation_permission() { fn workdir_routes_require_dedicated_operation_permission() {
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
Some("workdirs:operate")
);
assert_eq!( assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"), required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
Some("workdirs:operate") Some("workdirs:operate")
+53 -11
View File
@@ -23,10 +23,21 @@ use worker_runtime::http_server::{
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection, RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
}; };
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::working_directory::LocalGitWorktreeMaterializer; use worker_runtime::working_directory::RuntimeGitCacheMaterializer;
use worker_runtime::{Runtime, RuntimeOptions}; use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode { fn main() -> ExitCode {
let mut arguments = std::env::args().skip(1).collect::<Vec<_>>();
if arguments.first().map(String::as_str) == Some("__repository-ssh") {
arguments.remove(0);
return match worker_runtime::working_directory::run_repository_ssh_client(&arguments) {
Ok(status) => ExitCode::from(u8::try_from(status).unwrap_or(1)),
Err(error) => {
eprintln!("{error}");
ExitCode::from(1)
}
};
}
match run() { match run() {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(error) => { Err(error) => {
@@ -160,29 +171,52 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
}; };
let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root")) let mut factory = ProfileRuntimeWorkerFactory::new(fs_paths.worker_dir.join("worker-root"))
.with_runtime_store_dir(runtime_store_dir); .with_runtime_store_dir(runtime_store_dir);
if let Some(identity) = read_runtime_auth_file(&runtime_auth_path(config))?.identity { let runtime_auth = read_runtime_auth_file(&runtime_auth_path(config))?;
if let Some(identity) = runtime_auth.identity.clone() {
if let [trusted_server] = runtime_auth.trusted_servers.as_slice() {
factory =
factory.with_runtime_request_identity(identity, trusted_server.server_id.clone());
} else {
factory = factory.with_remote_worker_mutation_identity(identity); factory = factory.with_remote_worker_mutation_identity(identity);
} }
}
let mut backend_resource_client: Option<
Arc<dyn worker_runtime::resource::BackendResourceClient>,
> = None;
if let Some(endpoint) = config.backend_resource_endpoint.clone() { if let Some(endpoint) = config.backend_resource_endpoint.clone() {
factory = factory.with_resource_client(Arc::new( let identity = runtime_auth.identity.as_ref().ok_or_else(|| {
ProcessError::Auth(
"--backend-resource-endpoint requires a configured Runtime identity".to_owned(),
)
})?;
let [trusted_server] = runtime_auth.trusted_servers.as_slice() else {
return Err(ProcessError::Auth(
"--backend-resource-endpoint requires exactly one trusted Server identity"
.to_owned(),
));
};
let client = Arc::new(
worker_runtime::resource::HttpBackendResourceClient::new( worker_runtime::resource::HttpBackendResourceClient::new(
endpoint, endpoint,
config.backend_resource_token.clone(), config.backend_resource_token.clone(),
), )
)); .with_runtime_request_source(identity, trusted_server.server_id.clone()),
);
factory = factory.with_resource_client(client.clone());
backend_resource_client = Some(client);
} }
let backend = Arc::new( let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(factory) WorkerRuntimeExecutionBackend::new(factory)
.map_err(ProcessError::WorkerAdapter)? .map_err(ProcessError::WorkerAdapter)?
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
fs_paths.workdir_target.clone(), fs_paths.workdir_target.clone(),
)), )),
); );
match &config.http.store { let runtime = match &config.http.store {
RuntimeHttpStoreSelection::Memory => { RuntimeHttpStoreSelection::Memory => {
Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend) Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend)
.map_err(ProcessError::Runtime) .map_err(ProcessError::Runtime)?
} }
RuntimeHttpStoreSelection::Fs { root } => { RuntimeHttpStoreSelection::Fs { root } => {
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id( let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
@@ -195,12 +229,20 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
); );
options.display_name = config.http.display_name.clone(); options.display_name = config.http.display_name.clone();
Runtime::with_fs_store_and_execution_backend(options, backend) Runtime::with_fs_store_and_execution_backend(options, backend)
.map_err(ProcessError::Runtime) .map_err(ProcessError::Runtime)?
} }
_ => Err(ProcessError::usage( _ => {
return Err(ProcessError::usage(
"unsupported Runtime catalog store selection".to_string(), "unsupported Runtime catalog store selection".to_string(),
)), ));
} }
};
if let Some(client) = backend_resource_client {
runtime
.install_backend_resource_client(client)
.map_err(ProcessError::Runtime)?;
}
Ok(runtime)
} }
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions { fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
+112 -6
View File
@@ -1,3 +1,7 @@
use crate::auth::{
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
};
use crate::identity::WorkerId; use crate::identity::WorkerId;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex}; use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
use async_trait::async_trait; use async_trait::async_trait;
@@ -7,18 +11,46 @@ use std::sync::Mutex;
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str = pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
"application/vnd.yoi.profile-source-archive+tar"; "application/vnd.yoi.profile-source-archive+tar";
pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
"application/vnd.yoi.repository-ssh-access+json";
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024; pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
#[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecret {
pub private_key: String,
pub known_hosts_entry: String,
}
impl Drop for RepositorySshAccessSecret {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
}
}
impl std::fmt::Debug for RepositorySshAccessSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RepositorySshAccessSecret")
.field("private_key", &"[REDACTED]")
.field("known_hosts_entry", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum BackendResourceKind { pub enum BackendResourceKind {
ProfileSourceArchive, ProfileSourceArchive,
RepositorySshAccess,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum BackendResourceOperation { pub enum BackendResourceOperation {
FetchArchive, FetchArchive,
FetchOnce,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -62,7 +94,7 @@ pub struct BackendResourceFetchRequest {
pub audit_correlation_id: String, pub audit_correlation_id: String,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendResourceFetchResponse { pub struct BackendResourceFetchResponse {
pub kind: BackendResourceKind, pub kind: BackendResourceKind,
pub resource_id: String, pub resource_id: String,
@@ -72,6 +104,29 @@ pub struct BackendResourceFetchResponse {
pub audit_correlation_id: String, pub audit_correlation_id: String,
} }
impl std::fmt::Debug for BackendResourceFetchResponse {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("BackendResourceFetchResponse")
.field("kind", &self.kind)
.field("resource_id", &self.resource_id)
.field("digest", &self.digest)
.field("content_type", &self.content_type)
.field(
"bytes",
&format_args!("[REDACTED; {} bytes]", self.bytes.len()),
)
.field("audit_correlation_id", &self.audit_correlation_id)
.finish()
}
}
impl Drop for BackendResourceFetchResponse {
fn drop(&mut self) {
self.bytes.fill(0);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "code", rename_all = "snake_case")] #[serde(tag = "code", rename_all = "snake_case")]
pub enum BackendResourceError { pub enum BackendResourceError {
@@ -108,6 +163,8 @@ pub trait BackendResourceClient: Send + Sync + 'static {
pub struct HttpBackendResourceClient { pub struct HttpBackendResourceClient {
endpoint: String, endpoint: String,
bearer_token: Option<String>, bearer_token: Option<String>,
request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>,
client: reqwest::Client, client: reqwest::Client,
} }
@@ -117,9 +174,21 @@ impl HttpBackendResourceClient {
Self { Self {
endpoint: endpoint.into(), endpoint: endpoint.into(),
bearer_token, bearer_token,
request_source_signer: None,
request_source_audience: None,
client: reqwest::Client::new(), client: reqwest::Client::new(),
} }
} }
pub fn with_runtime_request_source(
mut self,
identity: &RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
self.request_source_audience = Some(audience.into());
self
}
} }
#[cfg(feature = "http-server")] #[cfg(feature = "http-server")]
@@ -129,7 +198,44 @@ impl BackendResourceClient for HttpBackendResourceClient {
&self, &self,
request: BackendResourceFetchRequest, request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> { ) -> Result<BackendResourceFetchResponse, BackendResourceError> {
let builder = self.client.post(&self.endpoint).json(&request); let body = serde_json::to_vec(&request).map_err(|error| {
BackendResourceError::InvalidResponse {
message: error.to_string(),
}
})?;
let endpoint = reqwest::Url::parse(&self.endpoint).map_err(|error| {
BackendResourceError::Transport {
message: error.to_string(),
}
})?;
let mut builder = self
.client
.post(endpoint.clone())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone());
if let Some(signer) = self.request_source_signer.as_ref() {
let audience = self.request_source_audience.as_deref().ok_or_else(|| {
BackendResourceError::Unauthorized {
message: "Runtime request proof audience is unavailable".to_owned(),
}
})?;
let proof = signer
.issue(
audience,
&request.handle.workspace_id,
None,
BACKEND_RESOURCE_FETCH_PERMISSION,
"POST",
endpoint.path(),
&body,
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| BackendResourceError::Unauthorized {
message: error.to_string(),
})?;
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
let builder = if let Some(token) = self.bearer_token.as_deref() { let builder = if let Some(token) = self.bearer_token.as_deref() {
builder.bearer_auth(token) builder.bearer_auth(token)
} else { } else {
@@ -193,7 +299,7 @@ pub fn build_profile_source_archive_fetch_request(
pub fn profile_source_archive_from_response( pub fn profile_source_archive_from_response(
handle: &BackendResourceHandle, handle: &BackendResourceHandle,
response: BackendResourceFetchResponse, mut response: BackendResourceFetchResponse,
) -> Result<ProfileSourceArchive, BackendResourceError> { ) -> Result<ProfileSourceArchive, BackendResourceError> {
if handle.kind != BackendResourceKind::ProfileSourceArchive if handle.kind != BackendResourceKind::ProfileSourceArchive
|| response.kind != BackendResourceKind::ProfileSourceArchive || response.kind != BackendResourceKind::ProfileSourceArchive
@@ -208,7 +314,7 @@ pub fn profile_source_archive_from_response(
if response.content_type != handle.content_type { if response.content_type != handle.content_type {
return Err(BackendResourceError::ContentTypeMismatch { return Err(BackendResourceError::ContentTypeMismatch {
expected: handle.content_type.clone(), expected: handle.content_type.clone(),
actual: response.content_type, actual: response.content_type.clone(),
}); });
} }
let actual_bytes = response.bytes.len() as u64; let actual_bytes = response.bytes.len() as u64;
@@ -223,7 +329,7 @@ pub fn profile_source_archive_from_response(
return Err(BackendResourceError::DigestMismatch { return Err(BackendResourceError::DigestMismatch {
expected: handle.digest.clone(), expected: handle.digest.clone(),
actual: if response.digest != handle.digest { actual: if response.digest != handle.digest {
response.digest response.digest.clone()
} else { } else {
actual_digest actual_digest
}, },
@@ -241,7 +347,7 @@ pub fn profile_source_archive_from_response(
} }
})?, })?,
}, },
content: response.bytes, content: std::mem::take(&mut response.bytes),
}) })
} }
+447 -3
View File
@@ -1,6 +1,6 @@
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck, ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
WorkerStatus, WorkerSummary, WorkingDirectoryRequest, WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
}; };
use crate::config_bundle::{ use crate::config_bundle::{
@@ -26,6 +26,10 @@ use crate::management::{
}; };
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent}; use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
use crate::resource::{
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest, BackendResourceKind,
REPOSITORY_SSH_ACCESS_CONTENT_TYPE, RepositorySshAccessSecret,
};
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
use crate::retention::{ use crate::retention::{
FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult,
@@ -172,6 +176,14 @@ impl Runtime {
Ok(runtime) Ok(runtime)
} }
pub fn install_backend_resource_client(
&self,
client: Arc<dyn BackendResourceClient>,
) -> Result<(), RuntimeError> {
self.lock()?.backend_resource_client = Some(BackendResourceClientRef(client));
Ok(())
}
/// Create or restore a filesystem-backed Runtime. /// Create or restore a filesystem-backed Runtime.
/// ///
/// The store is scoped by `options.root`; if the directory already exists, /// The store is scoped by `options.root`; if the directory already exists,
@@ -366,6 +378,103 @@ impl Runtime {
.map_err(RuntimeError::from) .map_err(RuntimeError::from)
} }
pub async fn create_working_directory_from_resource(
&self,
mut request: WorkingDirectoryRequest,
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
if let Some(ssh) = request
.materialization
.as_mut()
.and_then(|materialization| materialization.ssh.as_mut())
{
self.resolve_repository_access_resource(ssh).await?;
}
self.create_working_directory(request)
}
pub fn authorize_working_directory_repository_access(
&self,
request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeError> {
let backend = {
let state = self.lock()?;
state.ensure_running()?;
state.execution_backend.clone().ok_or_else(|| {
RuntimeError::ExecutionBackendUnavailable {
message: "working directory Repository access requires an execution backend"
.to_string(),
}
})?
};
backend
.authorize_working_directory_repository_access(&request)
.map_err(RuntimeError::from)
}
async fn resolve_repository_access_resource(
&self,
ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
) -> Result<(), RuntimeError> {
if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() {
return Ok(());
}
let (client, runtime_id) = {
let state = self.lock()?;
let client = state.backend_resource_client.clone().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Backend Repository access resource client is unavailable".to_string(),
)
})?;
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
})?;
(client, runtime_id)
};
let mut response = client
.0
.fetch_resource(BackendResourceFetchRequest {
handle: ssh.secret_resource.clone(),
runtime_id,
worker_id: None,
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(),
})
.await
.map_err(repository_resource_error)?;
if response.kind != BackendResourceKind::RepositorySshAccess
|| response.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE
|| response.resource_id != ssh.secret_resource.resource_id
|| response.digest != ssh.secret_resource.digest
|| response.bytes.len() as u64 > ssh.secret_resource.max_bytes
{
return Err(RuntimeError::InvalidRequest(
"Backend Repository SSH access resource response was invalid".to_string(),
));
}
let secret = serde_json::from_slice::<RepositorySshAccessSecret>(&response.bytes);
response.bytes.fill(0);
let mut secret = secret.map_err(|_| {
RuntimeError::InvalidRequest(
"Backend Repository SSH access resource payload was invalid".to_string(),
)
})?;
ssh.private_key =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
ssh.known_hosts_entry =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry));
Ok(())
}
pub async fn authorize_working_directory_repository_access_from_resource(
&self,
mut request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeError> {
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
})?;
self.resolve_repository_access_resource(ssh).await?;
self.authorize_working_directory_repository_access(request)
}
/// List Runtime-owned working directories through the attached execution backend. /// List Runtime-owned working directories through the attached execution backend.
pub fn list_working_directories( pub fn list_working_directories(
&self, &self,
@@ -566,12 +675,13 @@ impl Runtime {
let worker_id = request.worker_id; let worker_id = request.worker_id;
let worker_ref = WorkerRef::new(worker_id); let worker_ref = WorkerRef::new(worker_id);
let durable_request = durable_create_worker_request(&request);
let record = WorkerRecord { let record = WorkerRecord {
worker_ref: worker_ref.clone(), worker_ref: worker_ref.clone(),
worker_id: worker_id.clone(), worker_id: worker_id.clone(),
status: WorkerStatus::Stopped, status: WorkerStatus::Stopped,
workspace_id: scope.map(|scope| scope.workspace_id.clone()), workspace_id: scope.map(|scope| scope.workspace_id.clone()),
request: request.clone(), request: durable_request,
run_generation: 1, run_generation: 1,
working_directory: None, working_directory: None,
execution_handle: None, execution_handle: None,
@@ -1842,6 +1952,15 @@ struct SubscriptionSink {
lagged: Arc<AtomicBool>, lagged: Arc<AtomicBool>,
} }
#[derive(Clone)]
struct BackendResourceClientRef(Arc<dyn BackendResourceClient>);
impl std::fmt::Debug for BackendResourceClientRef {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("BackendResourceClientRef(..)")
}
}
#[derive(Debug)] #[derive(Debug)]
struct RuntimeState { struct RuntimeState {
display_name: Option<String>, display_name: Option<String>,
@@ -1853,6 +1972,7 @@ struct RuntimeState {
persistence: RuntimePersistence, persistence: RuntimePersistence,
status: RuntimeStatus, status: RuntimeStatus,
execution_backend: Option<WorkerExecutionBackendRef>, execution_backend: Option<WorkerExecutionBackendRef>,
backend_resource_client: Option<BackendResourceClientRef>,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: u64, next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>, workers: BTreeMap<WorkerId, WorkerRecord>,
@@ -1880,6 +2000,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Memory, persistence: RuntimePersistence::Memory,
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
backend_resource_client: None,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -1908,6 +2029,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store), persistence: RuntimePersistence::Fs(store),
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
backend_resource_client: None,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -1959,6 +2081,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store), persistence: RuntimePersistence::Fs(store),
status: persisted.status, status: persisted.status,
execution_backend: None, execution_backend: None,
backend_resource_client: None,
next_diagnostic_id, next_diagnostic_id,
workers, workers,
config_bundles: BTreeMap::new(), config_bundles: BTreeMap::new(),
@@ -2714,6 +2837,33 @@ fn worker_status_from_run_state(run_state: WorkerExecutionRunState) -> WorkerSta
} }
} }
fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
let category = match error {
BackendResourceError::Expired => "expired",
BackendResourceError::Unauthorized { .. } => "unauthorized",
BackendResourceError::UnsupportedKind => "unsupported_kind",
BackendResourceError::MissingResource => "missing_resource",
BackendResourceError::Oversized { .. } => "oversized",
BackendResourceError::DigestMismatch { .. } => "digest_mismatch",
BackendResourceError::ContentTypeMismatch { .. } => "content_type_mismatch",
BackendResourceError::InvalidResponse { .. } => "invalid_response",
BackendResourceError::Transport { .. } => "transport",
};
RuntimeError::InvalidRequest(format!(
"Backend Repository SSH access resource fetch failed: {category}"
))
}
fn durable_create_worker_request(request: &CreateWorkerRequest) -> CreateWorkerRequest {
let mut durable = request.clone();
if let Some(working_directory) = durable.working_directory_request.as_mut()
&& let Some(materialization) = working_directory.materialization.as_mut()
{
materialization.ssh = None;
}
durable
}
fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> { fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
request request
.working_directory .working_directory
@@ -2884,7 +3034,9 @@ fn subscription_worker_state(status: WorkerStatus) -> SubscriptionWorkerState {
mod tests { mod tests {
use super::*; use super::*;
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, ProfileSelector, WorkingDirectoryClaim, WorkspaceApiRef, ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
}; };
use crate::config_bundle::{ use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration, ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
@@ -2894,6 +3046,8 @@ mod tests {
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle, WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
WorkerExecutionRestoreRequest, WorkerExecutionRunState, WorkerExecutionRestoreRequest, WorkerExecutionRunState,
}; };
use crate::working_directory::WorkingDirectoryDiagnostic;
use async_trait::async_trait;
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -3115,6 +3269,243 @@ mod tests {
} }
} }
#[test]
fn durable_worker_request_omits_repository_credentials() {
let mut request = task_request("worker-secret-redaction");
request.working_directory_request = Some(WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: repository_resource_handle(),
private_key: SensitiveString::new("private-key-bytes"),
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
}),
}),
});
let durable = durable_create_worker_request(&request);
assert!(
request
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_some()
);
assert!(
durable
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_none()
);
let serialized = serde_json::to_string(&durable).unwrap();
assert!(!serialized.contains("private-key-bytes"));
assert!(!serialized.contains("known-hosts-entry"));
}
fn repository_resource_handle() -> crate::resource::BackendResourceHandle {
crate::resource::BackendResourceHandle {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
workspace_id: "workspace-1".to_string(),
scope_id: Some("repository-ssh-access".to_string()),
runtime_id: Some("runtime-1".to_string()),
worker_id: None,
resource_id: "repository-access-1".to_string(),
digest: "opaque:repository-access-1".to_string(),
operation: crate::resource::BackendResourceOperation::FetchOnce,
expires_at_unix_seconds: i64::MAX,
nonce: "repository-access-1".to_string(),
revision: "1".to_string(),
generation: None,
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id: "repository-access-1".to_string(),
profile_source_graph: None,
}
}
#[tokio::test]
async fn repository_access_resource_is_fetched_before_provider_authorization() {
let (runtime, backend) = runtime_and_backend();
backend
.repository_access_available
.store(true, Ordering::SeqCst);
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "private-key-bytes".to_string(),
known_hosts_entry: "known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
.unwrap();
let request = WorkingDirectoryRepositoryAccessRequest {
working_directory_id: "working-directory-1".to_string(),
materialization: RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
},
};
let replay = request.clone();
runtime
.authorize_working_directory_repository_access_from_resource(request)
.await
.unwrap();
assert!(
runtime
.authorize_working_directory_repository_access_from_resource(replay)
.await
.is_err()
);
let accesses = backend.repository_accesses.lock().unwrap();
assert_eq!(accesses.len(), 1);
let access = accesses[0].materialization.ssh.as_ref().unwrap();
assert_eq!(access.private_key.expose(), "private-key-bytes");
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
}
#[tokio::test]
async fn working_directory_create_fetches_repository_access_before_provider_call() {
let (runtime, backend) = runtime_and_backend();
backend
.repository_access_available
.store(true, Ordering::SeqCst);
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "create-private-key-bytes".to_string(),
known_hosts_entry: "create-known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
.unwrap();
let request = WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-create".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
}),
};
assert!(
runtime
.create_working_directory_from_resource(request)
.await
.is_err()
);
let requests = backend.working_directory_requests.lock().unwrap();
let access = requests[0]
.materialization
.as_ref()
.and_then(|materialization| materialization.ssh.as_ref())
.unwrap();
assert_eq!(access.private_key.expose(), "create-private-key-bytes");
assert_eq!(
access.known_hosts_entry.expose(),
"create-known-hosts-entry"
);
}
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest { fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
let mut request = task_request(objective); let mut request = task_request(objective);
request.workspace_api = Some(WorkspaceApiRef { request.workspace_api = Some(WorkspaceApiRef {
@@ -3196,6 +3587,9 @@ mod tests {
config_bundles: Mutex<Vec<Option<ConfigBundle>>>, config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>, contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>, dispatched_inputs: Mutex<Vec<WorkerInput>>,
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
repository_access_available: AtomicBool,
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
preserve_commit_ack_submission_id: AtomicBool, preserve_commit_ack_submission_id: AtomicBool,
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>, snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
@@ -3236,6 +3630,38 @@ mod tests {
"test-execution-backend" "test-execution-backend"
} }
fn create_working_directory(
&self,
request: &WorkingDirectoryRequest,
) -> Result<CatalogWorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
self.working_directory_requests
.lock()
.unwrap()
.push(request.clone());
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_unsupported",
"Worker execution backend does not support working directory materialization",
))
}
fn authorize_working_directory_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
self.repository_accesses
.lock()
.unwrap()
.push(request.clone());
if self.repository_access_available.load(Ordering::SeqCst) {
Ok(())
} else {
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_repository_access_unsupported",
"Worker execution backend does not support Repository access authorization",
))
}
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
self.run_generations self.run_generations
.lock() .lock()
@@ -3343,6 +3769,24 @@ mod tests {
} }
} }
struct TestRepositoryResourceClient {
response: Mutex<Option<crate::resource::BackendResourceFetchResponse>>,
}
#[async_trait]
impl BackendResourceClient for TestRepositoryResourceClient {
async fn fetch_resource(
&self,
_request: BackendResourceFetchRequest,
) -> Result<crate::resource::BackendResourceFetchResponse, BackendResourceError> {
self.response
.lock()
.unwrap()
.take()
.ok_or(BackendResourceError::MissingResource)
}
}
fn runtime_with_backend() -> Runtime { fn runtime_with_backend() -> Runtime {
let runtime = Runtime::with_execution_backend( let runtime = Runtime::with_execution_backend(
RuntimeOptions::default(), RuntimeOptions::default(),
+169 -37
View File
@@ -14,10 +14,13 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc}; use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration; use std::time::Duration;
use crate::auth::RuntimeIdentityMaterial; use crate::auth::{
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
};
use crate::catalog::{ use crate::catalog::{
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
}; };
use crate::execution::{ use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
@@ -35,9 +38,7 @@ use crate::working_directory::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use protocol::{Event, Method, Segment, WorkerStatus}; use protocol::{Event, Method, Segment, WorkerStatus};
use session_store::{ use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore};
CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore, collect_state,
};
#[cfg(test)] #[cfg(test)]
use session_store::{FsStore, FsWorkerStore}; use session_store::{FsStore, FsWorkerStore};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
@@ -65,8 +66,10 @@ const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9); const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool { fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
let LogEntry::UserInput { extensions, .. } = entry else { let extensions = match entry {
return false; LogEntry::UserInput { extensions, .. }
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return false,
}; };
extensions.iter().any(|extension| { extensions.iter().any(|extension| {
extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN
@@ -209,11 +212,11 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
return Err(WorkerObservationError::NotFound); return Err(WorkerObservationError::NotFound);
} }
let entries = sink.subscribe_with_snapshot().0; let entries = sink.subscribe_with_snapshot().0;
let state = collect_state(&entries); WorkerSessionCapture::from_log_entries(
Ok(WorkerSessionCapture { format!("runtime:{runtime_id}:worker:{worker_id}"),
segment_id: format!("runtime:{runtime_id}:worker:{worker_id}"), &entries,
items: state.history, )
}) .map_err(WorkerObservationError::Unavailable)
} }
} }
@@ -295,6 +298,7 @@ pub struct ProfileRuntimeWorkerFactory {
prompt_projection_cache: Arc<WorkspacePromptProjectionCache>, prompt_projection_cache: Arc<WorkspacePromptProjectionCache>,
runtime_id: Option<String>, runtime_id: Option<String>,
worker_mutation_identity: Option<RuntimeIdentityMaterial>, worker_mutation_identity: Option<RuntimeIdentityMaterial>,
runtime_request_audience: Option<String>,
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>, embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
controller_transport: WorkerControllerTransport, controller_transport: WorkerControllerTransport,
} }
@@ -311,6 +315,7 @@ impl ProfileRuntimeWorkerFactory {
prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()), prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()),
runtime_id: None, runtime_id: None,
worker_mutation_identity: None, worker_mutation_identity: None,
runtime_request_audience: None,
embedded_worker_mutation_dispatcher: None, embedded_worker_mutation_dispatcher: None,
controller_transport: WorkerControllerTransport::UnixSocket, controller_transport: WorkerControllerTransport::UnixSocket,
} }
@@ -331,6 +336,17 @@ impl ProfileRuntimeWorkerFactory {
self self
} }
pub fn with_runtime_request_identity(
mut self,
identity: RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.runtime_id = Some(identity.identity_id.clone());
self.worker_mutation_identity = Some(identity);
self.runtime_request_audience = Some(audience.into());
self
}
pub fn with_embedded_worker_mutation_dispatcher( pub fn with_embedded_worker_mutation_dispatcher(
mut self, mut self,
runtime_id: impl Into<String>, runtime_id: impl Into<String>,
@@ -457,13 +473,15 @@ impl ProfileRuntimeWorkerFactory {
async fn resolve_profile_source_archive( async fn resolve_profile_source_archive(
&self, &self,
source: &ProfileSourceArchiveSource, source: &ProfileSourceArchiveSource,
request_audience: Option<&str>,
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> { ) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
match source { match source {
ProfileSourceArchiveSource::Embedded { archive } => archive ProfileSourceArchiveSource::Embedded { archive } => archive
.verify() .verify()
.map_err(|err| format!("failed to verify embedded profile source archive: {err}")), .map_err(|err| format!("failed to verify embedded profile source archive: {err}")),
ProfileSourceArchiveSource::Http { location } => { ProfileSourceArchiveSource::Http { location } => {
self.fetch_profile_source_archive(location).await self.fetch_profile_source_archive(location, request_audience)
.await
} }
} }
} }
@@ -471,10 +489,18 @@ impl ProfileRuntimeWorkerFactory {
async fn fetch_profile_source_archive( async fn fetch_profile_source_archive(
&self, &self,
location: &ProfileSourceArchiveHttpRef, location: &ProfileSourceArchiveHttpRef,
request_audience: Option<&str>,
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> { ) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
if let Some(cached) = self.profile_archive_cache.get(&location.archive.digest) { if let Some(cached) = self.profile_archive_cache.get(&location.archive.digest) {
let response = let response = fetch_profile_source_archive_http(
fetch_profile_source_archive_http(location, Some(&location.archive.digest)).await?; location,
Some(&location.archive.digest),
self.worker_mutation_identity.as_ref(),
self.runtime_request_audience
.as_deref()
.or(request_audience),
)
.await?;
if let Some(fetched) = response { if let Some(fetched) = response {
self.profile_archive_cache.insert(fetched.clone()); self.profile_archive_cache.insert(fetched.clone());
fetched.verify().map_err(|err| { fetched.verify().map_err(|err| {
@@ -486,7 +512,14 @@ impl ProfileRuntimeWorkerFactory {
.map_err(|err| format!("failed to verify cached profile source archive: {err}")) .map_err(|err| format!("failed to verify cached profile source archive: {err}"))
} }
} else { } else {
let archive = fetch_profile_source_archive_http(location, None) let archive = fetch_profile_source_archive_http(
location,
None,
self.worker_mutation_identity.as_ref(),
self.runtime_request_audience
.as_deref()
.or(request_audience),
)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
"profile source archive HTTP revalidation returned 304 without a cached archive" "profile source archive HTTP revalidation returned 304 without a cached archive"
@@ -527,6 +560,7 @@ impl RuntimeWorkspaceBackendRef {
worker_ref: &WorkerRef, worker_ref: &WorkerRef,
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>, workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
mutation_identity: Option<&RuntimeIdentityMaterial>, mutation_identity: Option<&RuntimeIdentityMaterial>,
runtime_request_audience: Option<&str>,
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>, embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>, prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
) -> WorkerWorkspaceContext { ) -> WorkerWorkspaceContext {
@@ -546,6 +580,13 @@ impl RuntimeWorkspaceBackendRef {
if let Some(cache) = prompt_projection_cache { if let Some(cache) = prompt_projection_cache {
client = client.with_prompt_projection_cache(cache); client = client.with_prompt_projection_cache(cache);
} }
if let Some(identity) = mutation_identity {
let audience = runtime_request_audience
.or_else(|| workspace_scope.map(|scope| scope.server_id.as_str()));
if let Some(audience) = audience {
client = client.with_runtime_request_source(identity, audience.to_owned());
}
}
if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) { if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) {
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote( client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
identity, identity,
@@ -576,9 +617,40 @@ impl RuntimeWorkspaceBackendRef {
async fn fetch_profile_source_archive_http( async fn fetch_profile_source_archive_http(
location: &ProfileSourceArchiveHttpRef, location: &ProfileSourceArchiveHttpRef,
cached_digest: Option<&str>, cached_digest: Option<&str>,
identity: Option<&RuntimeIdentityMaterial>,
audience: Option<&str>,
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> { ) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let mut request = client.get(&location.url); let url = reqwest::Url::parse(&location.url)
.map_err(|error| format!("profile source archive URL is invalid: {error}"))?;
let path = url.path().to_owned();
let workspace_id = path
.split('/')
.collect::<Vec<_>>()
.windows(2)
.find_map(|parts| (parts[0] == "w").then_some(parts[1]))
.filter(|value| !value.is_empty())
.ok_or_else(|| "profile source archive URL is not workspace-scoped".to_owned())?;
let mut request = client.get(url);
if let Some(identity) = identity {
let audience = audience.ok_or_else(|| {
"profile source archive request proof audience is unavailable".to_owned()
})?;
let proof = RuntimeRequestSourceSigner::from_identity(identity)
.issue(
audience,
workspace_id,
None,
BACKEND_RESOURCE_FETCH_PERMISSION,
"GET",
&path,
b"",
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| error.to_string())?;
request = request.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if cached_digest == Some(location.archive.digest.as_str()) { if cached_digest == Some(location.archive.digest.as_str()) {
if let Some(etag) = location.etag.as_deref() { if let Some(etag) = location.etag.as_deref() {
request = request.header(reqwest::header::IF_NONE_MATCH, etag); request = request.header(reqwest::header::IF_NONE_MATCH, etag);
@@ -620,6 +692,8 @@ async fn fetch_profile_source_archive_http(
async fn fetch_profile_source_archive_http( async fn fetch_profile_source_archive_http(
_location: &ProfileSourceArchiveHttpRef, _location: &ProfileSourceArchiveHttpRef,
_cached_digest: Option<&str>, _cached_digest: Option<&str>,
_identity: Option<&RuntimeIdentityMaterial>,
_audience: Option<&str>,
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> { ) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
Err( Err(
"HTTP profile source archive fetch requires the worker-runtime http-server feature" "HTTP profile source archive fetch requires the worker-runtime http-server feature"
@@ -632,13 +706,17 @@ fn runtime_local_workdir_session(
root: &Path, root: &Path,
cwd: &Path, cwd: &Path,
scope: manifest::SharedScope, scope: manifest::SharedScope,
command_environment: std::collections::BTreeMap<String, String>,
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
) -> WorkdirSessionHandle { ) -> WorkdirSessionHandle {
Arc::new(LocalWorkdirSession::materialized_bound( Arc::new(LocalWorkdirSession::materialized_bound_with_environment(
Workdir::new(workdir_id), Workdir::new(workdir_id),
root.to_path_buf(), root.to_path_buf(),
cwd.to_path_buf(), cwd.to_path_buf(),
scope, scope,
WorkdirSessionCapabilities::ALL, WorkdirSessionCapabilities::ALL,
command_environment,
resources,
)) ))
} }
@@ -743,12 +821,19 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
&request.worker_ref, &request.worker_ref,
request.workspace_scope.as_ref(), request.workspace_scope.as_ref(),
self.worker_mutation_identity.as_ref(), self.worker_mutation_identity.as_ref(),
self.runtime_request_audience.as_deref(),
self.embedded_worker_mutation_dispatcher.as_ref(), self.embedded_worker_mutation_dispatcher.as_ref(),
Some(self.prompt_projection_cache.clone()), Some(self.prompt_projection_cache.clone()),
); );
let selector = profile.as_ref(); let selector = profile.as_ref();
let archive = self let archive = self
.resolve_profile_source_archive(&request.request.profile_source) .resolve_profile_source_archive(
&request.request.profile_source,
request
.workspace_scope
.as_ref()
.map(|scope| scope.server_id.as_str()),
)
.await?; .await?;
let (mut manifest, mut loader) = { let (mut manifest, mut loader) = {
let manifest = archive let manifest = archive
@@ -812,6 +897,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
binding.root(), binding.root(),
binding.cwd(), binding.cwd(),
worker.scope().clone(), worker.scope().clone(),
binding.command_environment(),
binding.session_resources(),
))); )));
} else { } else {
worker.bind_workdir_session(None); worker.bind_workdir_session(None);
@@ -909,6 +996,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
&request.worker_ref, &request.worker_ref,
request.workspace_scope.as_ref(), request.workspace_scope.as_ref(),
self.worker_mutation_identity.as_ref(), self.worker_mutation_identity.as_ref(),
self.runtime_request_audience.as_deref(),
self.embedded_worker_mutation_dispatcher.as_ref(), self.embedded_worker_mutation_dispatcher.as_ref(),
Some(self.prompt_projection_cache.clone()), Some(self.prompt_projection_cache.clone()),
); );
@@ -989,6 +1077,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
binding.root(), binding.root(),
binding.cwd(), binding.cwd(),
worker.scope().clone(), worker.scope().clone(),
binding.command_environment(),
binding.session_resources(),
))); )));
} else { } else {
worker.bind_workdir_session(None); worker.bind_workdir_session(None);
@@ -1500,6 +1590,19 @@ where
Ok(materializer.create(request)?.status()) Ok(materializer.create(request)?.status())
} }
fn authorize_working_directory_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| {
WorkingDirectoryDiagnostic::rejected(
"working_directory_materializer_unavailable",
"working directory Repository access requested, but no materializer is configured for this runtime backend",
)
})?;
materializer.authorize_repository_access(request)
}
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> { fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
self.working_directory_materializer self.working_directory_materializer
.as_ref() .as_ref()
@@ -1542,6 +1645,8 @@ where
binding.root(), binding.root(),
binding.cwd(), binding.cwd(),
manifest::SharedScope::new(scope), manifest::SharedScope::new(scope),
binding.command_environment(),
binding.session_resources(),
)) ))
} }
@@ -2060,7 +2165,7 @@ mod tests {
use crate::identity::WorkerRef; use crate::identity::WorkerRef;
use crate::management::RuntimeOptions; use crate::management::RuntimeOptions;
use crate::observation::WorkerObservationCursor; use crate::observation::WorkerObservationCursor;
use crate::working_directory::LocalGitWorktreeMaterializer; use crate::working_directory::RuntimeGitCacheMaterializer;
use agen::Engine; use agen::Engine;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request}; use agen::llm_client::{ClientError, LlmClient, Request};
@@ -2187,12 +2292,18 @@ mod tests {
let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main"); let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main");
let before_restart = let before_restart =
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None); backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None, None);
let adapter = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap(); let adapter = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap();
let (after_restore_kind, after_restore_workspace_id) = adapter let (after_restore_kind, after_restore_workspace_id) = adapter
.run_on_adapter_runtime(async move { .run_on_adapter_runtime(async move {
let after_restore = let after_restore = backend.worker_context(
backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None); &worker_ref,
Some(&scope),
Some(&identity),
None,
None,
None,
);
let client = after_restore.client_handle(); let client = after_restore.client_handle();
Ok(( Ok((
client.kind().to_string(), client.kind().to_string(),
@@ -2383,6 +2494,7 @@ mod tests {
None, None,
None, None,
None, None,
None,
); );
let workspace_client = workspace_context.client_handle(); let workspace_client = workspace_context.client_handle();
self.observed_workspace_clients.lock().unwrap().push(( self.observed_workspace_clients.lock().unwrap().push((
@@ -2393,7 +2505,9 @@ mod tests {
let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?; let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
let worker = Worker::new( let worker = Worker::new(
manifest, manifest,
Engine::new(self.client.clone()), Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
self.client.clone(),
),
store, store,
workspace_context, workspace_context,
filesystem_authority, filesystem_authority,
@@ -2631,12 +2745,17 @@ mod tests {
repository: WorkingDirectoryRepository { repository: WorkingDirectoryRepository {
id: "repo-main".to_string(), id: "repo-main".to_string(),
provider: "git".to_string(), provider: "git".to_string(),
uri: ".".to_string(), source: workspace_api::RepositorySource {
local_path: Some(repo.to_path_buf()), kind: workspace_api::RepositorySourceKind::LocalPath,
uri: repo.display().to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")), selector: Some(RepositorySelector::from("HEAD")),
}, },
materializer: MaterializerKind::LocalGitWorktree, materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: None, backend_workdir_id: None,
materialization: None,
} }
} }
@@ -2760,12 +2879,16 @@ mod tests {
root.path(), root.path(),
root.path(), root.path(),
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()), manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
Default::default(),
Vec::new(),
); );
let restored = runtime_local_workdir_session( let restored = runtime_local_workdir_session(
"working-directory-42", "working-directory-42",
root.path(), root.path(),
root.path(), root.path(),
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()), manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
Default::default(),
Vec::new(),
); );
assert_eq!(spawned.workdir().id().as_str(), "working-directory-42"); assert_eq!(spawned.workdir().id().as_str(), "working-directory-42");
@@ -2781,7 +2904,7 @@ mod tests {
archive: bundle.profile_source_archive.clone().unwrap(), archive: bundle.profile_source_archive.clone().unwrap(),
}; };
factory factory
.resolve_profile_source_archive(&source) .resolve_profile_source_archive(&source, None)
.await .await
.expect("embedded archive should resolve without Backend resource client"); .expect("embedded archive should resolve without Backend resource client");
} }
@@ -3120,14 +3243,17 @@ mod tests {
matches!( matches!(
entry, entry,
LogEntry::UserInput { segments, .. } LogEntry::UserInput { segments, .. }
| LogEntry::AnnotatedUserInput { segments, .. }
if segments == &vec![Segment::text("start the ticket")] if segments == &vec![Segment::text("start the ticket")]
) )
})); }));
let submission_id = entries let submission_id = entries
.iter() .iter()
.find_map(|entry| { .find_map(|entry| {
let LogEntry::UserInput { extensions, .. } = entry else { let extensions = match entry {
return None; LogEntry::UserInput { extensions, .. }
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return None,
}; };
extensions extensions
.iter() .iter()
@@ -3237,7 +3363,7 @@ mod tests {
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory) let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap() .unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(), runtime_base.path(),
)); ));
let runtime = let runtime =
@@ -3392,7 +3518,7 @@ mod tests {
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory) let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap() .unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(), runtime_base.path(),
)); ));
let runtime = let runtime =
@@ -3431,7 +3557,7 @@ mod tests {
let repo = create_clean_repo(); let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap() .unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(), runtime_base.path(),
)); ));
let runtime = let runtime =
@@ -3467,7 +3593,7 @@ mod tests {
let repo = create_clean_repo(); let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap() .unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(), runtime_base.path(),
)); ));
let runtime = let runtime =
@@ -3481,9 +3607,15 @@ mod tests {
assert!(format!("{error:?}").contains("spawn failed")); assert!(format!("{error:?}").contains("spawn failed"));
let working_directories_root = runtime_base.path(); let working_directories_root = runtime_base.path();
let remaining_entries = fs::read_dir(working_directories_root) let remaining_workdirs = fs::read_dir(working_directories_root)
.map(|entries| entries.count()) .map(|entries| {
entries
.flatten()
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
.count()
})
.unwrap_or(0); .unwrap_or(0);
assert_eq!(remaining_entries, 0); assert_eq!(remaining_workdirs, 0);
assert!(working_directories_root.join(".repository-cache").is_dir());
} }
} }
+82 -13
View File
@@ -7,8 +7,9 @@ use worker::{
}; };
use crate::auth::{ use crate::auth::{
RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner, RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
WORKER_REMOVE_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation, RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION,
WORKSPACE_REQUEST_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
WorkerMutationSourceClaims, new_token_id, WorkerMutationSourceClaims, new_token_id,
}; };
use crate::runtime::RuntimeWorkspaceScope; use crate::runtime::RuntimeWorkspaceScope;
@@ -289,6 +290,8 @@ pub struct RuntimeOwnedWorkspaceClient {
worker_id: String, worker_id: String,
request_timeout: Option<Duration>, request_timeout: Option<Duration>,
worker_remove: Option<RuntimeWorkerMutationForwarder>, worker_remove: Option<RuntimeWorkerMutationForwarder>,
request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>,
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>, prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
} }
@@ -306,6 +309,8 @@ impl RuntimeOwnedWorkspaceClient {
worker_id: worker_id.into(), worker_id: worker_id.into(),
request_timeout: None, request_timeout: None,
worker_remove: None, worker_remove: None,
request_source_signer: None,
request_source_audience: None,
prompt_projection_cache: None, prompt_projection_cache: None,
} }
} }
@@ -315,6 +320,16 @@ impl RuntimeOwnedWorkspaceClient {
self self
} }
pub fn with_runtime_request_source(
mut self,
identity: &RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
self.request_source_audience = Some(audience.into());
self
}
pub(crate) fn with_prompt_projection_cache( pub(crate) fn with_prompt_projection_cache(
mut self, mut self,
cache: Arc<WorkspacePromptProjectionCache>, cache: Arc<WorkspacePromptProjectionCache>,
@@ -363,15 +378,21 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
request: WorkspaceRequest, request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone(); let base_url = self.base_url.clone();
let workspace_id = self.workspace_id.clone();
let runtime_id = self.runtime_id.clone(); let runtime_id = self.runtime_id.clone();
let worker_id = self.worker_id.clone(); let worker_id = self.worker_id.clone();
let request_source_signer = self.request_source_signer.clone();
let request_source_audience = self.request_source_audience.clone();
let request_timeout = self.request_timeout; let request_timeout = self.request_timeout;
if tokio::runtime::Handle::try_current().is_ok() { if tokio::runtime::Handle::try_current().is_ok() {
std::thread::spawn(move || { std::thread::spawn(move || {
execute_runtime_owned_workspace_http( execute_runtime_owned_workspace_http(
&base_url, &base_url,
&workspace_id,
&runtime_id, &runtime_id,
&worker_id, &worker_id,
request_source_signer.as_ref(),
request_source_audience.as_deref(),
request_timeout, request_timeout,
request, request,
) )
@@ -383,8 +404,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
} else { } else {
execute_runtime_owned_workspace_http( execute_runtime_owned_workspace_http(
&self.base_url, &self.base_url,
&self.workspace_id,
&self.runtime_id, &self.runtime_id,
&self.worker_id, &self.worker_id,
self.request_source_signer.as_ref(),
self.request_source_audience.as_deref(),
self.request_timeout, self.request_timeout,
request, request,
) )
@@ -484,8 +508,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn execute_runtime_owned_workspace_http( fn execute_runtime_owned_workspace_http(
base_url: &str, base_url: &str,
workspace_id: &str,
runtime_id: &str, runtime_id: &str,
worker_id: &str, worker_id: &str,
request_source_signer: Option<&RuntimeRequestSourceSigner>,
request_source_audience: Option<&str>,
request_timeout: Option<Duration>, request_timeout: Option<Duration>,
request: WorkspaceRequest, request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
@@ -510,11 +537,33 @@ fn execute_runtime_owned_workspace_http(
)) ))
})?; })?;
let request_label = format!("{method} {}", request.path); let request_label = format!("{method} {}", request.path);
let body = request.body.unwrap_or_default();
let mut request_builder = client let mut request_builder = client
.request(method, url) .request(method.clone(), url)
.header("x-yoi-runtime-id", runtime_id) .header("x-yoi-runtime-id", runtime_id)
.header("x-yoi-worker-id", worker_id); .header("x-yoi-worker-id", worker_id);
if let Some(body) = request.body { if let Some(signer) = request_source_signer {
let audience = request_source_audience.ok_or_else(|| {
WorkspaceClientError::Request(
"runtime request proof audience is unavailable".to_owned(),
)
})?;
let proof = signer
.issue(
audience,
workspace_id,
Some(worker_id),
WORKSPACE_REQUEST_PERMISSION,
method.as_str(),
&request.path,
body.as_bytes(),
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
request_builder = request_builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !body.is_empty() {
request_builder = request_builder request_builder = request_builder
.header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body); .body(body);
@@ -590,8 +639,8 @@ fn unix_now_seconds() -> u64 {
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::{ use crate::auth::{
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims, WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
verify_worker_mutation_source_proof, decode_worker_mutation_source_claims, verify_worker_mutation_source_proof,
}; };
#[test] #[test]
@@ -797,7 +846,7 @@ mod tests {
} }
#[test] #[test]
fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() { fn ordinary_workspace_forwarding_stamps_runtime_identity_and_signs_path_and_query() {
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
use std::sync::Mutex; use std::sync::Mutex;
@@ -817,21 +866,41 @@ mod tests {
.unwrap(); .unwrap();
}); });
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let client = RuntimeOwnedWorkspaceClient::new( let client = RuntimeOwnedWorkspaceClient::new(
"workspace-a", "workspace-a",
format!("http://{address}"), format!("http://{address}"),
"runtime-a", "runtime-a",
"worker-a", "worker-a",
); )
.with_runtime_request_source(&identity, "server-a");
let response = client let response = client
.execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search")) .execute(WorkspaceRequest::get(
"/api/w/workspace-a/tickets/search?state=planning&limit=20",
))
.unwrap(); .unwrap();
assert_eq!(response.status, 200); assert_eq!(response.status, 200);
server.join().unwrap(); server.join().unwrap();
let request = received.lock().unwrap().to_ascii_lowercase(); let request = received.lock().unwrap().clone();
assert!(request.contains("x-yoi-runtime-id: runtime-a")); let lowercase_request = request.to_ascii_lowercase();
assert!(request.contains("x-yoi-worker-id: worker-a")); assert!(lowercase_request.contains("x-yoi-runtime-id: runtime-a"));
assert!(!request.contains("authorization:")); assert!(lowercase_request.contains("x-yoi-worker-id: worker-a"));
assert!(lowercase_request.contains("x-yoi-runtime-request-proof: yoi-runtime-request-v1."));
assert!(!lowercase_request.contains("authorization:"));
let token = request
.lines()
.find_map(|line| {
line.split_once(':').and_then(|(name, value)| {
name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
.then(|| value.trim())
})
})
.expect("runtime proof header");
let claims = decode_runtime_request_source_claims(token).unwrap();
assert_eq!(
claims.path,
"/api/w/workspace-a/tickets/search?state=planning&limit=20"
);
} }
#[test] #[test]
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -70,7 +70,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
// 5. Extract the assistant's reply from history // 5. Extract the assistant's reply from history
let history = worker.engine().history(); let history = worker.history();
if let Some(text) = history if let Some(text) = history
.iter() .iter()
.rev() .rev()
+1 -1
View File
@@ -22,7 +22,7 @@ use crate::compact::token_counter::{
EstimateSource, savings_for_prune_impl, token_estimates_for_prune_impl, EstimateSource, savings_for_prune_impl, token_estimates_for_prune_impl,
}; };
impl<C: LlmClient, St: Store> Worker<C, St> { impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// Enable prune projection on the underlying Engine. /// Enable prune projection on the underlying Engine.
/// ///
/// Registers the config and token/savings-estimator closures on the Engine. /// Registers the config and token/savings-estimator closures on the Engine.
+4 -4
View File
@@ -242,13 +242,13 @@ pub(crate) fn savings_for_prune_impl(
// ── Worker に生やす公開 API ─────────────────────────────────────────────── // ── Worker に生やす公開 API ───────────────────────────────────────────────
impl<C: LlmClient, St: Store> Worker<C, St> { impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// 現在の history 全体の推定トークン数。 /// 現在の history 全体の推定トークン数。
/// ///
/// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。 /// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。
pub fn total_tokens(&self) -> TokenEstimate { pub fn total_tokens(&self) -> TokenEstimate {
let usage = self.usage_history(); let usage = self.usage_history();
agen::token_counter::total_tokens(self.history(), &usage) agen::token_counter::total_tokens(&self.history(), &usage)
} }
/// 任意の history index 時点でのプロンプト全長推定。 /// 任意の history index 時点でのプロンプト全長推定。
@@ -259,7 +259,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// pointer 以降に増えたプロンプト長を測るのに使う。 /// pointer 以降に増えたプロンプト長を測るのに使う。
pub fn total_tokens_at(&self, history_len: usize) -> TokenEstimate { pub fn total_tokens_at(&self, history_len: usize) -> TokenEstimate {
let usage = self.usage_history(); let usage = self.usage_history();
agen::token_counter::total_tokens_at(self.history(), &usage, history_len) agen::token_counter::total_tokens_at(&self.history(), &usage, history_len)
} }
/// 末尾から `retained` トークン以上を残すための分割位置。 /// 末尾から `retained` トークン以上を残すための分割位置。
@@ -267,7 +267,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// `history[..cut.index]` が要約/破棄される側、`history[cut.index..]` が残る側。 /// `history[..cut.index]` が要約/破棄される側、`history[cut.index..]` が残る側。
pub fn split_for_retained(&self, retained: u64) -> SplitPoint { pub fn split_for_retained(&self, retained: u64) -> SplitPoint {
let usage = self.usage_history(); let usage = self.usage_history();
split_for_retained_impl(self.history(), &usage, retained) split_for_retained_impl(&self.history(), &usage, retained)
} }
} }
+63 -315
View File
@@ -31,12 +31,11 @@ use workdir::LocalWorkdirSession;
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle}; use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
use crate::compact::usage_tracker::UsageTracker; use crate::compact::usage_tracker::UsageTracker;
use crate::fs_view::ReadRequirement; use crate::feature::{
#[cfg(test)] FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
use crate::fs_view::slice_lines; ToolDeclaration,
use crate::session_capture::{
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionCapture, ToolPart,
}; };
use crate::fs_view::ReadRequirement;
/// Aggregated output of a compact worker run. /// Aggregated output of a compact worker run.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
@@ -91,248 +90,19 @@ struct SummaryParams {
pub text: String, pub text: String,
} }
/// Input to `search_session_log`.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct SearchSessionParams {
/// Case-insensitive substring to search in compact-target history.
pub query: String,
/// 0-based item offset to start searching from.
#[serde(default)]
pub offset: Option<usize>,
/// Maximum number of hits to return.
#[serde(default)]
pub limit: Option<usize>,
}
/// Input to `read_session_items`.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReadSessionParams {
/// 0-based compact-target history item offset.
pub offset: usize,
/// Maximum number of items to return.
pub limit: usize,
/// `compact` omits tool arguments/full results; `full` includes message text and tool result content.
#[serde(default = "default_session_read_mode")]
pub mode: String,
}
fn default_session_read_mode() -> String {
"compact".to_string()
}
const SESSION_TOOL_MAX_OUTPUT_TOKENS: u64 = 12_000;
const SESSION_SEARCH_MAX_RESULTS: usize = 50;
const SESSION_READ_MAX_ITEMS: usize = 80;
const MARK_DESCRIPTION: &str = "Inject a file's contents into the compacted context so the \ const MARK_DESCRIPTION: &str = "Inject a file's contents into the compacted context so the \
next session starts with it already read. Use this for files the next task needs in full. \ next session starts with it already read. Use this for files the next task needs in full. \
Optionally specify `offset` (0-based line) and `limit` (line count) to inject only a slice. \ Optionally specify `offset` (0-based line) and `limit` (line count) to inject only a slice. \
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded. \ Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded.";
Paths must be absolute.";
const REFERENCE_DESCRIPTION: &str = "Record a file path as a named reference in the compacted \ const REFERENCE_DESCRIPTION: &str = "Record a Workdir-relative file path as a named reference in \
context without injecting its contents. Use for files that are contextually relevant but \ the compacted context without injecting its contents. Use for files that are contextually \
whose current content the next session can fetch on demand."; relevant but whose current content the next session can fetch on demand.";
const SUMMARY_DESCRIPTION: &str = "Provide the final structured summary text. Subsequent calls \ const SUMMARY_DESCRIPTION: &str = "Provide the final structured summary text. Subsequent calls \
replace the previous content; only the last call is used. Must be called before the compact run \ replace the previous content; only the last call is used. Must be called before the compact run \
ends or compaction fails."; ends or compaction fails.";
const SEARCH_SESSION_DESCRIPTION: &str = "Search the compact-target session history by \
case-insensitive substring. Returns item indexes and compact snippets. Use this when the initial \
overview is not enough to identify which part of the session matters. Results are bounded; narrow \
the query if important details are omitted.";
const READ_SESSION_DESCRIPTION: &str = "Read a bounded range of compact-target session history \
items by 0-based index. mode='compact' omits tool arguments, full tool results, and reasoning \
bodies; mode='full' includes message text and tool result content but still remains bounded. Use \
this to verify details before writing the summary.";
struct SessionLogToolState {
items: Arc<Vec<Item>>,
view: SessionCapture,
}
struct SearchSessionLogTool {
state: Arc<SessionLogToolState>,
}
#[async_trait]
impl Tool for SearchSessionLogTool {
async fn execute(
&self,
input_json: &str,
_ctx: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: SearchSessionParams = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid search_session_log input: {e}"))
})?;
let query = params.query.trim().to_lowercase();
if query.is_empty() {
return Err(ToolError::InvalidArgument(
"search_session_log query must not be empty".to_string(),
));
}
let offset = params.offset.unwrap_or(0);
let limit = params
.limit
.unwrap_or(20)
.clamp(1, SESSION_SEARCH_MAX_RESULTS);
let hits = self.state.view.search(&SearchOptions {
query: params.query.clone(),
kind: None,
tool_part: None,
tool_name: None,
limit: Some(limit),
min_entry_index: Some(offset as u64),
from: None,
through: None,
offset: 0,
});
let blocks = hits
.iter()
.map(|hit| {
let part = hit
.tool_part
.map(|part| format!(" {part:?}"))
.unwrap_or_default();
let tool = hit
.tool_name
.as_ref()
.map(|name| format!(" {name}"))
.unwrap_or_default();
format!(
"[{} {}{}{} {:?}] {}\n{}",
hit.id,
hit.kind.as_str(),
part,
tool,
hit.entry_range,
hit.label,
hit.summary
)
})
.collect::<Vec<_>>();
let mut content = blocks.join("\n\n");
let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
let summary = if hits.is_empty() {
format!("No session log hits for {query:?} from item offset {offset}.")
} else if truncated {
format!(
"Found {} session log hit(s) for {query:?}; output truncated. Narrow the query.",
hits.len()
)
} else {
format!("Found {} session log hit(s) for {query:?}.", hits.len())
};
Ok(ToolOutput {
summary,
content: (!content.is_empty()).then_some(content),
attachments: Vec::new(),
})
}
}
struct ReadSessionItemsTool {
state: Arc<SessionLogToolState>,
}
#[async_trait]
impl Tool for ReadSessionItemsTool {
async fn execute(
&self,
input_json: &str,
_ctx: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ReadSessionParams = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid read_session_items input: {e}"))
})?;
let mode = SessionReadMode::parse(&params.mode)?;
let offset = params.offset.min(self.state.items.len());
let limit = params.limit.clamp(1, SESSION_READ_MAX_ITEMS);
let end = offset.saturating_add(limit).min(self.state.items.len());
let detail = match mode {
SessionReadMode::Compact => ReadDetail::Compact,
SessionReadMode::Full => ReadDetail::Full,
};
let read = if offset >= end {
crate::session_capture::ReadResult {
entries: Vec::new(),
truncated: false,
}
} else {
self.state.view.read(
ReadSelector::EntryRange([offset as u64, end.saturating_sub(1) as u64]),
ReadOptions {
include_tools: true,
tool_part: ToolPart::Both,
detail,
max_items: limit,
max_bytes: 48 * 1024,
},
)
};
let mut content = read
.entries
.iter()
.map(|entry| entry.text.clone())
.collect::<Vec<_>>()
.join("\n\n");
let token_truncated =
truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
let truncated = read.truncated || token_truncated;
let summary = if truncated {
format!(
"Read session items {offset}..{end} in {mode:?} mode; output truncated. Narrow the range."
)
} else {
format!("Read session items {offset}..{end} in {mode:?} mode.")
};
Ok(ToolOutput {
summary,
content: (!content.is_empty()).then_some(content),
attachments: Vec::new(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionReadMode {
Compact,
Full,
}
impl SessionReadMode {
fn parse(value: &str) -> Result<Self, ToolError> {
match value {
"compact" => Ok(Self::Compact),
"full" => Ok(Self::Full),
other => Err(ToolError::InvalidArgument(format!(
"invalid read_session_items mode {other:?}; expected 'compact' or 'full'"
))),
}
}
}
fn truncate_to_token_budget(text: &mut String, max_tokens: u64) -> bool {
let max_bytes = max_tokens.saturating_mul(4) as usize;
if text.len() <= max_bytes {
return false;
}
let mut cut = 0;
for (idx, _) in text.char_indices() {
if idx > max_bytes {
break;
}
cut = idx;
}
text.truncate(cut);
text.push_str("\n… [session tool output truncated]");
true
}
struct MarkReadRequiredTool { struct MarkReadRequiredTool {
session: WorkdirSessionHandle, session: WorkdirSessionHandle,
ctx: Arc<Mutex<CompactWorkerContext>>, ctx: Arc<Mutex<CompactWorkerContext>>,
@@ -508,36 +278,63 @@ pub(crate) fn write_summary_tool(ctx: Arc<Mutex<CompactWorkerContext>>) -> ToolD
}) })
} }
pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition { #[derive(Clone)]
let view = SessionCapture::new("compact-target", (*items).clone()); pub(crate) struct CompactionOutputFeature {
let state = Arc::new(SessionLogToolState { items, view }); session: Option<WorkdirSessionHandle>,
Arc::new(move || { tracker: tools::Tracker,
let schema = schemars::schema_for!(SearchSessionParams); context: Arc<Mutex<CompactWorkerContext>>,
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("search_session_log")
.description(SEARCH_SESSION_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
state: state.clone(),
});
(meta, tool)
})
} }
pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition { impl CompactionOutputFeature {
let view = SessionCapture::new("compact-target", (*items).clone()); pub(crate) fn new(
let state = Arc::new(SessionLogToolState { items, view }); session: Option<WorkdirSessionHandle>,
Arc::new(move || { tracker: tools::Tracker,
let schema = schemars::schema_for!(ReadSessionParams); context: Arc<Mutex<CompactWorkerContext>>,
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); ) -> Self {
let meta = ToolMeta::new("read_session_items") Self {
.description(READ_SESSION_DESCRIPTION) session,
.input_schema(schema_value); tracker,
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool { context,
state: state.clone(), }
}); }
(meta, tool) }
})
impl FeatureModule for CompactionOutputFeature {
fn descriptor(&self) -> FeatureDescriptor {
let descriptor = FeatureDescriptor::builtin("compaction-output", "Compaction Output")
.with_description("Read-only Workdir access and bounded compaction output decisions.")
.with_tool(ToolDeclaration::new("add_reference", REFERENCE_DESCRIPTION))
.with_tool(ToolDeclaration::new("write_summary", SUMMARY_DESCRIPTION));
if self.session.is_some() {
descriptor
.with_tool(ToolDeclaration::new("Read", "Read a Workdir file."))
.with_tool(ToolDeclaration::new("mark_read_required", MARK_DESCRIPTION))
} else {
descriptor
}
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if let Some(session) = &self.session {
context.tools().register(ToolContribution::new(
"Read",
tools::read_tool(session.clone(), self.tracker.clone()),
))?;
context.tools().register(ToolContribution::new(
"mark_read_required",
mark_read_required_tool(session.clone(), self.context.clone()),
))?;
}
context.tools().register(ToolContribution::new(
"add_reference",
add_reference_tool(self.context.clone()),
))?;
context.tools().register(ToolContribution::new(
"write_summary",
write_summary_tool(self.context.clone()),
))?;
Ok(())
}
} }
/// Interceptor that monitors compact-worker context occupancy. /// Interceptor that monitors compact-worker context occupancy.
@@ -810,53 +607,4 @@ mod tests {
assert_eq!(guard.references.len(), 1); assert_eq!(guard.references.len(), 1);
assert_eq!(guard.references[0], PathBuf::from(p)); assert_eq!(guard.references[0], PathBuf::from(p));
} }
#[tokio::test]
async fn search_session_log_returns_bounded_hits_without_full_tool_content() {
let items = Arc::new(vec![
Item::user_message("investigate compact failure"),
Item::tool_result_with_content(
"call-1",
"read trace with compact failure",
"very large raw trace body with secret detail",
),
]);
let view = SessionCapture::new("test", (*items).clone());
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
state: Arc::new(SessionLogToolState { items, view }),
});
let input = serde_json::json!({ "query": "compact", "limit": 10 }).to_string();
let out = tool.execute(&input, Default::default()).await.unwrap();
let content = out.content.unwrap();
assert!(content.contains("investigate compact failure"));
assert!(content.contains("read trace with compact failure"));
assert!(!content.contains("secret detail"));
}
#[tokio::test]
async fn read_session_items_full_mode_can_read_tool_result_content() {
let items = Arc::new(vec![Item::tool_result_with_content(
"call-1",
"read trace",
"raw trace detail",
)]);
let view = SessionCapture::new("test", (*items).clone());
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
state: Arc::new(SessionLogToolState { items, view }),
});
let input = serde_json::json!({ "offset": 0, "limit": 1, "mode": "full" }).to_string();
let out = tool.execute(&input, Default::default()).await.unwrap();
let content = out.content.unwrap();
assert!(content.contains("raw trace detail"));
}
#[test]
fn slice_lines_handles_offset_and_limit() {
let text = "a\nb\nc\nd";
assert_eq!(slice_lines(text, 0, None), "a\nb\nc\nd");
assert_eq!(slice_lines(text, 1, Some(2)), "b\nc");
assert_eq!(slice_lines(text, 10, None), "");
}
} }
+20 -4
View File
@@ -383,6 +383,7 @@ impl WorkerController {
worker.attach_alerter(alerter.clone()); worker.attach_alerter(alerter.clone());
// Also hand the raw broadcast sender so Worker-internal operations // Also hand the raw broadcast sender so Worker-internal operations
// can emit typed lifecycle `Event`s (currently: compact progress). // can emit typed lifecycle `Event`s (currently: compact progress).
worker.attach_internal_worker_registry(spawned_registry.clone());
worker.attach_event_tx(event_tx.clone()); worker.attach_event_tx(event_tx.clone());
// Bash spills long outputs to a per-worker subdir under the runtime // Bash spills long outputs to a per-worker subdir under the runtime
@@ -421,7 +422,6 @@ impl WorkerController {
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight); wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
// === 3. Tool registration (builtin / memory / spawn-orchestration) === // === 3. Tool registration (builtin / memory / spawn-orchestration) ===
spawned_registry.attach_parent_protocol(event_tx.clone(), worker.session_id().to_string());
let fs_for_view = register_worker_tools( let fs_for_view = register_worker_tools(
&mut worker, &mut worker,
bash_output_dir, bash_output_dir,
@@ -873,6 +873,14 @@ where
} }
let mut feature_registry = FeatureRegistryBuilder::new(); let mut feature_registry = FeatureRegistryBuilder::new();
if feature_config.sub_worker.enabled {
feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature(
worker.workspace_client_handle(),
spawned_registry.clone(),
),
);
}
if feature_config.task.enabled { if feature_config.task.enabled {
feature_registry.add_module(task_feature); feature_registry.add_module(task_feature);
} }
@@ -975,6 +983,14 @@ where
if feature_config.sub_worker.enabled { if feature_config.sub_worker.enabled {
worker.register_worker_orchestration_instruction(); worker.register_worker_orchestration_instruction();
if !feature_config.worker.enabled {
feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature(
worker.workspace_client_handle(),
spawned_registry.clone(),
),
);
}
} }
let host_worker_observation_provider = worker.worker_observation_provider(); let host_worker_observation_provider = worker.worker_observation_provider();
@@ -1744,7 +1760,7 @@ where
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>) fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>)
where where
C: LlmClient, C: LlmClient + 'static,
St: Store, St: Store,
{ {
match worker.list_rewind_targets() { match worker.list_rewind_targets() {
@@ -1770,7 +1786,7 @@ fn apply_rewind<C, St>(
expected_head_entries: usize, expected_head_entries: usize,
) -> bool ) -> bool
where where
C: LlmClient, C: LlmClient + 'static,
St: Store, St: Store,
{ {
match worker.rewind_to(target, expected_head_entries) { match worker.rewind_to(target, expected_head_entries) {
@@ -1818,7 +1834,7 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
where where
C: LlmClient, C: LlmClient + 'static,
St: Store, St: Store,
{ {
let manifest = worker.manifest(); let manifest = worker.manifest();
+622 -73
View File
@@ -12,7 +12,7 @@
//! ordinary feature reports/diagnostics instead of a separate authority layer. //! ordinary feature reports/diagnostics instead of a separate authority layer.
use std::any::{Any, type_name}; use std::any::{Any, type_name};
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fmt; use std::fmt;
use std::sync::Arc; use std::sync::Arc;
@@ -447,6 +447,16 @@ impl ServiceVersionReq {
requirement: "*".into(), requirement: "*".into(),
} }
} }
pub fn exact(version: impl Into<String>) -> Self {
Self {
requirement: version.into(),
}
}
fn matches(&self, provider_version: &str) -> bool {
self.requirement == "*" || self.requirement == provider_version
}
} }
/// Feature-provided service declaration. This first slice records provider /// Feature-provided service declaration. This first slice records provider
@@ -1430,11 +1440,13 @@ impl FeatureInstallContext<'_> {
pub struct FeatureRegistryInstallReport { pub struct FeatureRegistryInstallReport {
pub reports: Vec<FeatureInstallReport>, pub reports: Vec<FeatureInstallReport>,
pub services: FeatureServiceRegistry, pub services: FeatureServiceRegistry,
pub plan_error: Option<FeaturePlanError>,
} }
impl FeatureRegistryInstallReport { impl FeatureRegistryInstallReport {
pub fn has_errors(&self) -> bool { pub fn has_errors(&self) -> bool {
self.reports.iter().any(|report| { self.plan_error.is_some()
|| self.reports.iter().any(|report| {
report report
.diagnostics .diagnostics
.iter() .iter()
@@ -1443,17 +1455,19 @@ impl FeatureRegistryInstallReport {
} }
pub fn error_message(&self) -> String { pub fn error_message(&self) -> String {
self.reports let mut errors = self
.plan_error
.iter() .iter()
.flat_map(|report| { .map(ToString::to_string)
.collect::<Vec<_>>();
errors.extend(self.reports.iter().flat_map(|report| {
report report
.diagnostics .diagnostics
.iter() .iter()
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error) .filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message)) .map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
}) }));
.collect::<Vec<_>>() errors.join("; ")
.join("; ")
} }
pub fn installed_tool_names(&self) -> Vec<String> { pub fn installed_tool_names(&self) -> Vec<String> {
@@ -1485,12 +1499,265 @@ pub fn dedupe_instruction_contributions(
deduped deduped
} }
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PlannedServiceProvider {
pub service: ServiceId,
pub provider: FeatureId,
pub version: String,
}
/// A validated, deterministic installation order and its selected service providers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FeatureInstallPlan {
ordered_features: Vec<FeatureId>,
service_providers: BTreeMap<ServiceId, PlannedServiceProvider>,
ordered_indices: Vec<usize>,
}
impl FeatureInstallPlan {
pub fn ordered_features(&self) -> &[FeatureId] {
&self.ordered_features
}
pub fn service_providers(&self) -> &BTreeMap<ServiceId, PlannedServiceProvider> {
&self.service_providers
}
}
#[derive(Clone, Debug, Eq, PartialEq, Error)]
pub enum FeaturePlanError {
#[error("feature `{feature}` is registered more than once")]
DuplicateFeature { feature: FeatureId },
#[error("service `{service}` has multiple selected providers: {providers:?}")]
AmbiguousServiceProvider {
service: ServiceId,
providers: Vec<FeatureId>,
},
#[error("feature `{consumer}` requires service `{service}`, but no provider is selected")]
MissingServiceProvider {
consumer: FeatureId,
service: ServiceId,
},
#[error(
"feature `{consumer}` requires service `{service}` version `{requirement}`, but provider `{provider}` provides `{provider_version}`"
)]
ServiceVersionMismatch {
consumer: FeatureId,
service: ServiceId,
requirement: String,
provider: FeatureId,
provider_version: String,
},
#[error(
"service dependency cycle involves features {features:?} through services {services:?}"
)]
ServiceDependencyCycle {
features: Vec<FeatureId>,
services: Vec<ServiceId>,
},
}
/// Builder/installer for enabled feature modules. /// Builder/installer for enabled feature modules.
#[derive(Default)]
pub struct FeatureRegistryBuilder { pub struct FeatureRegistryBuilder {
modules: Vec<Arc<dyn FeatureModule>>, modules: Vec<Arc<dyn FeatureModule>>,
} }
fn build_feature_install_plan(
descriptors: &[FeatureDescriptor],
) -> Result<FeatureInstallPlan, FeaturePlanError> {
let mut feature_ids = BTreeSet::new();
for descriptor in descriptors {
if !feature_ids.insert(descriptor.id.clone()) {
return Err(FeaturePlanError::DuplicateFeature {
feature: descriptor.id.clone(),
});
}
}
let mut provider_candidates: BTreeMap<ServiceId, Vec<(usize, ServiceDeclaration)>> =
BTreeMap::new();
for (index, descriptor) in descriptors.iter().enumerate() {
for service in &descriptor.provides_services {
provider_candidates
.entry(service.id.clone())
.or_default()
.push((index, service.clone()));
}
}
let mut selected_providers = BTreeMap::new();
for (service, candidates) in provider_candidates {
if candidates.len() > 1 {
let mut providers = candidates
.iter()
.map(|(index, _)| descriptors[*index].id.clone())
.collect::<Vec<_>>();
providers.sort();
return Err(FeaturePlanError::AmbiguousServiceProvider { service, providers });
}
let (provider_index, declaration) = candidates
.into_iter()
.next()
.expect("provider candidates are non-empty");
selected_providers.insert(service, (provider_index, declaration));
}
let active = (0..descriptors.len()).collect::<BTreeSet<_>>();
let mut adjacency = vec![BTreeSet::new(); descriptors.len()];
let mut indegree = vec![0usize; descriptors.len()];
let mut edge_services: BTreeMap<(usize, usize), BTreeSet<ServiceId>> = BTreeMap::new();
for &consumer_index in &active {
let consumer = &descriptors[consumer_index];
for requirement in &consumer.requires_services {
let provider = selected_providers.get(&requirement.id);
let Some((provider_index, declaration)) = provider else {
if requirement.required {
return Err(FeaturePlanError::MissingServiceProvider {
consumer: consumer.id.clone(),
service: requirement.id.clone(),
});
}
continue;
};
if !requirement.version.matches(&declaration.version) {
return Err(FeaturePlanError::ServiceVersionMismatch {
consumer: consumer.id.clone(),
service: requirement.id.clone(),
requirement: requirement.version.requirement.clone(),
provider: descriptors[*provider_index].id.clone(),
provider_version: declaration.version.clone(),
});
}
if adjacency[*provider_index].insert(consumer_index) {
indegree[consumer_index] += 1;
}
edge_services
.entry((*provider_index, consumer_index))
.or_default()
.insert(requirement.id.clone());
}
}
let mut ready = active
.iter()
.copied()
.filter(|index| indegree[*index] == 0)
.collect::<BTreeSet<_>>();
let mut ordered_indices = Vec::with_capacity(active.len());
while let Some(index) = ready.pop_first() {
ordered_indices.push(index);
for &dependent in &adjacency[index] {
indegree[dependent] -= 1;
if indegree[dependent] == 0 {
ready.insert(dependent);
}
}
}
if ordered_indices.len() != active.len() {
let cycle_indices = find_service_dependency_cycle(&active, &adjacency);
let features = cycle_indices
.iter()
.map(|index| descriptors[*index].id.clone())
.collect::<Vec<_>>();
let mut services = BTreeSet::new();
for edge in cycle_indices.windows(2) {
if let Some(ids) = edge_services.get(&(edge[0], edge[1])) {
services.extend(ids.iter().cloned());
}
}
if let (Some(first), Some(last)) = (cycle_indices.first(), cycle_indices.last())
&& let Some(ids) = edge_services.get(&(*last, *first))
{
services.extend(ids.iter().cloned());
}
return Err(FeaturePlanError::ServiceDependencyCycle {
features,
services: services.into_iter().collect(),
});
}
let service_providers = selected_providers
.into_iter()
.map(|(service, (provider_index, declaration))| {
let planned = PlannedServiceProvider {
service: service.clone(),
provider: descriptors[provider_index].id.clone(),
version: declaration.version,
};
(service, planned)
})
.collect();
let ordered_features = ordered_indices
.iter()
.map(|index| descriptors[*index].id.clone())
.collect();
Ok(FeatureInstallPlan {
ordered_features,
service_providers,
ordered_indices,
})
}
fn find_service_dependency_cycle(
active: &BTreeSet<usize>,
adjacency: &[BTreeSet<usize>],
) -> Vec<usize> {
fn visit(
index: usize,
active: &BTreeSet<usize>,
adjacency: &[BTreeSet<usize>],
state: &mut [u8],
stack: &mut Vec<usize>,
) -> Option<Vec<usize>> {
state[index] = 1;
stack.push(index);
for &next in &adjacency[index] {
if !active.contains(&next) {
continue;
}
match state[next] {
0 => {
if let Some(cycle) = visit(next, active, adjacency, state, stack) {
return Some(cycle);
}
}
1 => {
let start = stack
.iter()
.position(|candidate| *candidate == next)
.expect("visiting node is present in DFS stack");
return Some(stack[start..].to_vec());
}
_ => {}
}
}
stack.pop();
state[index] = 2;
None
}
let mut state = vec![0u8; adjacency.len()];
let mut stack = Vec::new();
for &index in active {
if state[index] == 0
&& let Some(cycle) = visit(index, active, adjacency, &mut state, &mut stack)
{
return cycle;
}
}
Vec::new()
}
impl Default for FeatureRegistryBuilder {
fn default() -> Self {
Self {
modules: Vec::new(),
}
}
}
impl FeatureRegistryBuilder { impl FeatureRegistryBuilder {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
@@ -1523,10 +1790,14 @@ impl FeatureRegistryBuilder {
.collect() .collect()
} }
pub fn plan(&self) -> Result<FeatureInstallPlan, FeaturePlanError> {
build_feature_install_plan(&self.descriptors())
}
/// Install modules into the existing Engine tool path and hook builder. /// Install modules into the existing Engine tool path and hook builder.
pub(crate) fn install_into_engine<C: LlmClient>( pub(crate) fn install_into_engine<C: LlmClient, A>(
self, self,
worker: &mut Engine<C, Mutable>, worker: &mut Engine<C, Mutable, A>,
hook_builder: &mut HookRegistryBuilder, hook_builder: &mut HookRegistryBuilder,
) -> FeatureRegistryInstallReport { ) -> FeatureRegistryInstallReport {
let mut pending_tools = Vec::new(); let mut pending_tools = Vec::new();
@@ -1568,53 +1839,52 @@ impl FeatureRegistryBuilder {
.iter() .iter()
.map(|module| module.descriptor()) .map(|module| module.descriptor())
.collect(); .collect();
let mut service_registry = FeatureServiceRegistry::default(); let plan = match build_feature_install_plan(&descriptors) {
let mut reports = Vec::with_capacity(self.modules.len()); Ok(plan) => plan,
let mut seen_features = HashSet::new(); Err(error) => {
let message = error.to_string();
let mut pending_modules: Vec<_> = self.modules.into_iter().zip(descriptors).collect(); let reports = descriptors
let mut ordered_modules = Vec::with_capacity(pending_modules.len());
let mut declared_services = HashSet::new();
while !pending_modules.is_empty() {
let next = pending_modules
.iter() .iter()
.position(|(_, descriptor)| { .map(|descriptor| {
descriptor let mut report = FeatureInstallReport::new(descriptor);
.requires_services report
.iter() .diagnostics
.filter(|requirement| requirement.required) .push(FeatureDiagnostic::error(message.clone()));
.all(|requirement| declared_services.contains(&requirement.id)) report.mark_skipped(
}) FeatureContributionKind::Diagnostic,
.unwrap_or(0); descriptor.id.to_string(),
let entry = pending_modules.remove(next); "feature installation plan rejected before installation",
declared_services.extend(
entry
.1
.provides_services
.iter()
.map(|service| service.id.clone()),
); );
ordered_modules.push(entry); report
})
.collect();
return FeatureRegistryInstallReport {
reports,
services: FeatureServiceRegistry::default(),
plan_error: Some(error),
};
} }
};
let mut service_registry = FeatureServiceRegistry::default();
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
let ordered_modules = plan
.ordered_indices
.into_iter()
.map(|index| {
(
modules[index]
.take()
.expect("planned feature module is selected exactly once"),
descriptors[index].clone(),
)
})
.collect::<Vec<_>>();
for (module, descriptor) in ordered_modules { for (module, descriptor) in ordered_modules {
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor); let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
let mut report = FeatureInstallReport::new(&descriptor); let mut report = FeatureInstallReport::new(&descriptor);
if !seen_features.insert(descriptor.id.clone()) {
report.diagnostics.push(FeatureDiagnostic::error(format!(
"duplicate feature id: {}",
descriptor.id
)));
report.mark_skipped(
FeatureContributionKind::Diagnostic,
descriptor.id.to_string(),
"duplicate feature id",
);
reports.push(report);
continue;
}
let mut required_service_failed = false; let mut required_service_failed = false;
for requirement in descriptor.requires_services.iter().cloned() { for requirement in descriptor.requires_services.iter().cloned() {
if service_registry.provides(&requirement.id) { if service_registry.provides(&requirement.id) {
@@ -1681,6 +1951,7 @@ impl FeatureRegistryBuilder {
FeatureRegistryInstallReport { FeatureRegistryInstallReport {
reports, reports,
services: service_registry, services: service_registry,
plan_error: None,
} }
} }
} }
@@ -1821,6 +2092,59 @@ mod tests {
} }
} }
struct PlannedServiceFeature {
descriptor: FeatureDescriptor,
install_calls: Arc<AtomicUsize>,
fail_install: bool,
}
impl PlannedServiceFeature {
fn new(descriptor: FeatureDescriptor) -> Self {
Self {
descriptor,
install_calls: Arc::new(AtomicUsize::new(0)),
fail_install: false,
}
}
}
impl FeatureModule for PlannedServiceFeature {
fn descriptor(&self) -> FeatureDescriptor {
self.descriptor.clone()
}
fn install(
&self,
context: &mut FeatureInstallContext<'_>,
) -> Result<(), FeatureInstallError> {
self.install_calls.fetch_add(1, Ordering::SeqCst);
if self.fail_install {
return Err(FeatureInstallError::Install(
"injected provider failure".into(),
));
}
for service in &self.descriptor.provides_services {
context
.services()
.provide(service.clone(), Arc::new(service.id.to_string()))?;
}
Ok(())
}
}
fn provided_service(id: &'static str, version: &'static str) -> ServiceDeclaration {
ServiceDeclaration::new(ServiceId::builtin(id), version, "test service")
}
fn required_service(id: &'static str, version: ServiceVersionReq) -> ServiceRequirement {
{
let mut requirement =
ServiceRequirement::required(ServiceId::builtin(id), "test dependency");
requirement.version = version;
requirement
}
}
fn instruction(id: &'static str, prompt_ref: &'static str) -> FeatureInstructionDeclaration { fn instruction(id: &'static str, prompt_ref: &'static str) -> FeatureInstructionDeclaration {
FeatureInstructionDeclaration::new( FeatureInstructionDeclaration::new(
FeatureInstructionId::builtin(id), FeatureInstructionId::builtin(id),
@@ -1830,6 +2154,249 @@ mod tests {
.unwrap() .unwrap()
} }
#[test]
fn install_plan_orders_provider_before_consumer_stably() {
let consumer = PlannedServiceFeature::new(
FeatureDescriptor::builtin("consumer", "Consumer")
.with_service_requirement(required_service("catalog", ServiceVersionReq::any())),
);
let unrelated =
PlannedServiceFeature::new(FeatureDescriptor::builtin("unrelated", "Unrelated"));
let provider = PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider", "Provider")
.with_provided_service(provided_service("catalog", "1")),
);
let builder = FeatureRegistryBuilder::new()
.with_module(consumer)
.with_module(unrelated)
.with_module(provider);
let plan = builder.plan().expect("service graph should be valid");
assert_eq!(
plan.ordered_features(),
&[
FeatureId::builtin("unrelated"),
FeatureId::builtin("provider"),
FeatureId::builtin("consumer"),
]
);
}
#[test]
fn install_plan_orders_compatible_optional_provider_before_consumer() {
let service = ServiceId::builtin("catalog");
let consumer = PlannedServiceFeature::new(
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
ServiceRequirement::optional(service.clone(), "optional catalog"),
),
);
let provider = PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider", "Provider")
.with_provided_service(provided_service("catalog", "1")),
);
let builder = FeatureRegistryBuilder::new()
.with_module(consumer)
.with_module(provider);
let plan = builder.plan().expect("optional binding should be valid");
assert_eq!(
plan.ordered_features(),
&[
FeatureId::builtin("provider"),
FeatureId::builtin("consumer"),
]
);
let mut hooks = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = builder.install_into_pending(&mut pending_tools, &mut hooks);
assert!(!report.has_errors());
let consumer = report
.reports
.iter()
.find(|report| report.feature_id == FeatureId::builtin("consumer"))
.expect("consumer report");
assert!(
consumer
.resolved_service_requirements
.iter()
.any(|requirement| requirement.id == service)
);
}
#[test]
fn install_plan_rejects_missing_required_service() {
let builder = FeatureRegistryBuilder::new().with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("consumer", "Consumer")
.with_service_requirement(required_service("missing", ServiceVersionReq::any())),
));
assert_eq!(
builder.plan(),
Err(FeaturePlanError::MissingServiceProvider {
consumer: FeatureId::builtin("consumer"),
service: ServiceId::builtin("missing"),
})
);
}
#[test]
fn install_plan_rejects_ambiguous_provider_independent_of_registration_order() {
fn plan(reverse: bool) -> FeaturePlanError {
let provider_a = PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider-a", "Provider A")
.with_provided_service(provided_service("catalog", "1")),
);
let provider_b = PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider-b", "Provider B")
.with_provided_service(provided_service("catalog", "1")),
);
let mut builder = FeatureRegistryBuilder::new();
if reverse {
builder.add_module(provider_b).add_module(provider_a);
} else {
builder.add_module(provider_a).add_module(provider_b);
}
builder.plan().expect_err("duplicate providers must fail")
}
let expected = FeaturePlanError::AmbiguousServiceProvider {
service: ServiceId::builtin("catalog"),
providers: vec![
FeatureId::builtin("provider-a"),
FeatureId::builtin("provider-b"),
],
};
assert_eq!(plan(false), expected);
assert_eq!(plan(true), expected);
}
#[test]
fn install_plan_rejects_service_version_mismatch() {
let builder = FeatureRegistryBuilder::new()
.with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
required_service("catalog", ServiceVersionReq::exact("2")),
),
))
.with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider", "Provider")
.with_provided_service(provided_service("catalog", "1")),
));
assert_eq!(
builder.plan(),
Err(FeaturePlanError::ServiceVersionMismatch {
consumer: FeatureId::builtin("consumer"),
service: ServiceId::builtin("catalog"),
requirement: "2".into(),
provider: FeatureId::builtin("provider"),
provider_version: "1".into(),
})
);
}
#[test]
fn install_plan_reports_service_cycle_members() {
let builder = FeatureRegistryBuilder::new()
.with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("alpha", "Alpha")
.with_provided_service(provided_service("alpha-service", "1"))
.with_service_requirement(required_service(
"beta-service",
ServiceVersionReq::any(),
)),
))
.with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("beta", "Beta")
.with_provided_service(provided_service("beta-service", "1"))
.with_service_requirement(required_service(
"alpha-service",
ServiceVersionReq::any(),
)),
));
let error = builder.plan().expect_err("cycle must fail before install");
let FeaturePlanError::ServiceDependencyCycle { features, services } = error else {
panic!("unexpected plan error: {error:?}");
};
assert_eq!(
features.into_iter().collect::<BTreeSet<_>>(),
BTreeSet::from([FeatureId::builtin("alpha"), FeatureId::builtin("beta")])
);
assert_eq!(
services.into_iter().collect::<BTreeSet<_>>(),
BTreeSet::from([
ServiceId::builtin("alpha-service"),
ServiceId::builtin("beta-service"),
])
);
}
#[test]
fn plan_failure_has_no_install_side_effects_and_reports_identities() {
let first = PlannedServiceFeature::new(
FeatureDescriptor::builtin("first", "First")
.with_provided_service(provided_service("control", "1")),
);
let first_calls = Arc::clone(&first.install_calls);
let second = PlannedServiceFeature::new(
FeatureDescriptor::builtin("second", "Second")
.with_provided_service(provided_service("control", "1")),
);
let second_calls = Arc::clone(&second.install_calls);
let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(first)
.with_module(second)
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.has_errors());
assert!(matches!(
report.plan_error,
Some(FeaturePlanError::AmbiguousServiceProvider { .. })
));
assert!(report.error_message().contains("builtin:control"));
assert!(report.error_message().contains("builtin:first"));
assert!(report.error_message().contains("builtin:second"));
assert_eq!(first_calls.load(Ordering::SeqCst), 0);
assert_eq!(second_calls.load(Ordering::SeqCst), 0);
assert!(pending_tools.is_empty());
}
#[test]
fn provider_install_failure_is_fatal_and_does_not_queue_tools() {
let provider_calls = Arc::new(AtomicUsize::new(0));
let provider = PlannedServiceFeature {
descriptor: FeatureDescriptor::builtin("provider", "Provider")
.with_provided_service(provided_service("control", "1")),
install_calls: Arc::clone(&provider_calls),
fail_install: true,
};
let consumer = ToolFeature {
descriptor: FeatureDescriptor::builtin("consumer", "Consumer")
.with_service_requirement(required_service("control", ServiceVersionReq::any()))
.with_tool(ToolDeclaration::new("Dependent", "dependent tool")),
contribution_name: "Dependent",
model_visible_name: "Dependent",
};
let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(consumer)
.with_module(provider)
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.has_errors());
assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
assert!(pending_tools.is_empty());
assert!(report.error_message().contains("builtin:provider"));
assert!(report.error_message().contains("injected provider failure"));
}
#[test] #[test]
fn descriptor_contributions_are_recorded() { fn descriptor_contributions_are_recorded() {
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy") let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
@@ -2382,10 +2949,6 @@ mod tests {
); );
let consumer = FeatureDescriptor::builtin("consumer", "Consumer") let consumer = FeatureDescriptor::builtin("consumer", "Consumer")
.with_service_requirement(ServiceRequirement::required(service.clone(), "needs demo")); .with_service_requirement(ServiceRequirement::required(service.clone(), "needs demo"));
let missing_service = ServiceId::builtin("missing-service");
let missing = FeatureDescriptor::builtin("missing", "Missing").with_service_requirement(
ServiceRequirement::required(missing_service, "needs missing"),
);
let optional_service = ServiceId::builtin("optional-service"); let optional_service = ServiceId::builtin("optional-service");
let optional = FeatureDescriptor::builtin("optional", "Optional").with_service_requirement( let optional = FeatureDescriptor::builtin("optional", "Optional").with_service_requirement(
ServiceRequirement::optional(optional_service, "nice to have"), ServiceRequirement::optional(optional_service, "nice to have"),
@@ -2399,9 +2962,6 @@ mod tests {
.with_module(ServiceFeature { .with_module(ServiceFeature {
descriptor: consumer, descriptor: consumer,
}) })
.with_module(ServiceFeature {
descriptor: missing,
})
.with_module(ServiceFeature { .with_module(ServiceFeature {
descriptor: optional, descriptor: optional,
}) })
@@ -2413,18 +2973,6 @@ mod tests {
report.reports[1].resolved_service_requirements[0].id, report.reports[1].resolved_service_requirements[0].id,
service service
); );
let missing_report = report
.reports
.iter()
.find(|feature| feature.feature_id == FeatureId::builtin("missing"))
.unwrap();
assert!(!missing_report.installed);
assert!(
missing_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("required service requirement"))
);
let optional_report = report let optional_report = report
.reports .reports
.iter() .iter()
@@ -2543,11 +3091,12 @@ mod tests {
.with_module(ServiceFeature { descriptor }) .with_module(ServiceFeature { descriptor })
.install_into_pending(&mut pending_tools, &mut hook_builder); .install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.has_errors()); assert!(report.has_errors());
assert!( assert!(matches!(
report report.plan_error,
.error_message() Some(FeaturePlanError::MissingServiceProvider { .. })
.contains("required service requirement") ));
); assert!(report.error_message().contains("builtin:consumer"));
assert!(report.error_message().contains("builtin:missing-service"));
} }
#[test] #[test]
@@ -560,6 +560,7 @@ where
attempt.instance_id, attempt.checked_state_revision attempt.instance_id, attempt.checked_state_revision
)), )),
max_turns: Some(12), max_turns: Some(12),
engine_configurator: None,
features, features,
required_tools: &[ required_tools: &[
"ShowOverview", "ShowOverview",
@@ -571,6 +572,7 @@ where
workspace: WorkerWorkspaceContext::no_workspace(), workspace: WorkerWorkspaceContext::no_workspace(),
filesystem: WorkerFilesystemAuthority::None, filesystem: WorkerFilesystemAuthority::None,
scope: Scope::empty(), scope: Scope::empty(),
workdir_session: None,
}, },
}; };
match run_internal_worker(spec).await { match run_internal_worker(spec).await {
@@ -398,24 +398,35 @@ impl WorkspaceHttpWorkdirBackend {
workdir_output(format!("Listed {count} Workdir(s)"), &response) workdir_output(format!("Listed {count} Workdir(s)"), &response)
} }
fn create(&self, input: WorkdirCreateInput) -> Result<ToolOutput, ToolError> { fn create(
let runtime_id = validate_identity(&input.runtime_id, CREATE_TOOL, "runtime_id")?; &self,
input: WorkdirCreateInput,
operation_id: String,
) -> Result<ToolOutput, ToolError> {
let runtime_id = input
.runtime_id
.as_deref()
.map(|value| validate_identity(value, CREATE_TOOL, "runtime_id"))
.transpose()?;
let repository_id = validate_identity(&input.repository_id, CREATE_TOOL, "repository_id")?; let repository_id = validate_identity(&input.repository_id, CREATE_TOOL, "repository_id")?;
let selector = validate_optional_selector(input.selector)?; let selector = validate_optional_selector(input.selector)?;
let workspace_id = encode_path_segment(self.workspace_id()?); let workspace_id = encode_path_segment(self.workspace_id()?);
let runtime_path = encode_path_segment(runtime_id);
let request = WorkdirCreateRequest { let request = WorkdirCreateRequest {
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.map(str::to_string),
repository_id: repository_id.to_string(), repository_id: repository_id.to_string(),
selector, selector,
operation_id,
}; };
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json( let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/runtimes/{runtime_path}/working-directories"), format!("/api/w/{workspace_id}/working-directories"),
serde_json::to_string(&request).map_err(decode_error)?, serde_json::to_string(&request).map_err(decode_error)?,
))?; ))?;
workdir_output( workdir_output(
format!("Created Workdir {}", response.item.working_directory_id), format!(
"Created Workdir {} on Runtime {}",
response.item.working_directory_id, response.runtime_id
),
&response, &response,
) )
} }
@@ -518,16 +529,17 @@ impl Tool for WorkspaceHttpWorkdirTool {
async fn execute( async fn execute(
&self, &self,
input_json: &str, input_json: &str,
_ctx: ToolExecutionContext, ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
match self.operation { match self.operation {
WorkdirOperation::List => { WorkdirOperation::List => {
let _input = parse_input::<WorkdirListInput>(input_json)?; let _input = parse_input::<WorkdirListInput>(input_json)?;
self.backend.list() self.backend.list()
} }
WorkdirOperation::Create => self WorkdirOperation::Create => self.backend.create(
.backend parse_input::<WorkdirCreateInput>(input_json)?,
.create(parse_input::<WorkdirCreateInput>(input_json)?), ctx.call_id.to_string(),
),
WorkdirOperation::Attach => self WorkdirOperation::Attach => self
.backend .backend
.attach(parse_input::<WorkdirAttachInput>(input_json)?), .attach(parse_input::<WorkdirAttachInput>(input_json)?),
@@ -635,9 +647,9 @@ fn create_schema() -> serde_json::Value {
json!({ json!({
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["runtime_id", "repository_id"], "required": ["repository_id"],
"properties": { "properties": {
"runtime_id": {"type": "string", "minLength": 1}, "runtime_id": {"type": ["string", "null"], "minLength": 1},
"repository_id": {"type": "string", "minLength": 1}, "repository_id": {"type": "string", "minLength": 1},
"selector": {"type": ["string", "null"], "minLength": 1} "selector": {"type": ["string", "null"], "minLength": 1}
} }
@@ -677,7 +689,8 @@ struct WorkdirListInput {}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct WorkdirCreateInput { struct WorkdirCreateInput {
runtime_id: String, #[serde(default)]
runtime_id: Option<String>,
repository_id: String, repository_id: String,
#[serde(default)] #[serde(default)]
selector: Option<String>, selector: Option<String>,
@@ -685,10 +698,12 @@ struct WorkdirCreateInput {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct WorkdirCreateRequest { struct WorkdirCreateRequest {
runtime_id: String, #[serde(skip_serializing_if = "Option::is_none")]
runtime_id: Option<String>,
repository_id: String, repository_id: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
selector: Option<String>, selector: Option<String>,
operation_id: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -916,7 +931,11 @@ mod tests {
#[test] #[test]
fn schemas_expose_identities_without_paths_or_session_handles() { fn schemas_expose_identities_without_paths_or_session_handles() {
let create = create_schema(); let create = create_schema();
assert_eq!(create["required"], json!(["runtime_id", "repository_id"])); assert_eq!(create["required"], json!(["repository_id"]));
assert_eq!(
create["properties"]["runtime_id"]["type"],
json!(["string", "null"])
);
assert!(create["properties"].get("path").is_none()); assert!(create["properties"].get("path").is_none());
assert!(create["properties"].get("session_id").is_none()); assert!(create["properties"].get("session_id").is_none());
assert_eq!(attach_schema()["required"], json!(["workdir_id"])); assert_eq!(attach_schema()["required"], json!(["workdir_id"]));
@@ -946,6 +965,7 @@ mod tests {
})), })),
response(json!({ response(json!({
"workspace_id": "workspace/test", "workspace_id": "workspace/test",
"runtime_id": "runtime/one",
"item": workdir_json("wd-created"), "item": workdir_json("wd-created"),
"diagnostics": [] "diagnostics": []
})), })),
@@ -961,6 +981,7 @@ mod tests {
})), })),
response(json!({ response(json!({
"workspace_id": "workspace/test", "workspace_id": "workspace/test",
"runtime_id": "runtime/one",
"item": { "item": {
"working_directory_id": "wd-created", "working_directory_id": "wd-created",
"repository_id": "main", "repository_id": "main",
@@ -985,13 +1006,19 @@ mod tests {
.is_none() .is_none()
); );
let created = backend let created = backend
.create(WorkdirCreateInput { .create(
runtime_id: "runtime/one".to_string(), WorkdirCreateInput {
runtime_id: Some("runtime/one".to_string()),
repository_id: "main".to_string(), repository_id: "main".to_string(),
selector: Some("refs/heads/topic".to_string()), selector: Some("refs/heads/topic".to_string()),
}) },
"call-create-1".to_string(),
)
.unwrap(); .unwrap();
assert_eq!(created.summary, "Created Workdir wd-created"); assert_eq!(
created.summary,
"Created Workdir wd-created on Runtime runtime/one"
);
let created: serde_json::Value = let created: serde_json::Value =
serde_json::from_str(created.content.as_deref().unwrap()).unwrap(); serde_json::from_str(created.content.as_deref().unwrap()).unwrap();
assert_eq!(created["item"]["working_directory_id"], "wd-created"); assert_eq!(created["item"]["working_directory_id"], "wd-created");
@@ -1017,12 +1044,14 @@ mod tests {
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
assert_eq!( assert_eq!(
requests[1].path, requests[1].path,
"/api/w/workspace%2Ftest/runtimes/runtime%2Fone/working-directories" "/api/w/workspace%2Ftest/working-directories"
); );
assert_eq!(requests[1].method, WorkspaceRequestMethod::Post); assert_eq!(requests[1].method, WorkspaceRequestMethod::Post);
let body: serde_json::Value = let body: serde_json::Value =
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
assert_eq!(body["repository_id"], "main"); assert_eq!(body["repository_id"], "main");
assert_eq!(body["runtime_id"], "runtime/one");
assert_eq!(body["operation_id"], "call-create-1");
assert_eq!(body["selector"], "refs/heads/topic"); assert_eq!(body["selector"], "refs/heads/topic");
assert_eq!( assert_eq!(
requests[2].path, requests[2].path,
@@ -1216,16 +1245,50 @@ mod tests {
assert_eq!(body["operation"]["request"]["path"], "file"); assert_eq!(body["operation"]["request"]["path"], "file");
} }
#[test]
fn create_omits_runtime_for_backend_default_resolution() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"workspace_id": "workspace/test",
"runtime_id": "arcadia",
"item": workdir_json("wd-default"),
"diagnostics": []
}))]));
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
let created = backend
.create(
WorkdirCreateInput {
runtime_id: None,
repository_id: "main".to_string(),
selector: None,
},
"call-default".to_string(),
)
.unwrap();
assert_eq!(
created.summary,
"Created Workdir wd-default on Runtime arcadia"
);
let requests = client.requests();
let body: serde_json::Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert!(body.get("runtime_id").is_none());
assert_eq!(body["operation_id"], "call-default");
}
#[test] #[test]
fn invalid_or_extra_inputs_are_rejected_before_workspace_request() { fn invalid_or_extra_inputs_are_rejected_before_workspace_request() {
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new())); let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
let backend = WorkspaceHttpWorkdirBackend::new(client.clone()); let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
let error = backend let error = backend
.create(WorkdirCreateInput { .create(
runtime_id: " ".to_string(), WorkdirCreateInput {
runtime_id: Some(" ".to_string()),
repository_id: "main".to_string(), repository_id: "main".to_string(),
selector: None, selector: None,
}) },
"call-invalid".to_string(),
)
.unwrap_err(); .unwrap_err();
assert!(matches!(error, ToolError::InvalidArgument(_))); assert!(matches!(error, ToolError::InvalidArgument(_)));
assert!(client.requests().is_empty()); assert!(client.requests().is_empty());
@@ -356,6 +356,52 @@ pub fn manage_worker_feature(
} }
} }
pub struct SubWorkerControlFeature {
client: Arc<dyn WorkspaceClient>,
registry: Arc<SpawnedWorkerRegistry>,
}
impl SubWorkerControlFeature {
pub fn new(client: Arc<dyn WorkspaceClient>, registry: Arc<SpawnedWorkerRegistry>) -> Self {
Self { client, registry }
}
}
impl FeatureModule for SubWorkerControlFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("sub-worker-control", "SubWorker Control")
.with_description("Parent-owned SubWorker control service provider")
.with_provided_service(ServiceDeclaration::new(
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Parent-owned SubWorker discovery and control operations",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let control: Arc<dyn WorkerControlService> = Arc::new(WorkspaceWorkerControlService {
workspace_id: self.client.workspace_id().unwrap_or_default().to_string(),
client: self.client.clone(),
registry: Some(self.registry.clone()),
});
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Parent-owned SubWorker discovery and control operations",
),
control,
)
}
}
pub fn sub_worker_control_feature(
client: Arc<dyn WorkspaceClient>,
registry: Arc<SpawnedWorkerRegistry>,
) -> SubWorkerControlFeature {
SubWorkerControlFeature::new(client, registry)
}
impl FeatureModule for ManageWorkerFeature { impl FeatureModule for ManageWorkerFeature {
fn descriptor(&self) -> FeatureDescriptor { fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME) let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
@@ -1046,6 +1092,42 @@ mod tests {
assert!(body.get("initial_text").is_none()); assert!(body.get("initial_text").is_none());
} }
#[tokio::test]
async fn worker_and_sub_worker_providers_are_ambiguous_before_install() {
let runtime_base = tempfile::tempdir().unwrap();
let runtime_dir = Arc::new(
crate::runtime::dir::RuntimeDir::create(runtime_base.path(), "feature-plan")
.await
.unwrap(),
);
let registry = SpawnedWorkerRegistry::new(runtime_dir);
let client: Arc<dyn WorkspaceClient> = Arc::new(RecordingWorkspaceClient::default());
let mut builder = crate::feature::FeatureRegistryBuilder::new();
builder.add_module(sub_worker_control_feature(client.clone(), registry.clone()));
builder.add_module(manage_worker_feature(client, Some(registry), true));
assert_eq!(
builder.plan(),
Err(crate::feature::FeaturePlanError::AmbiguousServiceProvider {
service: ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
providers: vec![
crate::feature::FeatureId::builtin("sub-worker-control"),
crate::feature::FeatureId::builtin("worker"),
],
})
);
let mut hooks = crate::HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = builder.install_into_pending(&mut pending_tools, &mut hooks);
assert!(matches!(
report.plan_error,
Some(crate::feature::FeaturePlanError::AmbiguousServiceProvider { .. })
));
assert!(pending_tools.is_empty());
assert!(report.services.providers().is_empty());
}
#[test] #[test]
fn worker_service_can_remain_enabled_without_direct_spawn_surface() { fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
let client = Arc::new(RecordingWorkspaceClient::default()); let client = Arc::new(RecordingWorkspaceClient::default());
@@ -6,7 +6,9 @@ use memory::backend::{
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation, MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
}; };
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence}; use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence};
use memory::schema::{EvidenceKind, SourceEvidenceRef, SourceRef}; use memory::schema::{
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef,
};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
@@ -174,17 +176,29 @@ impl Tool for StageMemoryCandidateTool {
"StageMemoryCandidate requires at least one entry_ref".to_string(), "StageMemoryCandidate requires at least one entry_ref".to_string(),
)); ));
} }
let mut evidence = Vec::with_capacity(params.entry_refs.len()); let mut entries = Vec::with_capacity(params.entry_refs.len());
let mut source_refs = Vec::with_capacity(params.entry_refs.len());
for entry_ref in &params.entry_refs { for entry_ref in &params.entry_refs {
let projection = self.state.view.evidence_for(entry_ref).ok_or_else(|| { entries.push(self.state.view.evidence_for(entry_ref).ok_or_else(|| {
ToolError::InvalidArgument(format!( ToolError::InvalidArgument(format!(
"unknown SessionEntryRef {entry_ref:?} for this extraction capture" "unknown SessionEntryRef {entry_ref:?} for this extraction capture"
)) ))
})?; })?);
evidence.push(staging_evidence(&projection));
source_refs.push(source_evidence_ref(&projection));
} }
if matches!(params.kind, CandidateKind::Preference)
&& entries.iter().any(|entry| {
!matches!(
entry.origin,
crate::WorkerHistoryProvenance::HumanInput { .. }
)
})
{
return Err(ToolError::InvalidArgument(
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
.to_string(),
));
}
let evidence = entries.iter().map(staging_evidence).collect();
let source_refs = entries.iter().map(source_evidence_ref).collect();
let candidate = ExtractedCandidate { let candidate = ExtractedCandidate {
kind: params.kind, kind: params.kind,
claim: params.claim, claim: params.claim,
@@ -310,11 +324,65 @@ fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
} }
} }
fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
use crate::WorkerHistoryProvenance as Origin;
let mut evidence = EvidenceOrigin {
kind: EvidenceOriginKind::LegacyUnknown,
account_id: None,
workspace_id: None,
runtime_id: None,
worker_id: None,
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
};
match origin {
Origin::HumanInput { account_id } => {
evidence.kind = EvidenceOriginKind::HumanInput;
evidence.account_id = Some(account_id.clone());
}
Origin::WorkerInput { actor } => {
evidence.kind = EvidenceOriginKind::WorkerInput;
evidence.workspace_id = actor.workspace_id.clone();
evidence.runtime_id = actor.runtime_id.clone();
evidence.worker_id = Some(actor.worker_id.clone());
}
Origin::FlowInstruction {
selector,
definition_id,
definition_revision,
..
} => {
evidence.kind = EvidenceOriginKind::FlowInstruction;
evidence.flow_selector = Some(selector.clone());
evidence.flow_definition_id = Some(definition_id.clone());
evidence.flow_definition_revision = Some(*definition_revision);
}
Origin::BackendInstruction { .. } => evidence.kind = EvidenceOriginKind::BackendInstruction,
Origin::ModelOutput { worker } => {
evidence.kind = EvidenceOriginKind::ModelOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::ToolOutput { worker } => {
evidence.kind = EvidenceOriginKind::ToolOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::DerivedSummary => evidence.kind = EvidenceOriginKind::DerivedSummary,
Origin::LegacyUnknown => evidence.kind = EvidenceOriginKind::LegacyUnknown,
}
evidence
}
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence { fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
StagingEvidence { StagingEvidence {
id: entry.entry_ref.to_string(), id: entry.entry_ref.to_string(),
kind: evidence_kind(entry), kind: evidence_kind(entry),
entry_range: Some(entry.entry_range), entry_range: Some(entry.entry_range),
origin: Some(evidence_origin(&entry.origin)),
excerpt: Some(entry.excerpt.clone()), excerpt: Some(entry.excerpt.clone()),
summary: Some(entry.summary.clone()), summary: Some(entry.summary.clone()),
} }
@@ -325,6 +393,7 @@ fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
segment_id: Some(entry.segment_id.clone()), segment_id: Some(entry.segment_id.clone()),
entry_range: Some(entry.entry_range), entry_range: Some(entry.entry_range),
evidence_id: Some(entry.entry_ref.to_string()), evidence_id: Some(entry.entry_ref.to_string()),
origin: Some(evidence_origin(&entry.origin)),
evidence_kind: Some(evidence_kind(entry)), evidence_kind: Some(evidence_kind(entry)),
label: Some(entry.label.clone()), label: Some(entry.label.clone()),
summary: Some(entry.summary.clone()), summary: Some(entry.summary.clone()),
@@ -432,6 +501,15 @@ mod tests {
assert!(input.contains("StageMemoryCandidate.entry_refs")); assert!(input.contains("StageMemoryCandidate.entry_refs"));
} }
#[test]
fn human_origin_projects_account_authority_into_evidence() {
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput {
account_id: "account-1".into(),
});
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
}
#[test] #[test]
fn backend_input_failures_remain_invalid_argument_tool_errors() { fn backend_input_failures_remain_invalid_argument_tool_errors() {
let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend( let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend(
@@ -445,6 +523,19 @@ mod tests {
assert!(matches!(http, ToolError::InvalidArgument(_))); assert!(matches!(http, ToolError::InvalidArgument(_)));
} }
#[tokio::test]
async fn preference_rejects_legacy_unknown_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() };
let error = tool
.execute(
r#"{"kind":"preference","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
}
#[tokio::test] #[tokio::test]
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() { async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() }; let tool = StageMemoryCandidateTool { state: state() };
@@ -50,11 +50,11 @@ struct MergeRequestTool {
kind: Kind, kind: Kind,
} }
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct ShowInput { struct TicketInput {
ticket: String, ticket: String,
} }
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct OpenInput { struct OpenMergeRequestInput {
ticket: String, ticket: String,
repository_id: String, repository_id: String,
selector_from: String, selector_from: String,
@@ -63,7 +63,7 @@ struct OpenInput {
summary: String, summary: String,
} }
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct CompleteInput { struct CompleteMergeRequestInput {
ticket: String, ticket: String,
operation_id: String, operation_id: String,
approval_event_id: String, approval_event_id: String,
@@ -86,7 +86,7 @@ enum MergeResolutionInput {
ConflictsResolved, ConflictsResolved,
} }
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct ReviewInput { struct ReviewMergeRequestInput {
decision: ReviewDecisionInput, decision: ReviewDecisionInput,
#[serde(default)] #[serde(default)]
body: String, body: String,
@@ -133,19 +133,19 @@ impl Kind {
fn name(self) -> &'static str { fn name(self) -> &'static str {
match self { match self {
Self::Show => "MergeRequestShow", Self::Show => "ShowMergeRequest",
Self::Readiness => "MergeRequestReadinessCheck", Self::Readiness => "CheckMergeRequestReadiness",
Self::Open => "MergeRequestOpen", Self::Open => "OpenMergeRequest",
Self::Complete => "MergeRequestComplete", Self::Complete => "CompleteMergeRequest",
Self::Review => "MergeRequestReview", Self::Review => "ReviewMergeRequest",
} }
} }
fn schema(self) -> serde_json::Value { fn schema(self) -> serde_json::Value {
match self { match self {
Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)), Self::Show | Self::Readiness => json!(schemars::schema_for!(TicketInput)),
Self::Open => json!(schemars::schema_for!(OpenInput)), Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
Self::Complete => json!(schemars::schema_for!(CompleteInput)), Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
Self::Review => json!(schemars::schema_for!(ReviewInput)), Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
} }
} }
} }
@@ -156,13 +156,13 @@ impl Tool for MergeRequestTool {
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into()) ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
})?; })?;
if matches!(self.kind, Kind::Show) { if matches!(self.kind, Kind::Show) {
let value: ShowInput = parse(input)?; let value: TicketInput = parse(input)?;
nonempty(&value.ticket)?; nonempty(&value.ticket)?;
return self.show_current_merge_request(ws, &value.ticket); return self.show_current_merge_request(ws, &value.ticket);
} }
let (method, path, body) = match self.kind { let (method, path, body) = match self.kind {
Kind::Readiness => { Kind::Readiness => {
let v: ShowInput = parse(input)?; let v: TicketInput = parse(input)?;
nonempty(&v.ticket)?; nonempty(&v.ticket)?;
( (
WorkspaceRequestMethod::Get, WorkspaceRequestMethod::Get,
@@ -170,9 +170,9 @@ impl Tool for MergeRequestTool {
None, None,
) )
} }
Kind::Show => unreachable!("MergeRequestShow is handled above"), Kind::Show => unreachable!("ShowMergeRequest is handled above"),
Kind::Open => { Kind::Open => {
let v: OpenInput = parse(input)?; let v: OpenMergeRequestInput = parse(input)?;
nonempty(&v.ticket)?; nonempty(&v.ticket)?;
( (
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
@@ -183,7 +183,7 @@ impl Tool for MergeRequestTool {
) )
} }
Kind::Complete => { Kind::Complete => {
let v: CompleteInput = parse(input)?; let v: CompleteMergeRequestInput = parse(input)?;
nonempty(&v.ticket)?; nonempty(&v.ticket)?;
( (
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
@@ -194,7 +194,7 @@ impl Tool for MergeRequestTool {
) )
} }
Kind::Review => { Kind::Review => {
let v: ReviewInput = parse(input)?; let v: ReviewMergeRequestInput = parse(input)?;
let ctx = self.client.reviewer_context().ok_or_else(|| { let ctx = self.client.reviewer_context().ok_or_else(|| {
ToolError::ExecutionFailed( ToolError::ExecutionFailed(
"Review submit requires injected Reviewer capability".into(), "Review submit requires injected Reviewer capability".into(),
@@ -397,19 +397,21 @@ impl FeatureModule for MergeRequestFeature {
pub fn description(n: &str) -> Option<&'static str> { pub fn description(n: &str) -> Option<&'static str> {
match n { match n {
"MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."), "ShowMergeRequest" => Some(
"MergeRequestReadinessCheck" => { "Read the selector-based Merge Request, append-only thread, source-review freshness, and target-integration evidence before review, fix, or handoff decisions.",
Some("Resolve current provider refs and derive readiness from valid review events.") ),
} "CheckMergeRequestReadiness" => Some(
"MergeRequestOpen" => { "Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.",
Some("Open a Merge Request with immutable source and target selectors.") ),
} "OpenMergeRequest" => Some(
"MergeRequestComplete" => { "Open the Ticket's one Merge Request with immutable source and target selectors; reuse it and advance only selector_from with a normal non-force push for later fixes.",
Some("Complete using an approved review event and final target-ref evidence.") ),
} "CompleteMergeRequest" => Some(
"MergeRequestReview" => { "Record Orchestrator-owned integration using unchanged-source approval and refreshed final target-ref evidence.",
Some("Submit the injected Reviewer capability result for its captured subject ref.") ),
} "ReviewMergeRequest" => Some(
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
),
_ => None, _ => None,
} }
} }
@@ -469,6 +471,31 @@ mod tests {
} }
} }
#[test]
fn model_facing_operations_use_only_verb_first_names() {
for name in [
"ShowMergeRequest",
"CheckMergeRequestReadiness",
"OpenMergeRequest",
"CompleteMergeRequest",
"ReviewMergeRequest",
] {
assert!(description(name).is_some(), "missing operation {name}");
}
for legacy in [
"MergeRequestShow",
"MergeRequestReadinessCheck",
"MergeRequestOpen",
"MergeRequestComplete",
"MergeRequestReview",
] {
assert!(
description(legacy).is_none(),
"legacy alias {legacy} must not remain registered"
);
}
}
#[tokio::test] #[tokio::test]
async fn show_resolves_ticket_projection_then_reads_canonical_resource() { async fn show_resolves_ticket_projection_then_reads_canonical_resource() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![ let client = Arc::new(RecordingWorkspaceClient::new(vec![
@@ -546,7 +573,7 @@ mod tests {
open: true, open: true,
..Default::default() ..Default::default()
}; };
assert_eq!(tool_names(coder), ["MergeRequestShow", "MergeRequestOpen"]); assert_eq!(tool_names(coder), ["ShowMergeRequest", "OpenMergeRequest"]);
let reviewer = MergeRequestFeatureConfig { let reviewer = MergeRequestFeatureConfig {
show: true, show: true,
@@ -555,7 +582,7 @@ mod tests {
}; };
assert_eq!( assert_eq!(
tool_names(reviewer), tool_names(reviewer),
["MergeRequestShow", "MergeRequestReview"] ["ShowMergeRequest", "ReviewMergeRequest"]
); );
let orchestrator = MergeRequestFeatureConfig { let orchestrator = MergeRequestFeatureConfig {
@@ -567,9 +594,9 @@ mod tests {
assert_eq!( assert_eq!(
tool_names(orchestrator), tool_names(orchestrator),
[ [
"MergeRequestShow", "ShowMergeRequest",
"MergeRequestReadinessCheck", "CheckMergeRequestReadiness",
"MergeRequestComplete" "CompleteMergeRequest"
] ]
); );
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]); assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
@@ -581,8 +608,8 @@ mod tests {
#[test] #[test]
fn schemas_hide_revision_and_commit_authority() { fn schemas_hide_revision_and_commit_authority() {
let schemas = [ let schemas = [
schemars::schema_for!(OpenInput), schemars::schema_for!(OpenMergeRequestInput),
schemars::schema_for!(CompleteInput), schemars::schema_for!(CompleteMergeRequestInput),
]; ];
for s in schemas { for s in schemas {
let j = serde_json::to_string(&s).unwrap(); let j = serde_json::to_string(&s).unwrap();
@@ -89,18 +89,19 @@ impl Tool for SpawnTicketCoderTool {
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| { let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}")) ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
})?; })?;
let ticket_id = authority_id(input.ticket_id, "ticket_id")?; let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
let workflow_state = self let ticket = self
.ticket_service .ticket_service
.workflow_state(&ticket_id) .ticket_handoff(&ticket_ref)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !matches!( if !matches!(
workflow_state, ticket.workflow_state,
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
) { ) {
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"Ticket {ticket_id} must be queued or inprogress before spawning its Coder; current state is {}", "Ticket {} must be queued or inprogress before spawning its Coder; current state is {}",
workflow_state.as_str() ticket.resource_key,
ticket.workflow_state.as_str()
))); )));
} }
let call_id = non_empty(ctx.call_id, "tool call_id")?; let call_id = non_empty(ctx.call_id, "tool call_id")?;
@@ -115,14 +116,14 @@ impl Tool for SpawnTicketCoderTool {
)?, )?,
relative_cwd, relative_cwd,
profile: CODER_PROFILE.to_string(), profile: CODER_PROFILE.to_string(),
ticket_id: Some(ticket_id.clone()), ticket_id: Some(ticket.id.clone()),
operation_id: Some(format!("spawn-ticket-coder:{ticket_id}:{call_id}")), operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
display_name: format!("Coder · {ticket_id}"), display_name: format!("Coder · {}", ticket.resource_key),
initial_submit: vec![ initial_submit: vec![
Segment::Flow { Segment::Flow {
selector: CODER_FLOW.to_string(), selector: CODER_FLOW.to_string(),
}, },
Segment::text(format!("Implement Ticket {ticket_id}.")), Segment::text(format!("Implement Ticket {}.", ticket.resource_key)),
], ],
}) })
.await .await
@@ -134,7 +135,7 @@ impl Tool for SpawnTicketCoderTool {
))); )));
} }
Ok(ToolOutput { Ok(ToolOutput {
summary: format!("Spawned Coder for Ticket {ticket_id}"), summary: format!("Spawned Coder for Ticket {}", ticket.resource_key),
content: Some(response.body), content: Some(response.body),
attachments: Vec::new(), attachments: Vec::new(),
}) })
@@ -201,21 +202,31 @@ mod tests {
use crate::worker::{WorkspaceClientError, WorkspaceResponse}; use crate::worker::{WorkspaceClientError, WorkspaceResponse};
use super::*; use super::*;
use crate::feature::builtin::ticket::TicketHandoff;
#[derive(Default)] #[derive(Default)]
struct RecordingTicketService; struct RecordingTicketService;
impl TicketService for RecordingTicketService { impl TicketService for RecordingTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> { fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
Ok(TicketWorkflowState::Queued) assert_eq!(ticket_ref, "T-482");
Ok(TicketHandoff {
id: "00001KZXN51C7".to_string(),
resource_key: "T-482".to_string(),
workflow_state: TicketWorkflowState::Queued,
})
} }
} }
struct FixedTicketService(TicketWorkflowState); struct FixedTicketService(TicketWorkflowState);
impl TicketService for FixedTicketService { impl TicketService for FixedTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> { fn ticket_handoff(&self, _ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
Ok(self.0) Ok(TicketHandoff {
id: "00001KZXN51C7".to_string(),
resource_key: "T-482".to_string(),
workflow_state: self.0,
})
} }
} }
@@ -247,7 +258,7 @@ mod tests {
}; };
tool.execute( tool.execute(
&serde_json::json!({ &serde_json::json!({
"ticket_id": "00001KZXN51C7", "ticket_id": "T-482",
"runtime_id": "runtime-1", "runtime_id": "runtime-1",
"working_directory_id": "workdir-1" "working_directory_id": "workdir-1"
}) })
@@ -265,16 +276,20 @@ mod tests {
request.operation_id.as_deref(), request.operation_id.as_deref(),
Some("spawn-ticket-coder:00001KZXN51C7:call-7") Some("spawn-ticket-coder:00001KZXN51C7:call-7")
); );
assert_eq!(request.display_name, "Coder · 00001KZXN51C7"); assert_eq!(request.display_name, "Coder · T-482");
assert_eq!( assert_eq!(
request.initial_submit, request.initial_submit,
vec![ vec![
Segment::Flow { Segment::Flow {
selector: CODER_FLOW.to_string() selector: CODER_FLOW.to_string()
}, },
Segment::text("Implement Ticket 00001KZXN51C7.") Segment::text("Implement Ticket T-482.")
] ]
); );
assert!(!request.display_name.contains("00001KZXN51C7"));
assert!(request.initial_submit.iter().all(|segment| {
!Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7")
}));
} }
#[tokio::test] #[tokio::test]
@@ -193,6 +193,7 @@ impl Tool for ShowOverviewTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"label": entry.label, "label": entry.label,
@@ -234,15 +235,16 @@ impl Tool for SearchEntriesTool {
.transpose()?; .transpose()?;
let from = params.from.as_deref().map(parse_entry_ref).transpose()?; let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?; let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
let view = self.state.view();
if let (Some(from), Some(through)) = (&from, &through) { if let (Some(from), Some(through)) = (&from, &through) {
if from.source_index() > through.source_index() { if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument( return Err(ToolError::InvalidArgument(
"SearchEntries from must not be after through".to_string(), "SearchEntries from must not be after through".to_string(),
)); ));
} }
} }
let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
let hits = self.state.view().search(&SearchOptions { let hits = view.search(&SearchOptions {
query: params.query, query: params.query,
kind, kind,
tool_part, tool_part,
@@ -318,6 +320,7 @@ impl Tool for ReadEntryTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()), "tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
+47 -15
View File
@@ -267,7 +267,20 @@ pub const TICKET_SERVICE_ID: &str = "ticket.authority";
const TICKET_SERVICE_VERSION: &str = "1"; const TICKET_SERVICE_VERSION: &str = "1";
pub trait TicketService: Send + Sync { pub trait TicketService: Send + Sync {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError>; fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketHandoff {
pub id: String,
pub resource_key: String,
pub workflow_state: TicketWorkflowState,
}
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
resource_key.strip_prefix("T-").is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
})
} }
struct BackendTicketService { struct BackendTicketService {
@@ -275,10 +288,18 @@ struct BackendTicketService {
} }
impl TicketService for BackendTicketService { impl TicketService for BackendTicketService {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError> { fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
self.backend let ticket = self.backend.show(ticket_ref.into())?;
.show(ticket_id.into()) let resource_key = ticket
.map(|ticket| ticket.meta.workflow_state) .meta
.resource_key
.filter(|key| is_canonical_ticket_resource_key(key))
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
Ok(TicketHandoff {
id: ticket.meta.id,
resource_key,
workflow_state: ticket.meta.workflow_state,
})
} }
} }
@@ -873,12 +894,13 @@ impl WorkspaceHttpTicketBackend {
})?), })?),
) )
.map(TicketBackendOperationResult::Ticket), .map(TicketBackendOperationResult::Ticket),
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit( TicketBackendOperation::QueueReady { id, .. } => Self::request(
client, client,
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)), format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
None, None,
), )
.map(TicketBackendOperationResult::QueueOutcome),
TicketBackendOperation::Close { id, resolution } => Self::request_unit( TicketBackendOperation::Close { id, resolution } => Self::request_unit(
client, client,
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
@@ -1099,16 +1121,18 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
) )
} }
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> { fn queue_ready(
match self.invoke(TicketBackendOperation::QueueReady { &self,
id: TicketIdOrSlug,
queued_by: &str,
) -> TicketResult<ticket::TicketQueueOutcome> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::QueueReady {
id, id,
queued_by: queued_by.to_string(), queued_by: queued_by.to_string(),
})? { }),
TicketBackendOperationResult::Unit => Ok(()), TicketBackendOperationResult::QueueOutcome
other => Err(TicketError::Conflict(format!( )
"unexpected ticket backend response: {other:?}"
))),
}
} }
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> { fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
@@ -1767,6 +1791,14 @@ provider = "github"
assert_eq!(removed.target, "01TARGET"); assert_eq!(removed.target, "01TARGET");
} }
#[test]
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
assert!(is_canonical_ticket_resource_key("T-482"));
for invalid in ["", "00001KZVNXFNK", "T-", "T-key", "O-482"] {
assert!(!is_canonical_ticket_resource_key(invalid));
}
}
#[test] #[test]
fn workspace_http_backend_executes_ticket_create_operation() { fn workspace_http_backend_executes_ticket_create_operation() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -1,11 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
#[cfg(test)]
use agen::Item; use agen::Item;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait; use async_trait::async_trait;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::collect_state; use session_store::{LogEntry, collect_state};
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService}; use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
use crate::feature::{ use crate::feature::{
@@ -60,7 +61,27 @@ pub struct WorkerObservationSubject {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct WorkerSessionCapture { pub struct WorkerSessionCapture {
pub segment_id: String, pub segment_id: String,
pub items: Vec<Item>, pub entries: Vec<agen::HistoryEntry<crate::SessionHistoryMetadata>>,
}
impl WorkerSessionCapture {
pub fn from_log_entries(
segment_id: impl Into<String>,
log_entries: &[LogEntry],
) -> Result<Self, String> {
let segment_id = segment_id.into();
let state = collect_state(log_entries);
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
log_entries,
)?;
Ok(Self {
segment_id,
entries,
})
}
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -161,9 +182,17 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
}) })
.collect::<Result<Vec<session_store::LogEntry>, _>>()?; .collect::<Result<Vec<session_store::LogEntry>, _>>()?;
let state = collect_state(&entries); let state = collect_state(&entries);
let segment_id = response.segment_id;
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture { Ok(WorkerSessionCapture {
segment_id: response.segment_id, segment_id,
items: state.history, entries: typed_entries,
}) })
} }
} }
@@ -392,9 +421,15 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
.ok_or(WorkerObservationError::NotFound)?; .ok_or(WorkerObservationError::NotFound)?;
let entries = record.session.entries(); let entries = record.session.entries();
let state = collect_state(&entries); let state = collect_state(&entries);
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
Default::default(),
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture { Ok(WorkerSessionCapture {
segment_id: format!("subworker:{name}"), segment_id: format!("subworker:{name}"),
items: state.history, entries: typed_entries,
}) })
} }
} }
@@ -508,6 +543,7 @@ impl Tool for ViewSessionOverviewTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"label": entry.label, "label": entry.label,
@@ -547,7 +583,7 @@ impl Tool for SearchSessionEntriesTool {
let from = params.from.as_deref().map(parse_entry_ref).transpose()?; let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?; let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
if let (Some(from), Some(through)) = (&from, &through) { if let (Some(from), Some(through)) = (&from, &through) {
if from.source_index() > through.source_index() { if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument( return Err(ToolError::InvalidArgument(
"SearchSessionEntries from must not be after through".to_string(), "SearchSessionEntries from must not be after through".to_string(),
)); ));
@@ -573,6 +609,7 @@ impl Tool for SearchSessionEntriesTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()), "tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
@@ -628,6 +665,7 @@ impl Tool for ReadSessionEntryTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()), "tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
@@ -661,7 +699,10 @@ async fn latest_view(
.capture_worker_session(subject) .capture_worker_session(subject)
.await .await
.map_err(tool_error)?; .map_err(tool_error)?;
Ok(SessionCapture::new(capture.segment_id, capture.items)) Ok(SessionCapture::from_history_entries(
capture.segment_id,
capture.entries,
))
} }
fn parse_input<T: serde::de::DeserializeOwned>( fn parse_input<T: serde::de::DeserializeOwned>(
@@ -751,9 +792,23 @@ mod tests {
if subject != &granted_subject() { if subject != &granted_subject() {
return Err(WorkerObservationError::NotFound); return Err(WorkerObservationError::NotFound);
} }
let entries = self
.captures
.lock()
.unwrap()
.clone()
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = crate::SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("fake-{index:08}"));
agen::HistoryEntry::new(item, metadata)
})
.collect();
Ok(WorkerSessionCapture { Ok(WorkerSessionCapture {
segment_id: "segment".to_string(), segment_id: "segment".to_string(),
items: self.captures.lock().unwrap().clone(), entries,
}) })
} }
} }
@@ -796,7 +851,7 @@ mod tests {
let read = read_definition(provider.clone())().1; let read = read_definition(provider.clone())().1;
let hidden = read let hidden = read
.execute( .execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"E00000000"}"#, r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"Efake-00000000"}"#,
agen::tool::ToolExecutionContext::direct(), agen::tool::ToolExecutionContext::direct(),
) )
.await .await
@@ -810,7 +865,7 @@ mod tests {
.push(message("a1", Role::Assistant, "second")); .push(message("a1", Role::Assistant, "second"));
let output = read let output = read
.execute( .execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000000"}"#, r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000000"}"#,
agen::tool::ToolExecutionContext::direct(), agen::tool::ToolExecutionContext::direct(),
) )
.await .await
@@ -819,7 +874,7 @@ mod tests {
let output = read let output = read
.execute( .execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000001"}"#, r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000001"}"#,
agen::tool::ToolExecutionContext::direct(), agen::tool::ToolExecutionContext::direct(),
) )
.await .await
+135 -4
View File
@@ -43,6 +43,8 @@ pub(crate) struct InternalWorkerAuthority {
pub workspace: WorkerWorkspaceContext, pub workspace: WorkerWorkspaceContext,
pub filesystem: WorkerFilesystemAuthority, pub filesystem: WorkerFilesystemAuthority,
pub scope: Scope, pub scope: Scope,
/// Provider-bound session inherited in an attenuated form from the owner.
pub workdir_session: Option<workdir::WorkdirSessionHandle>,
} }
pub(crate) struct InternalWorkerSpec { pub(crate) struct InternalWorkerSpec {
@@ -53,6 +55,17 @@ pub(crate) struct InternalWorkerSpec {
pub input: String, pub input: String,
pub cache_key: Option<String>, pub cache_key: Option<String>,
pub max_turns: Option<u32>, pub max_turns: Option<u32>,
pub engine_configurator: Option<
Box<
dyn FnOnce(
&mut Engine<
Box<dyn LlmClient>,
agen::state::Mutable,
crate::SessionHistoryMetadata,
>,
) + Send,
>,
>,
pub features: FeatureRegistryBuilder, pub features: FeatureRegistryBuilder,
pub required_tools: &'static [&'static str], pub required_tools: &'static [&'static str],
pub authority: InternalWorkerAuthority, pub authority: InternalWorkerAuthority,
@@ -102,6 +115,7 @@ where
input, input,
cache_key, cache_key,
max_turns, max_turns,
engine_configurator,
features, features,
required_tools, required_tools,
authority, authority,
@@ -120,7 +134,9 @@ where
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>)); let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone(); let usage_slot = last_usage.clone();
let mut engine = Engine::new(client).system_prompt(system_prompt); let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.on_usage(move |usage| { engine.on_usage(move |usage| {
if let Ok(mut slot) = usage_slot.lock() { if let Ok(mut slot) = usage_slot.lock() {
*slot = Some(usage.clone()); *slot = Some(usage.clone());
@@ -128,7 +144,11 @@ where
}); });
engine.set_cache_key(cache_key); engine.set_cache_key(cache_key);
engine.set_max_turns(max_turns); engine.set_max_turns(max_turns);
if let Some(configure) = engine_configurator {
configure(&mut engine);
}
let store = EphemeralSessionStore::default(); let store = EphemeralSessionStore::default();
let inherited_workdir_session = authority.workdir_session.clone();
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
engine, engine,
@@ -144,6 +164,9 @@ where
identity: identity.clone(), identity: identity.clone(),
history_entries: 0, history_entries: 0,
})?; })?;
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
let install_report = worker.install_features(features); let install_report = worker.install_features(features);
let installed_tools = install_report.installed_tool_names(); let installed_tools = install_report.installed_tool_names();
@@ -250,7 +273,6 @@ impl InternalWorkerSessionStatus {
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub(crate) enum InternalWorkerSessionError { pub(crate) enum InternalWorkerSessionError {
#[cfg(test)]
#[error("failed to build internal Worker session: {message}")] #[error("failed to build internal Worker session: {message}")]
Build { message: String }, Build { message: String },
#[error("internal Worker session is busy")] #[error("internal Worker session is busy")]
@@ -410,7 +432,6 @@ impl InternalWorkerSessionHandle {
Ok(()) Ok(())
} }
#[cfg(test)]
pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus { pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus {
loop { loop {
let notified = self.state_changed.notified(); let notified = self.state_changed.notified();
@@ -475,6 +496,7 @@ pub(crate) async fn spawn_internal_worker_session(
input, input,
cache_key, cache_key,
max_turns, max_turns,
engine_configurator,
features, features,
required_tools, required_tools,
authority, authority,
@@ -484,7 +506,9 @@ pub(crate) async fn spawn_internal_worker_session(
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>)); let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone(); let usage_slot = last_usage.clone();
let mut engine = Engine::new(client).system_prompt(system_prompt); let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.on_usage(move |usage| { engine.on_usage(move |usage| {
if let Ok(mut slot) = usage_slot.lock() { if let Ok(mut slot) = usage_slot.lock() {
*slot = Some(usage.clone()); *slot = Some(usage.clone());
@@ -492,7 +516,11 @@ pub(crate) async fn spawn_internal_worker_session(
}); });
engine.set_cache_key(cache_key); engine.set_cache_key(cache_key);
engine.set_max_turns(max_turns); engine.set_max_turns(max_turns);
if let Some(configure) = engine_configurator {
configure(&mut engine);
}
let store = EphemeralSessionStore::default(); let store = EphemeralSessionStore::default();
let inherited_workdir_session = authority.workdir_session.clone();
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
engine, engine,
@@ -505,6 +533,9 @@ pub(crate) async fn spawn_internal_worker_session(
.map_err(|source| InternalWorkerSessionError::Build { .map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(), message: source.to_string(),
})?; })?;
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
let install_report = worker.install_features(features); let install_report = worker.install_features(features);
let installed_tools = install_report.installed_tool_names(); let installed_tools = install_report.installed_tool_names();
let install_failed = install_report let install_failed = install_report
@@ -539,6 +570,104 @@ pub(crate) async fn spawn_internal_worker_session(
spawn_prepared_internal_worker_session(worker, store, input, None).await spawn_prepared_internal_worker_session(worker, store, input, None).await
} }
/// Prepare an observable Internal Worker from the same bounded spec as one-shot
/// helpers, but do not start its first turn. The owner must register the returned
/// handle before calling `send`, preserving the snapshot/live boundary.
pub(crate) fn prepare_internal_worker_from_spec(
spec: InternalWorkerSpec,
visibility: InternalWorkerVisibility,
) -> std::pin::Pin<
Box<
dyn std::future::Future<
Output = Result<InternalWorkerSessionHandle, InternalWorkerSessionError>,
> + Send,
>,
> {
Box::pin(async move {
let InternalWorkerSpec {
identity,
mut manifest,
client,
system_prompt,
input: _,
cache_key,
max_turns,
engine_configurator,
features,
required_tools,
authority,
} = spec;
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
manifest.feature = Default::default();
manifest.plugins = Default::default();
manifest.mcp = Default::default();
manifest.skills = None;
manifest.compaction = None;
manifest.memory = None;
let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.set_cache_key(cache_key);
engine.set_max_turns(max_turns);
if let Some(configure) = engine_configurator {
configure(&mut engine);
}
let store = EphemeralSessionStore::default();
let inherited_workdir_session = authority.workdir_session.clone();
let mut worker = Worker::new(
manifest,
engine,
store.clone(),
authority.workspace,
authority.filesystem,
authority.scope,
)
.await
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
let install_report = worker.install_features(features);
let installed_tools = install_report.installed_tool_names();
let install_failed = install_report
.reports
.iter()
.any(|report| !report.installed);
let missing = required_tools
.iter()
.filter(|required| {
!installed_tools
.iter()
.any(|installed| installed == **required)
})
.copied()
.collect::<Vec<_>>();
if install_failed || !missing.is_empty() {
let diagnostics = install_report
.reports
.iter()
.flat_map(|report| report.diagnostics.iter())
.map(|diagnostic| diagnostic.message.as_str())
.collect::<Vec<_>>()
.join("; ");
return Err(InternalWorkerSessionError::Build {
message: format!(
"internal Worker feature installation failed: {diagnostics}; missing tools: {}",
missing.join(", ")
),
});
}
Box::pin(prepare_internal_worker_session(
worker, store, visibility, None, None,
))
.await
})
}
fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Sender<Event>) { fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Sender<Event>) {
let (_, mut log_rx) = sink.subscribe_with_snapshot(); let (_, mut log_rx) = sink.subscribe_with_snapshot();
tokio::spawn(async move { tokio::spawn(async move {
@@ -1076,12 +1205,14 @@ permission = "write"
input: "input".to_string(), input: "input".to_string(),
cache_key: Some("internal-test".to_string()), cache_key: Some("internal-test".to_string()),
max_turns: Some(1), max_turns: Some(1),
engine_configurator: None,
features: FeatureRegistryBuilder::new(), features: FeatureRegistryBuilder::new(),
required_tools, required_tools,
authority: InternalWorkerAuthority { authority: InternalWorkerAuthority {
workspace: WorkerWorkspaceContext::no_workspace(), workspace: WorkerWorkspaceContext::no_workspace(),
filesystem: WorkerFilesystemAuthority::None, filesystem: WorkerFilesystemAuthority::None,
scope: Scope::empty(), scope: Scope::empty(),
workdir_session: None,
}, },
} }
} }
+39 -2
View File
@@ -8,6 +8,7 @@
//! decisions (continue / skip / abort / pause). //! decisions (continue / skip / abort / pause).
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -33,7 +34,9 @@ use crate::hook::{
}; };
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance}; use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
use crate::prompt::catalog::PromptCatalog; use crate::prompt::catalog::PromptCatalog;
use crate::session_history::SessionHistoryMetadata;
use crate::worker::SystemItemCommitter; use crate::worker::SystemItemCommitter;
use agen::HistoryEntry;
use agen::token_counter::total_tokens; use agen::token_counter::total_tokens;
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`. /// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
@@ -73,6 +76,7 @@ pub(crate) struct WorkerInterceptor {
/// worker. `None` in tests / `Worker::new` paths where no writer is /// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached. /// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>, log_writer: Option<Arc<dyn SystemItemCommitter>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
/// Next turn index assigned by `on_prompt_submit`. /// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize, next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt). /// Tool calls observed in the current turn (reset on each new prompt).
@@ -80,6 +84,7 @@ pub(crate) struct WorkerInterceptor {
} }
impl WorkerInterceptor { impl WorkerInterceptor {
#[cfg(test)]
pub(crate) fn new( pub(crate) fn new(
registry: Arc<HookRegistry>, registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>, compact_state: Option<Arc<CompactState>>,
@@ -88,6 +93,28 @@ impl WorkerInterceptor {
pending_attachments: Arc<Mutex<Vec<SystemItem>>>, pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<ArcSwap<PromptCatalog>>, prompts: Arc<ArcSwap<PromptCatalog>>,
log_writer: Option<Arc<dyn SystemItemCommitter>>, log_writer: Option<Arc<dyn SystemItemCommitter>>,
) -> Self {
Self::new_with_history_queue(
registry,
compact_state,
usage_history,
pending_notifies,
pending_attachments,
prompts,
log_writer,
Arc::new(Mutex::new(VecDeque::new())),
)
}
pub(crate) fn new_with_history_queue(
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
pending_notifies: NotifyBuffer,
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<ArcSwap<PromptCatalog>>,
log_writer: Option<Arc<dyn SystemItemCommitter>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
) -> Self { ) -> Self {
Self { Self {
registry, registry,
@@ -99,6 +126,7 @@ impl WorkerInterceptor {
prompts, prompts,
prompt_workspace_id: None, prompt_workspace_id: None,
log_writer, log_writer,
pending_committed_history,
next_turn_index: AtomicUsize::new(0), next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0), tool_calls_this_turn: AtomicUsize::new(0),
} }
@@ -125,7 +153,11 @@ impl WorkerInterceptor {
return Ok(()); return Ok(());
}; };
for item in items { for item in items {
writer.commit_system_item(item.clone())?; let entry = writer.commit_system_item(item.clone())?;
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.push_back(entry);
} }
Ok(()) Ok(())
} }
@@ -507,7 +539,12 @@ mod tests {
&self, &self,
entry: session_store::LogEntry, entry: session_store::LogEntry,
) -> Result<(), session_store::StoreError> { ) -> Result<(), session_store::StoreError> {
if let session_store::LogEntry::SystemItem { item, .. } = entry { let item = match entry {
session_store::LogEntry::SystemItem { item, .. } => Some(item),
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
_ => None,
};
if let Some(item) = item {
self.committed self.committed
.lock() .lock()
.expect("committed system-item list poisoned") .expect("committed system-item list poisoned")
+8 -2
View File
@@ -29,15 +29,21 @@ pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtoco
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> { pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
match entry { match entry {
LogEntry::SegmentStart { .. } => { entry @ (LogEntry::SegmentStart { .. } | LogEntry::AnnotatedSegmentStart { .. }) => {
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize"); let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
Some(Event::SegmentRotated { entry: value }) Some(Event::SegmentRotated { entry: value })
} }
LogEntry::UserInput { segments, .. } => Some(Event::UserMessage { segments }), LogEntry::UserInput { segments, .. } | LogEntry::AnnotatedUserInput { segments, .. } => {
Some(Event::UserMessage { segments })
}
LogEntry::SystemItem { item, .. } => { LogEntry::SystemItem { item, .. } => {
let value = serde_json::to_value(&item).expect("SystemItem is Serialize"); let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value }) Some(Event::SystemItem { item: value })
} }
LogEntry::AnnotatedSystemItem { entry, .. } => {
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
}
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }), LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
other => { other => {
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant // `SegmentLogSink::is_live_relevant` keeps non-live-relevant
+5
View File
@@ -12,6 +12,7 @@ pub mod prompt;
pub mod runtime; pub mod runtime;
pub mod segment_log_sink; pub mod segment_log_sink;
mod session_capture; mod session_capture;
mod session_history;
pub mod shared_state; pub mod shared_state;
mod shutdown_after_idle; mod shutdown_after_idle;
pub mod skill; pub mod skill;
@@ -41,6 +42,10 @@ pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTem
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus}; pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
pub use runtime::dir::RuntimeDir; pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink; pub use segment_log_sink::SegmentLogSink;
pub use session_history::{
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
WorkerHistoryProvenance, WorkerSubjectSnapshot,
};
pub use shared_state::WorkerSharedState; pub use shared_state::WorkerSharedState;
pub use worker::{ pub use worker::{
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError, LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
+1 -1
View File
@@ -34,7 +34,7 @@ impl PermissionHook {
} }
} }
impl<C: LlmClient, St: Store> Worker<C, St> { impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
pub(crate) fn apply_permissions_from_manifest(&mut self) { pub(crate) fn apply_permissions_from_manifest(&mut self) {
let Some(permissions) = self.manifest().permissions.clone() else { let Some(permissions) = self.manifest().permissions.clone() else {
return; return;
+47 -3
View File
@@ -102,7 +102,6 @@ pub enum WorkerPrompt {
AgentsMdSection, AgentsMdSection,
ResidentMemorySummarySection, ResidentMemorySummarySection,
WorkerOrchestrationGuidanceSection, WorkerOrchestrationGuidanceSection,
TicketEventCompanionNotice,
SubWorkerSpawnToolDescription, SubWorkerSpawnToolDescription,
} }
@@ -122,7 +121,6 @@ impl WorkerPrompt {
Self::WorkerOrchestrationGuidanceSection => { Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section" "internal.worker_orchestration_guidance_section"
} }
Self::TicketEventCompanionNotice => "worker.ticket_event_companion_notice",
Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description", Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description",
} }
} }
@@ -139,7 +137,6 @@ impl WorkerPrompt {
WorkerPrompt::AgentsMdSection, WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection, WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::TicketEventCompanionNotice,
WorkerPrompt::SubWorkerSpawnToolDescription, WorkerPrompt::SubWorkerSpawnToolDescription,
]; ];
} }
@@ -593,6 +590,12 @@ mod tests {
fn builtin_dcdl_catalog_loads() { fn builtin_dcdl_catalog_loads() {
let catalog = PromptCatalog::builtins_only().unwrap(); let catalog = PromptCatalog::builtins_only().unwrap();
assert!(!catalog.projection.templates.is_empty()); assert!(!catalog.projection.templates.is_empty());
assert!(
!catalog
.projection
.templates
.contains_key("worker.ticket_event_companion_notice")
);
} }
#[test] #[test]
@@ -609,6 +612,47 @@ mod tests {
assert!(!prompt.contains("use the Ticket repository `origin` transport")); assert!(!prompt.contains("use the Ticket repository `origin` transport"));
} }
#[test]
fn merge_request_prompts_use_selector_refs_and_verb_first_operations() {
let catalog = PromptCatalog::builtins_only().unwrap();
let templates = &catalog.projection.templates;
let common = &templates["common.merge_request"];
let coder = &templates["role.coder"];
let orchestrator = &templates["role.orchestrator"];
let reviewer = &templates["role.reviewer"];
let combined = format!("{common}\n{coder}\n{orchestrator}\n{reviewer}");
for operation in [
"OpenMergeRequest",
"ShowMergeRequest",
"ReviewMergeRequest",
"CheckMergeRequestReadiness",
"CompleteMergeRequest",
] {
assert!(combined.contains(operation), "missing {operation}");
}
for stale_operation in [
"MergeRequestOpen",
"MergeRequestShow",
"MergeRequestReview",
"MergeRequestReadinessCheck",
"MergeRequestComplete",
"MergeRequestAddRevision",
] {
assert!(
!combined.contains(stale_operation),
"stale operation {stale_operation} remains in prompt authority"
);
}
assert!(common.contains("selector_from"));
assert!(common.contains("normal non-force push"));
assert!(common.contains("Moving only the target ref does not invalidate approval"));
assert!(common.contains("take precedence over stale Memory"));
assert!(coder.contains("Never invent an add-revision operation"));
assert!(orchestrator.contains("Target-only movement preserves source approval"));
assert!(reviewer.contains("target-only movement does not invalidate approval"));
}
#[test] #[test]
fn graph_rejects_dynamic_legacy_missing_and_cycles() { fn graph_rejects_dynamic_legacy_missing_and_cycles() {
let invalid = BTreeMap::from([ let invalid = BTreeMap::from([
+3
View File
@@ -121,8 +121,11 @@ impl SegmentLogSink {
matches!( matches!(
entry, entry,
LogEntry::SegmentStart { .. } LogEntry::SegmentStart { .. }
| LogEntry::AnnotatedSegmentStart { .. }
| LogEntry::UserInput { .. } | LogEntry::UserInput { .. }
| LogEntry::AnnotatedUserInput { .. }
| LogEntry::SystemItem { .. } | LogEntry::SystemItem { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. } | LogEntry::Invoke { .. }
) )
} }
+146 -20
View File
@@ -6,7 +6,8 @@
use std::sync::Arc; use std::sync::Arc;
use agen::{Item, Role}; use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
use agen::{HistoryEntry, Item, Role};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const DEFAULT_SEARCH_LIMIT: usize = 20; const DEFAULT_SEARCH_LIMIT: usize = 20;
@@ -21,14 +22,21 @@ const OVERVIEW_ANCHOR_STRIDE: usize = 8;
pub(crate) struct SessionEntryRef(String); pub(crate) struct SessionEntryRef(String);
impl SessionEntryRef { impl SessionEntryRef {
pub(crate) fn new(source_index: usize) -> Self { pub(crate) fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
Self(format!("E{source_index:08}")) Self(format!("E{}", entry_id.0))
} }
pub(crate) fn parse(value: &str) -> Option<Self> { pub(crate) fn parse(value: &str) -> Option<Self> {
let reference = Self(value.to_string()); let suffix = value.strip_prefix('E')?;
reference.source_index()?; if suffix.is_empty()
Some(reference) || suffix.len() > 64
|| !suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return None;
}
Some(Self(value.to_string()))
} }
pub(crate) fn as_str(&self) -> &str { pub(crate) fn as_str(&self) -> &str {
@@ -97,6 +105,7 @@ impl ToolPart {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct OverviewItem { pub(crate) struct OverviewItem {
pub id: SessionEntryRef, pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2], pub entry_range: [u64; 2],
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub label: String, pub label: String,
@@ -107,6 +116,7 @@ pub(crate) struct OverviewItem {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ReferenceEntry { pub(crate) struct ReferenceEntry {
pub id: SessionEntryRef, pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2], pub entry_range: [u64; 2],
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
@@ -132,6 +142,7 @@ pub(crate) struct SearchOptions {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct SearchHit { pub(crate) struct SearchHit {
pub id: SessionEntryRef, pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>, pub tool_name: Option<String>,
@@ -143,6 +154,7 @@ pub(crate) struct SearchHit {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub(crate) enum ReadSelector<'a> { pub(crate) enum ReadSelector<'a> {
Id(&'a str), Id(&'a str),
#[cfg(test)]
EntryRange([u64; 2]), EntryRange([u64; 2]),
} }
@@ -176,6 +188,7 @@ impl Default for ReadOptions {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ReadEntry { pub(crate) struct ReadEntry {
pub id: SessionEntryRef, pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>, pub tool_name: Option<String>,
@@ -194,6 +207,7 @@ pub(crate) struct ReadResult {
pub(crate) struct SessionEntryEvidence { pub(crate) struct SessionEntryEvidence {
pub segment_id: String, pub segment_id: String,
pub entry_ref: SessionEntryRef, pub entry_ref: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2], pub entry_range: [u64; 2],
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
@@ -205,26 +219,42 @@ pub(crate) struct SessionEntryEvidence {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct SessionCapture { pub(crate) struct SessionCapture {
segment_id: String, segment_id: String,
items: Arc<Vec<Item>>, entries: Arc<Vec<HistoryEntry<SessionHistoryMetadata>>>,
overview: Vec<OverviewItem>, overview: Vec<OverviewItem>,
index: Vec<ReferenceEntry>, index: Vec<ReferenceEntry>,
} }
impl SessionCapture { impl SessionCapture {
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self { pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
let entries = items
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("{index:08}"));
HistoryEntry::new(item, metadata)
})
.collect();
Self::from_history_entries(segment_id, entries)
}
pub(crate) fn from_history_entries(
segment_id: impl Into<String>,
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
) -> Self {
let segment_id = segment_id.into(); let segment_id = segment_id.into();
let items = Arc::new(items); let entries = Arc::new(entries);
let mut overview = Vec::new(); let mut overview = Vec::new();
let mut index = Vec::new(); let mut index = Vec::new();
for (idx, item) in items.iter().enumerate() { for (idx, entry) in entries.iter().enumerate() {
let item = &entry.item;
let entry_range = [idx as u64, idx as u64]; let entry_range = [idx as u64, idx as u64];
match item { match item {
Item::Message { role, content, .. } => { Item::Message { role, content, .. } => {
let kind = match role { let Some(kind) = message_reference_kind(&entry.annotation.origin, role) else {
Role::User => ReferenceKind::User, continue;
Role::Assistant => ReferenceKind::Assistant,
Role::System => continue,
}; };
let text = content let text = content
.iter() .iter()
@@ -233,9 +263,10 @@ impl SessionCapture {
.join(""); .join("");
let label = format!("{} message", kind.as_str()); let label = format!("{} message", kind.as_str());
let summary = truncate_chars(&text, 240); let summary = truncate_chars(&text, 240);
let id = SessionEntryRef::new(idx); let id = SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id);
index.push(ReferenceEntry { index.push(ReferenceEntry {
id: id.clone(), id: id.clone(),
origin: entry.annotation.origin.clone(),
entry_range, entry_range,
kind, kind,
tool_part: None, tool_part: None,
@@ -247,6 +278,7 @@ impl SessionCapture {
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) { if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
overview.push(OverviewItem { overview.push(OverviewItem {
id: id.clone(), id: id.clone(),
origin: entry.annotation.origin.clone(),
entry_range, entry_range,
kind, kind,
label, label,
@@ -260,7 +292,8 @@ impl SessionCapture {
} => { } => {
let text = format!("{name}\n{arguments}"); let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry { index.push(ReferenceEntry {
id: SessionEntryRef::new(idx), id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
entry_range, entry_range,
kind: ReferenceKind::Tool, kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Input), tool_part: Some(ToolPart::Input),
@@ -286,7 +319,8 @@ impl SessionCapture {
content.as_deref().unwrap_or_default(), content.as_deref().unwrap_or_default(),
); );
index.push(ReferenceEntry { index.push(ReferenceEntry {
id: SessionEntryRef::new(idx), id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
entry_range, entry_range,
kind: ReferenceKind::Tool, kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Output), tool_part: Some(ToolPart::Output),
@@ -326,7 +360,7 @@ impl SessionCapture {
Self { Self {
segment_id, segment_id,
items, entries,
overview, overview,
index, index,
} }
@@ -336,6 +370,14 @@ impl SessionCapture {
&self.overview &self.overview
} }
pub(crate) fn source_index_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
self.index
.iter()
.find(|entry| entry.id == *reference)
.map(|entry| entry.entry_range[0])
.or_else(|| reference.source_index())
}
pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> { pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
let query = options.query.trim().to_lowercase(); let query = options.query.trim().to_lowercase();
let limit = options let limit = options
@@ -346,12 +388,12 @@ impl SessionCapture {
let min_entry_index = options let min_entry_index = options
.from .from
.as_ref() .as_ref()
.and_then(SessionEntryRef::source_index) .and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0)); .unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
let max_entry_index = options let max_entry_index = options
.through .through
.as_ref() .as_ref()
.and_then(SessionEntryRef::source_index) .and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or(u64::MAX); .unwrap_or(u64::MAX);
let mut skipped = 0usize; let mut skipped = 0usize;
let mut hits = Vec::new(); let mut hits = Vec::new();
@@ -390,6 +432,7 @@ impl SessionCapture {
} }
hits.push(SearchHit { hits.push(SearchHit {
id: entry.id.clone(), id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind, kind: entry.kind,
tool_part: entry.tool_part, tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(), tool_name: entry.tool_name.clone(),
@@ -418,6 +461,7 @@ impl SessionCapture {
.iter() .iter()
.filter(|entry| entry.id.as_str() == id) .filter(|entry| entry.id.as_str() == id)
.collect(), .collect(),
#[cfg(test)]
ReadSelector::EntryRange([start, end]) => self ReadSelector::EntryRange([start, end]) => self
.index .index
.iter() .iter()
@@ -440,13 +484,18 @@ impl SessionCapture {
} }
} }
} }
let Some(item) = self.items.get(entry.entry_range[0] as usize) else { let Some(item) = self
.entries
.get(entry.entry_range[0] as usize)
.map(|entry| &entry.item)
else {
continue; continue;
}; };
let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes)); let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes));
bytes = bytes.saturating_add(text.len()); bytes = bytes.saturating_add(text.len());
entries.push(ReadEntry { entries.push(ReadEntry {
id: entry.id.clone(), id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind, kind: entry.kind,
tool_part: entry.tool_part, tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(), tool_name: entry.tool_name.clone(),
@@ -483,6 +532,7 @@ impl SessionCapture {
Some(SessionEntryEvidence { Some(SessionEntryEvidence {
segment_id: self.segment_id.clone(), segment_id: self.segment_id.clone(),
entry_ref: entry.id.clone(), entry_ref: entry.id.clone(),
origin: entry.origin.clone(),
entry_range: entry.entry_range, entry_range: entry.entry_range,
kind: entry.kind, kind: entry.kind,
tool_part: entry.tool_part, tool_part: entry.tool_part,
@@ -493,6 +543,28 @@ impl SessionCapture {
} }
} }
fn message_reference_kind(
origin: &WorkerHistoryProvenance,
provider_role: &Role,
) -> Option<ReferenceKind> {
match origin {
WorkerHistoryProvenance::HumanInput { .. }
| WorkerHistoryProvenance::WorkerInput { .. } => Some(ReferenceKind::User),
WorkerHistoryProvenance::ModelOutput { .. } => Some(ReferenceKind::Assistant),
WorkerHistoryProvenance::ToolOutput { .. } => Some(ReferenceKind::Tool),
WorkerHistoryProvenance::LegacyUnknown => match provider_role {
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
Role::System => None,
},
// Flow/backend/system content remains out of the observation surface
// even when represented with a provider user/system role.
WorkerHistoryProvenance::FlowInstruction { .. }
| WorkerHistoryProvenance::BackendInstruction { .. }
| WorkerHistoryProvenance::DerivedSummary => None,
}
}
fn render_item( fn render_item(
item: &Item, item: &Item,
entry: &ReferenceEntry, entry: &ReferenceEntry,
@@ -561,6 +633,60 @@ fn truncate_chars(text: &str, max_chars: usize) -> String {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn flow_user_role_is_excluded_while_explicit_human_origin_remains_evidence() {
let entries = vec![
crate::session_history::history_entry(
Item::user_message("trusted flow instruction"),
WorkerHistoryProvenance::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "coder-review".into(),
definition_revision: 3,
instance_id: "instance".into(),
state_id: "implement".into(),
},
),
crate::session_history::history_entry(
Item::user_message("remember my preference"),
WorkerHistoryProvenance::HumanInput {
account_id: "account-1".into(),
},
),
];
let capture = SessionCapture::from_history_entries("segment", entries);
let overview = capture.overview();
assert_eq!(overview.len(), 1);
assert!(matches!(
overview[0].origin,
WorkerHistoryProvenance::HumanInput { .. }
));
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
assert!(evidence.excerpt.ends_with("remember my preference"));
assert!(matches!(
evidence.origin,
WorkerHistoryProvenance::HumanInput { .. }
));
}
#[test]
fn stable_logical_ref_survives_retention_and_restore_projection() {
let retained = crate::session_history::history_entry(
Item::assistant_message("retained"),
WorkerHistoryProvenance::ModelOutput {
worker: crate::session_history::worker_subject(Default::default()),
},
);
let expected_ref = SessionEntryRef::from_history_entry_id(&retained.annotation.entry_id);
let before = SessionCapture::from_history_entries("old", vec![retained.clone()]);
let after = SessionCapture::from_history_entries("new", vec![retained]);
assert_eq!(before.overview()[0].id, expected_ref);
assert_eq!(after.overview()[0].id, expected_ref);
assert_eq!(
after.evidence_for(expected_ref.as_str()).unwrap().entry_ref,
expected_ref
);
}
#[test] #[test]
fn overview_contains_user_and_assistant_only() { fn overview_contains_user_and_assistant_only() {
let view = SessionCapture::new( let view = SessionCapture::new(
+219
View File
@@ -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]
);
}
}
+325 -19
View File
@@ -1,7 +1,8 @@
//! Parent-owned registry of direct Internal SubWorker sessions. //! Parent-owned registry of direct Internal Worker sessions.
//! //!
//! `SubWorkerSpawn` inserts typed `InternalWorkerSessionHandle`s; List/Send/Stop and //! `SubWorkerSpawn` inserts controllable SubWorker handles, while host services such as
//! worker-observation use the same in-memory authority. Internal children are not persisted, restored, discovered as //! compaction insert parent-visible service handles without joining the model-facing
//! List/Send/Stop surface. Internal children are not persisted, restored, discovered as
//! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process //! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process
//! child records only to reclaim their delegated scope and clear obsolete metadata. //! child records only to reclaim their delegated scope and clear obsolete metadata.
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny //! Parent registry drop closes all session handles and synchronously returns delegated Write deny
@@ -177,6 +178,52 @@ impl InternalSpawnedWorkerRecord {
} }
} }
/// Parent-visible service Internal Worker. Unlike a SubWorker this record has no
/// delegated scope, model-facing control name, or stop-summary authority.
#[derive(Clone)]
pub(crate) struct InternalServiceWorkerRecord {
pub service_kind: String,
pub display_name: String,
pub session: InternalWorkerSessionHandle,
protocol_revision: Arc<AtomicU64>,
protocol_emit_lock: Arc<Mutex<()>>,
protocol_terminal: Arc<AtomicBool>,
forwarding_started: Arc<AtomicBool>,
}
impl InternalServiceWorkerRecord {
pub(crate) fn new(
service_kind: impl Into<String>,
display_name: impl Into<String>,
session: InternalWorkerSessionHandle,
) -> Self {
Self {
service_kind: service_kind.into(),
display_name: display_name.into(),
session,
protocol_revision: Arc::new(AtomicU64::new(0)),
protocol_emit_lock: Arc::new(Mutex::new(())),
protocol_terminal: Arc::new(AtomicBool::new(false)),
forwarding_started: Arc::new(AtomicBool::new(false)),
}
}
fn protocol_ref(&self, parent_session_id: Option<String>) -> InternalWorkerRef {
InternalWorkerRef {
session_id: self.session.session_id_string(),
name: self.display_name.clone(),
parent_session_id,
kind: InternalWorkerKind::Service {
kind: self.service_kind.clone(),
},
}
}
fn protocol_revision(&self) -> u64 {
self.protocol_revision.load(Ordering::Acquire)
}
}
pub(crate) struct InternalSpawnReservation { pub(crate) struct InternalSpawnReservation {
registry: Arc<SpawnedWorkerRegistry>, registry: Arc<SpawnedWorkerRegistry>,
worker_name: String, worker_name: String,
@@ -214,6 +261,7 @@ impl Drop for InternalSpawnReservation {
pub struct SpawnedWorkerRegistry { pub struct SpawnedWorkerRegistry {
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>, internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
internal_names: std::sync::Mutex<HashSet<String>>, internal_names: std::sync::Mutex<HashSet<String>>,
parent_scope: Option<SharedScope>, parent_scope: Option<SharedScope>,
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>, parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
@@ -226,10 +274,21 @@ pub struct SpawnedWorkerRegistryLoad {
} }
impl SpawnedWorkerRegistry { impl SpawnedWorkerRegistry {
pub(crate) fn new_for_internal_services() -> Arc<Self> {
Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope: None,
parent_protocol: Mutex::new(None),
})
}
/// Empty registry used by tests and non-spawning projections. /// Empty registry used by tests and non-spawning projections.
pub fn new(_runtime_dir: Arc<RuntimeDir>) -> Arc<Self> { pub fn new(_runtime_dir: Arc<RuntimeDir>) -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope: None, parent_scope: None,
parent_protocol: Mutex::new(None), parent_protocol: Mutex::new(None),
@@ -239,6 +298,7 @@ impl SpawnedWorkerRegistry {
pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> Arc<Self> { pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope: Some(parent_scope), parent_scope: Some(parent_scope),
parent_protocol: Mutex::new(None), parent_protocol: Mutex::new(None),
@@ -317,6 +377,7 @@ impl SpawnedWorkerRegistry {
Ok(SpawnedWorkerRegistryLoad { Ok(SpawnedWorkerRegistryLoad {
registry: Arc::new(Self { registry: Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope, parent_scope,
parent_protocol: Mutex::new(None), parent_protocol: Mutex::new(None),
@@ -356,6 +417,144 @@ impl SpawnedWorkerRegistry {
for record in self.internal_records.lock().unwrap().clone() { for record in self.internal_records.lock().unwrap().clone() {
self.start_protocol_forwarding(record); self.start_protocol_forwarding(record);
} }
for record in self.service_records.lock().unwrap().clone() {
self.start_service_protocol_forwarding(record);
}
}
/// Register a parent-visible service Internal Worker before its first turn.
pub(crate) fn attach_service(
&self,
record: InternalServiceWorkerRecord,
) -> io::Result<InternalWorkerRef> {
let parent_session_id = self
.parent_protocol
.lock()
.unwrap()
.as_ref()
.map(|(_, id)| id.clone());
let worker_ref = record.protocol_ref(parent_session_id);
let session_id = record.session.session_id_string();
let mut records = self
.service_records
.lock()
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?;
if records
.iter()
.any(|candidate| candidate.session.session_id_string() == session_id)
{
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"internal service Worker is already registered",
));
}
records.push(record.clone());
drop(records);
self.start_service_protocol_forwarding(record);
Ok(worker_ref)
}
/// Stop and remove one parent-owned service Worker. This is host-only and is
/// intentionally separate from the SubWorker control surface.
pub(crate) async fn stop_service(&self, session_id: &str) -> io::Result<bool> {
let record = self
.service_records
.lock()
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?
.iter()
.find(|record| record.session.session_id_string() == session_id)
.cloned();
let Some(record) = record else {
return Ok(false);
};
record
.session
.stop()
.await
.map_err(|error| io::Error::other(error.to_string()))?;
self.remove_service(session_id)
}
/// Remove one service Worker and emit the terminal projection fence.
pub(crate) fn remove_service(&self, session_id: &str) -> io::Result<bool> {
let removed = {
let mut records = self
.service_records
.lock()
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?;
records
.iter()
.position(|record| record.session.session_id_string() == session_id)
.map(|index| records.remove(index))
};
if let Some(record) = removed {
self.publish_service_removal(&record);
Ok(true)
} else {
Ok(false)
}
}
fn start_service_protocol_forwarding(&self, record: InternalServiceWorkerRecord) {
if record.session.visibility() != InternalWorkerVisibility::ParentClient
|| record.forwarding_started.swap(true, Ordering::AcqRel)
{
return;
}
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
else {
record.forwarding_started.store(false, Ordering::Release);
return;
};
let worker = record.protocol_ref(Some(parent_session_id));
let protocol_revision = record.protocol_revision.clone();
let protocol_emit_lock = record.protocol_emit_lock.clone();
let protocol_terminal = record.protocol_terminal.clone();
let mut child_rx = record.session.subscribe_events();
tokio::spawn(async move {
loop {
match child_rx.recv().await {
Ok(event) => {
let shutdown = matches!(event, Event::Shutdown);
let _emit_guard = protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(event),
});
if shutdown {
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
let _emit_guard = protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(Event::Error {
code: protocol::ErrorCode::Internal,
message: format!(
"internal Worker output lagged by {skipped} events; reconnect to resynchronize"
),
}),
});
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
} }
fn start_protocol_forwarding(&self, record: InternalSpawnedWorkerRecord) { fn start_protocol_forwarding(&self, record: InternalSpawnedWorkerRecord) {
@@ -427,28 +626,37 @@ impl SpawnedWorkerRegistry {
.unwrap() .unwrap()
.as_ref() .as_ref()
.map(|(_, id)| id.clone()); .map(|(_, id)| id.clone());
self.internal_records let mut snapshots = self
.internal_records
.lock() .lock()
.unwrap() .unwrap()
.iter() .iter()
.filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient) .filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient)
.map(|record| { .map(|record| {
let snapshot = record.session.protocol_snapshot(); internal_worker_snapshot(
InternalWorkerSnapshot { record.protocol_ref(parent_session_id.clone()),
worker: record.protocol_ref(parent_session_id.clone()), record.protocol_revision(),
revision: record.protocol_revision(), &record.session,
entries: snapshot )
.entries
.into_iter()
.filter_map(|entry| serde_json::to_value(entry).ok())
.collect(),
status: snapshot.status,
error: snapshot.error,
in_flight: snapshot.in_flight,
internal_workers: snapshot.internal_workers,
}
}) })
.collect() .collect::<Vec<_>>();
snapshots.extend(
self.service_records
.lock()
.unwrap()
.iter()
.filter(|record| {
record.session.visibility() == InternalWorkerVisibility::ParentClient
})
.map(|record| {
internal_worker_snapshot(
record.protocol_ref(parent_session_id.clone()),
record.protocol_revision(),
&record.session,
)
}),
);
snapshots
} }
pub(crate) fn get_internal(&self, worker_name: &str) -> Option<InternalSpawnedWorkerRecord> { pub(crate) fn get_internal(&self, worker_name: &str) -> Option<InternalSpawnedWorkerRecord> {
@@ -567,6 +775,47 @@ impl SpawnedWorkerRegistry {
revision, revision,
}); });
} }
fn publish_service_removal(&self, record: &InternalServiceWorkerRecord) {
if record.session.visibility() != InternalWorkerVisibility::ParentClient {
return;
}
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
else {
return;
};
let _emit_guard = record
.protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
record.protocol_terminal.store(true, Ordering::Release);
let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorkerRemoved {
worker: record.protocol_ref(Some(parent_session_id)),
revision,
});
}
}
fn internal_worker_snapshot(
worker: InternalWorkerRef,
revision: u64,
session: &InternalWorkerSessionHandle,
) -> InternalWorkerSnapshot {
let snapshot = session.protocol_snapshot();
InternalWorkerSnapshot {
worker,
revision,
entries: snapshot
.entries
.into_iter()
.filter_map(|entry| serde_json::to_value(entry).ok())
.collect(),
status: snapshot.status,
error: snapshot.error,
in_flight: snapshot.in_flight,
internal_workers: snapshot.internal_workers,
}
} }
impl Drop for SpawnedWorkerRegistry { impl Drop for SpawnedWorkerRegistry {
@@ -813,6 +1062,63 @@ mod tests {
assert_eq!(snapshots[0].in_flight.blocks.len(), 1); assert_eq!(snapshots[0].in_flight.blocks.len(), 1);
} }
#[tokio::test]
async fn service_worker_is_parent_visible_but_not_subworker_controllable() {
let registry = registry();
let (parent_tx, mut parent_rx) = broadcast::channel(16);
registry.attach_parent_protocol(parent_tx, "parent-session".into());
let (session, child_tx) =
test_internal_worker_session(InternalWorkerVisibility::ParentClient);
let session_id = session.session_id_string();
let worker_ref = registry
.attach_service(InternalServiceWorkerRecord::new(
"compaction",
"Compaction",
session,
))
.unwrap();
assert!(matches!(
worker_ref.kind,
InternalWorkerKind::Service { ref kind } if kind == "compaction"
));
assert!(registry.list_internal().is_empty());
child_tx
.send(Event::TextDone {
text: "summary candidate".into(),
})
.unwrap();
let event = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
.await
.unwrap()
.unwrap();
assert!(matches!(
event,
Event::InternalWorker { worker, revision: 1, event }
if worker.session_id == session_id
&& matches!(worker.kind, InternalWorkerKind::Service { ref kind } if kind == "compaction")
&& matches!(*event, Event::TextDone { ref text } if text == "summary candidate")
));
let snapshots = registry.internal_worker_snapshots();
assert_eq!(snapshots.len(), 1);
assert!(matches!(
snapshots[0].worker.kind,
InternalWorkerKind::Service { ref kind } if kind == "compaction"
));
assert!(registry.remove_service(&session_id).unwrap());
let removed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
.await
.unwrap()
.unwrap();
assert!(matches!(
removed,
Event::InternalWorkerRemoved { worker, revision: 2 }
if worker.session_id == session_id
));
assert!(registry.internal_worker_snapshots().is_empty());
}
#[tokio::test] #[tokio::test]
async fn service_private_internal_output_is_never_disclosed() { async fn service_private_internal_output_is_never_disclosed() {
let registry = registry(); let registry = registry();
+19 -20
View File
@@ -329,13 +329,13 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(), "reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
)); ));
} }
if input if !input
.scope .scope
.iter() .iter()
.any(|rule| matches!(rule.permission, PermissionInput::Write)) .any(|rule| matches!(rule.permission, PermissionInput::Write))
{ {
return Err(ToolError::InvalidArgument( return Err(ToolError::InvalidArgument(
"Merge Request Reviewer SubWorkers must have read-only delegated scope".to_string(), "Merge Request Reviewer SubWorkers must include writable delegated scope".to_string(),
)); ));
} }
Ok(()) Ok(())
@@ -1008,28 +1008,28 @@ mod tests {
} }
#[test] #[test]
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() { fn reviewer_handoff_requires_explicit_builtin_profile_and_writable_scope() {
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer", "name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"work","permission":"read"}], "scope":[{"target":"work","permission":"write"}],
"review":{"ticket_id":"T1"} "review":{"ticket_id":"T1"}
})) }))
.unwrap(); .unwrap();
assert!(validate_reviewer_handoff(&valid).is_ok()); assert!(validate_reviewer_handoff(&valid).is_ok());
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:coder", "name":"reviewer","task":"review","profile":"builtin:coder",
"scope":[{"target":"work","permission":"read"}],
"review":{"ticket_id":"T1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"work","permission":"write"}], "scope":[{"target":"work","permission":"write"}],
"review":{"ticket_id":"T1"} "review":{"ticket_id":"T1"}
})) }))
.unwrap(); .unwrap();
assert!(validate_reviewer_handoff(&writable).is_err()); assert!(validate_reviewer_handoff(&wrong_profile).is_err());
let read_only: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"work","permission":"read"}],
"review":{"ticket_id":"T1"}
}))
.unwrap();
assert!(validate_reviewer_handoff(&read_only).is_err());
} }
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule { fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
@@ -1079,7 +1079,7 @@ extract_threshold = 4000
} }
#[tokio::test] #[tokio::test]
async fn reviewer_profile_spawns_and_notifies_parent_controller() { async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
let runtime = TempDir::new().unwrap(); let runtime = TempDir::new().unwrap();
let workspace_root = runtime.path().join("project"); let workspace_root = runtime.path().join("project");
let available_profiles = write_project_profile_registry( let available_profiles = write_project_profile_registry(
@@ -1140,7 +1140,7 @@ extract_threshold = 4000
"task": "review immutable commit", "task": "review immutable commit",
"scope": [{ "scope": [{
"target": ".", "target": ".",
"permission": "read", "permission": "write",
"recursive": true "recursive": true
}] }]
}); });
@@ -1171,11 +1171,10 @@ extract_threshold = 4000
let record = registry let record = registry
.get_internal("reviewer-child") .get_internal("reviewer-child")
.expect("Internal reviewer registry record"); .expect("Internal reviewer registry record");
assert!(record.installed_tools.iter().any(|name| name == "Read")); for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
for denied in ["Write", "Edit", "Bash"] {
assert!( assert!(
!record.installed_tools.iter().any(|name| name == denied), record.installed_tools.iter().any(|name| name == required),
"read-only child unexpectedly received {denied}: {:?}", "write-scoped child is missing {required}: {:?}",
record.installed_tools record.installed_tools
); );
} }
@@ -1250,7 +1249,7 @@ extract_threshold = 4000
) )
.await .await
.unwrap(); .unwrap();
assert!(first_capture.items.iter().any(|item| { assert!(first_capture.entries.iter().map(|entry| &entry.item).any(|item| {
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed")))) matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
})); }));
@@ -1272,7 +1271,7 @@ extract_threshold = 4000
) )
.await .await
.unwrap(); .unwrap();
assert!(latest_capture.items.len() > first_capture.items.len()); assert!(latest_capture.entries.len() > first_capture.entries.len());
fail_requests.store(true, Ordering::SeqCst); fail_requests.store(true, Ordering::SeqCst);
send.execute( send.execute(
+1020 -323
View File
File diff suppressed because it is too large Load Diff
+86 -11
View File
@@ -163,7 +163,8 @@ async fn make_worker_with_manifest(
let scope = worker::Scope::writable(&pwd).unwrap(); let scope = worker::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp); std::mem::forget(pwd_tmp);
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
worker, worker,
@@ -204,7 +205,14 @@ fn system_texts_in_sink_session_start(
) -> Vec<String> { ) -> Vec<String> {
let (entries, _rx) = worker.sink().subscribe_with_snapshot(); let (entries, _rx) = worker.sink().subscribe_with_snapshot();
for entry in entries.into_iter().rev() { for entry in entries.into_iter().rev() {
if let session_store::LogEntry::SegmentStart { history, .. } = entry { let history = match entry {
session_store::LogEntry::AnnotatedSegmentStart { history, .. } => history
.into_iter()
.map(|entry| entry.item)
.collect::<Vec<_>>(),
session_store::LogEntry::SegmentStart { history, .. } => history,
_ => continue,
};
return history return history
.into_iter() .into_iter()
.filter_map(|logged| { .filter_map(|logged| {
@@ -226,7 +234,6 @@ fn system_texts_in_sink_session_start(
}) })
.collect(); .collect();
} }
}
Vec::new() Vec::new()
} }
@@ -337,7 +344,12 @@ permission = "write"
// New segment records forked_from pointing at the source. // New segment records forked_from pointing at the source.
let new_entries = store.read_all(session_id, new_segment_id).unwrap(); let new_entries = store.read_all(session_id, new_segment_id).unwrap();
match &new_entries[0] { match &new_entries[0] {
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
session_id: seg_session,
forked_from: Some(origin),
..
}
| LogEntry::SegmentStart {
session_id: seg_session, session_id: seg_session,
forked_from: Some(origin), forked_from: Some(origin),
.. ..
@@ -431,7 +443,7 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
let kinds: Vec<&str> = events let kinds: Vec<&str> = events
.iter() .iter()
.map(|e| match e { .map(|e| match e {
Event::CompactStart => "start", Event::CompactStart { .. } => "start",
Event::CompactDone { .. } => "done", Event::CompactDone { .. } => "done",
Event::CompactFailed { .. } => "failed", Event::CompactFailed { .. } => "failed",
_ => "other", _ => "other",
@@ -445,10 +457,61 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
!kinds.contains(&"failed"), !kinds.contains(&"failed"),
"unexpected CompactFailed in {kinds:?}" "unexpected CompactFailed in {kinds:?}"
); );
let starts = events
.iter()
.filter_map(|event| match event {
Event::CompactStart { lifecycle } => Some(lifecycle),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
starts.len(),
2,
"start and Internal Worker binding revisions"
);
assert_eq!(starts[0].compaction_id, starts[1].compaction_id);
assert_eq!(starts[0].revision, 1);
assert!(starts[0].internal_worker.is_none());
assert_eq!(starts[1].revision, 2);
assert!(matches!(
starts[1].internal_worker.as_ref().map(|worker| &worker.kind),
Some(protocol::InternalWorkerKind::Service { kind }) if kind == "compaction"
));
assert!(events.iter().any(|event| matches!(
event,
Event::InternalWorker { worker, .. }
if matches!(&worker.kind, protocol::InternalWorkerKind::Service { kind } if kind == "compaction")
)), "compactor activity must be projected through the parent stream");
let completed = events
.iter()
.find_map(|event| match event {
Event::CompactDone { lifecycle } => Some(lifecycle),
_ => None,
})
.expect("completed lifecycle");
assert_eq!(completed.compaction_id, starts[0].compaction_id);
assert_eq!(completed.revision, 3);
assert_eq!(completed.summary.as_deref(), Some("summary"));
assert_eq!(completed.state, protocol::CompactionLifecycleState::Done);
let done_index = events
.iter()
.position(|event| matches!(event, Event::CompactDone { .. }))
.expect("done event");
let removed_index = events
.iter()
.position(|event| matches!(event, Event::InternalWorkerRemoved { .. }))
.expect("terminal compactor session must be released");
assert!(
done_index < removed_index,
"terminal lifecycle precedes release fence"
);
// CompactDone carries the new Segment ID; the Session ID is unchanged. // CompactDone carries the new Segment ID; the Session ID is unchanged.
let new_id_in_event = events.iter().find_map(|e| match e { let new_id_in_event = events.iter().find_map(|e| match e {
Event::CompactDone { new_segment_id } => Some(*new_segment_id), Event::CompactDone { lifecycle } => lifecycle
.new_segment_id
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
_ => None, _ => None,
}); });
assert!(new_id_in_event.is_some(), "CompactDone missing"); assert!(new_id_in_event.is_some(), "CompactDone missing");
@@ -488,7 +551,7 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
let kinds: Vec<&str> = events let kinds: Vec<&str> = events
.iter() .iter()
.map(|e| match e { .map(|e| match e {
Event::CompactStart => "start", Event::CompactStart { .. } => "start",
Event::CompactDone { .. } => "done", Event::CompactDone { .. } => "done",
Event::CompactFailed { .. } => "failed", Event::CompactFailed { .. } => "failed",
_ => "other", _ => "other",
@@ -504,7 +567,10 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
); );
let new_id_in_event = events.iter().find_map(|e| match e { let new_id_in_event = events.iter().find_map(|e| match e {
Event::CompactDone { new_segment_id } => Some(*new_segment_id), Event::CompactDone { lifecycle } => lifecycle
.new_segment_id
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
_ => None, _ => None,
}); });
assert_eq!(new_id_in_event, Some(worker.segment_id())); assert_eq!(new_id_in_event, Some(worker.segment_id()));
@@ -533,6 +599,9 @@ model_id = "test-model"
max_tokens = 100 max_tokens = 100
[memory] [memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1 extract_threshold = 1
[compaction] [compaction]
@@ -659,7 +728,7 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
let kinds: Vec<&str> = events let kinds: Vec<&str> = events
.iter() .iter()
.map(|e| match e { .map(|e| match e {
Event::CompactStart => "start", Event::CompactStart { .. } => "start",
Event::CompactDone { .. } => "done", Event::CompactDone { .. } => "done",
Event::CompactFailed { .. } => "failed", Event::CompactFailed { .. } => "failed",
_ => "other", _ => "other",
@@ -695,6 +764,9 @@ model_id = "test-model"
max_tokens = 100 max_tokens = 100
[memory] [memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1 extract_threshold = 1
[[scope.allow]] [[scope.allow]]
@@ -817,11 +889,14 @@ async fn controller_compact_method_emits_start_and_done() {
.expect("timeout waiting for compact events") .expect("timeout waiting for compact events")
.expect("event") .expect("event")
{ {
Event::CompactStart => saw_start = true, Event::CompactStart { .. } => saw_start = true,
Event::CompactDone { .. } => { Event::CompactDone { .. } => {
break; break;
} }
Event::CompactFailed { error } => panic!("manual compact failed: {error}"), Event::CompactFailed { lifecycle } => panic!(
"manual compact failed: {}",
lifecycle.error.as_deref().unwrap_or("unknown error")
),
_ => {} _ => {}
} }
} }
+43 -24
View File
@@ -32,16 +32,29 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
let mut items = Vec::new(); let mut items = Vec::new();
for entry in entries { for entry in entries {
match entry { match entry {
LogEntry::AnnotatedSegmentStart { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::SegmentStart { history, .. } => { LogEntry::SegmentStart { history, .. } => {
items.extend(history.into_iter().map(Item::from)); items.extend(history.into_iter().map(Item::from));
} }
LogEntry::AnnotatedUserInput { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::UserInput { segments, .. } => { LogEntry::UserInput { segments, .. } => {
let text = protocol::Segment::flatten_to_text(&segments); let text = protocol::Segment::flatten_to_text(&segments);
items.push(Item::user_message(text)); items.push(Item::user_message(text));
} }
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => {
items.push(Item::from(entry.item));
}
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => { LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
items.push(Item::from(item)); items.push(Item::from(item));
} }
LogEntry::AnnotatedSystemItem { entry, .. } => {
items.push(entry.item.to_history_item());
}
LogEntry::SystemItem { item, .. } => { LogEntry::SystemItem { item, .. } => {
items.push(item.to_history_item()); items.push(item.to_history_item());
} }
@@ -51,6 +64,14 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
items items
} }
fn system_item(entry: &LogEntry) -> Option<&session_store::SystemItem> {
match entry {
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
LogEntry::SystemItem { item, .. } => Some(item),
_ => None,
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock LLM Client // Mock LLM Client
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -192,7 +213,8 @@ async fn make_worker_with_pwd_and_manifest(
let scope = manifest::Scope::writable(&pwd).unwrap(); let scope = manifest::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp); std::mem::forget(pwd_tmp);
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()); let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
let worker = Worker::new( let worker = Worker::new(
manifest, manifest,
@@ -804,10 +826,12 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
// Walk the entries, find a `LogEntry::UserInput` and // Walk the entries, find a `LogEntry::UserInput` and
// confirm its segments flatten to our submitted text. // confirm its segments flatten to our submitted text.
let mut found = false; let mut found = false;
for value in entries { for value in &entries {
let entry: session_store::LogEntry = let entry: session_store::LogEntry =
serde_json::from_value(value).expect("LogEntry deserialise"); serde_json::from_value(value.clone()).expect("LogEntry deserialise");
if let session_store::LogEntry::UserInput { segments, .. } = entry { if let session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } = entry
{
let text = protocol::Segment::flatten_to_text(&segments); let text = protocol::Segment::flatten_to_text(&segments);
if text == "hello in-flight" { if text == "hello in-flight" {
found = true; found = true;
@@ -815,7 +839,10 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
} }
} }
} }
assert!(found, "snapshot must carry the in-flight UserInput entry"); assert!(
found,
"snapshot must carry the in-flight UserInput entry: {entries:?}"
);
return; return;
} }
Event::Alert(_) => continue, Event::Alert(_) => continue,
@@ -1086,7 +1113,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
_ => {} _ => {}
}, },
entry = entry_rx.recv() => match entry { entry = entry_rx.recv() => match entry {
Ok(session_store::LogEntry::UserInput { segments, .. }) => { Ok(session_store::LogEntry::UserInput { segments, .. } | session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
user_input_segments = Some(segments); user_input_segments = Some(segments);
if saw_turn_end { if saw_turn_end {
break; break;
@@ -1317,11 +1344,8 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
let (entries, _) = handle.sink.subscribe_with_snapshot(); let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_notify_in_mirror = entries.iter().any(|e| { let saw_notify_in_mirror = entries.iter().any(|e| {
matches!( matches!(
e, system_item(e),
session_store::LogEntry::SystemItem { Some(session_store::SystemItem::Notification { message, .. }) if message == "turn finished"
item: session_store::SystemItem::Notification { message, .. },
..
} if message == "turn finished"
) )
}); });
assert!( assert!(
@@ -1463,14 +1487,11 @@ async fn worker_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_
let (entries, _) = handle.sink.subscribe_with_snapshot(); let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_worker_event_in_mirror = entries.iter().any(|e| { let saw_worker_event_in_mirror = entries.iter().any(|e| {
matches!( matches!(
e, system_item(e),
session_store::LogEntry::SystemItem { Some(session_store::SystemItem::WorkerEvent {
item: session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::TurnEnded { worker_name }, event: protocol::WorkerEvent::TurnEnded { worker_name },
.. ..
}, }) if worker_name == "child"
..
} if worker_name == "child"
) )
}); });
assert!( assert!(
@@ -1552,14 +1573,11 @@ async fn worker_event_scope_sub_delegated_while_idle_stays_control_plane_only()
let (entries, _) = handle.sink.subscribe_with_snapshot(); let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_scope_event_in_mirror = entries.iter().any(|entry| { let saw_scope_event_in_mirror = entries.iter().any(|entry| {
matches!( matches!(
entry, system_item(entry),
session_store::LogEntry::SystemItem { Some(session_store::SystemItem::WorkerEvent {
item: session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::ScopeSubDelegated { .. }, event: protocol::WorkerEvent::ScopeSubDelegated { .. },
.. ..
}, })
..
}
) )
}); });
assert!( assert!(
@@ -2373,7 +2391,8 @@ async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bo
let entry: session_store::LogEntry = let entry: session_store::LogEntry =
serde_json::from_value(value).expect("LogEntry deserialise"); serde_json::from_value(value).expect("LogEntry deserialise");
match entry { match entry {
session_store::LogEntry::UserInput { segments, .. } => { session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } => {
protocol::Segment::flatten_to_text(&segments).contains(needle) protocol::Segment::flatten_to_text(&segments).contains(needle)
} }
_ => false, _ => false,
+7 -3
View File
@@ -87,6 +87,7 @@ impl Tool for BigContentTool {
Ok(ToolOutput { Ok(ToolOutput {
summary: self.summary.into(), summary: self.summary.into(),
content: Some(self.content.clone()), content: Some(self.content.clone()),
attachments: Vec::new(),
}) })
} }
} }
@@ -187,7 +188,8 @@ async fn make_worker(
let pwd = pwd_tmp.path().to_path_buf(); let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap(); let scope = worker::Scope::writable(&pwd).unwrap();
let mut worker = Engine::new(client); let mut worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
worker.register_tool(big_content_tool_definition(tool_name)); worker.register_tool(big_content_tool_definition(tool_name));
let worker = Worker::new( let worker = Worker::new(
@@ -459,7 +461,8 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() {
// protected token budget covers the only user message). That is enough to drive // protected token budget covers the only user message). That is enough to drive
// the failure path: at least one metric attempts to write. // the failure path: at least one metric attempts to write.
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]); let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
worker, worker,
@@ -535,7 +538,8 @@ permission = "write"
let pwd_tmp = tempfile::tempdir().unwrap(); let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf(); let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap(); let scope = worker::Scope::writable(&pwd).unwrap();
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
worker, worker,
@@ -130,7 +130,8 @@ async fn make_worker_with_body(
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap(); EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection); let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
worker, worker,
+195 -19
View File
@@ -7,6 +7,88 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use workdir::workspace::WorkingDirectorySummary; use workdir::workspace::WorkingDirectorySummary;
/// Provider-neutral classification of an authoritative Repository source.
///
/// Local paths remain distinct from network Git transports so callers cannot
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RepositorySourceKind {
LocalPath,
File,
Ssh,
Http,
Https,
/// A legacy value that could not be classified during migration. It remains
/// inspectable but every provider operation must fail closed.
Invalid,
}
impl RepositorySourceKind {
pub const fn is_remote(self) -> bool {
matches!(self, Self::Ssh | Self::Http | Self::Https)
}
pub const fn as_str(self) -> &'static str {
match self {
Self::LocalPath => "local_path",
Self::File => "file",
Self::Ssh => "ssh",
Self::Http => "http",
Self::Https => "https",
Self::Invalid => "invalid",
}
}
pub fn parse(value: &str) -> Option<Self> {
Some(match value {
"local_path" => Self::LocalPath,
"file" => Self::File,
"ssh" => Self::Ssh,
"http" => Self::Http,
"https" => Self::Https,
"invalid" => Self::Invalid,
_ => return None,
})
}
}
/// Stable Repository source identity stored by Workspace authority.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySource {
pub kind: RepositorySourceKind,
/// Canonical source representation. This is an absolute local path for
/// `local_path`, and a normalized URI/remote specification otherwise.
pub uri: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RepositoryObservedStatus {
Unverified,
Ready,
Invalid,
}
impl RepositoryObservedStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Unverified => "unverified",
Self::Ready => "ready",
Self::Invalid => "invalid",
}
}
pub fn parse(value: &str) -> Option<Self> {
Some(match value {
"unverified" => Self::Unverified,
"ready" => Self::Ready,
"invalid" => Self::Invalid,
_ => return None,
})
}
}
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search"; pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search"; pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
@@ -165,24 +247,6 @@ pub struct RuntimeSourceSummary {
pub note: String, pub note: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeCapabilitySummary {
pub can_list_hosts: bool,
pub can_list_workers: bool,
pub can_get_worker: bool,
pub can_spawn_worker: bool,
pub can_stop_worker: bool,
pub has_workspace_fs: bool,
pub has_shell: bool,
pub has_git: bool,
pub supports_worktrees: bool,
pub supports_backend_internal_tools: bool,
pub workspace_scope: String,
pub max_workers: usize,
pub os: String,
pub arch: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeSummary { pub struct RuntimeSummary {
pub runtime_id: String, pub runtime_id: String,
@@ -192,7 +256,9 @@ pub struct RuntimeSummary {
pub source: RuntimeSourceSummary, pub source: RuntimeSourceSummary,
#[serde(default)] #[serde(default)]
pub host_ids: Vec<String>, pub host_ids: Vec<String>,
pub capabilities: RuntimeCapabilitySummary, pub worker_creation_available: bool,
pub os: String,
pub arch: String,
#[serde(default)] #[serde(default)]
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
@@ -291,6 +357,116 @@ pub struct UpdateWorkspaceMemorySettingsRequest {
pub language: String, pub language: String,
} }
/// Public metadata for one Workspace-scoped Repository SSH credential.
///
/// Secret references and secret material are deliberately not part of this DTO.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepositorySshCredential {
pub credential_id: String,
pub workspace_id: String,
pub name: String,
pub public_key_algorithm: String,
pub public_key_fingerprint: String,
pub current_revision: u64,
pub status: String,
pub created_at: String,
pub rotated_at: Option<String>,
#[serde(default)]
pub referenced_repositories: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateRepositorySshCredentialRequest {
pub operation_id: String,
pub credential_id: String,
pub name: String,
pub private_key: String,
#[serde(default)]
pub passphrase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RotateRepositorySshCredentialRequest {
pub operation_id: String,
pub expected_revision: u64,
pub private_key: String,
#[serde(default)]
pub passphrase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DeleteRepositorySshCredentialRequest {
pub operation_id: String,
pub expected_revision: u64,
}
/// Public metadata for an explicitly pinned SSH host key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepositorySshHostTrust {
pub host_trust_id: String,
pub workspace_id: String,
pub hostname: String,
pub port: u16,
pub key_algorithm: String,
pub host_key: String,
pub fingerprint: String,
pub current_revision: u64,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub referenced_repositories: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PutRepositorySshHostTrustRequest {
pub operation_id: String,
pub host_trust_id: String,
pub hostname: String,
pub port: u16,
pub host_key: String,
#[serde(default)]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DeleteRepositorySshHostTrustRequest {
pub operation_id: String,
pub expected_revision: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RepositoryAccessMode {
ReadOnly,
ReadWrite,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepositorySshAccessBinding {
pub repository_id: String,
pub credential_id: String,
pub host_trust_id: String,
pub access: RepositoryAccessMode,
}
/// Secret-free active Repository access projection consumed by later Runtime work.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepositoryAccessProjection {
pub workspace_id: String,
pub config_revision: u64,
pub projection_digest: String,
pub bindings: Vec<RepositorySshAccessBinding>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+3
View File
@@ -25,11 +25,13 @@ manifest.workspace = true
protocol = { workspace = true } protocol = { workspace = true }
project-record.workspace = true project-record.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
ring.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
serde_yaml.workspace = true serde_yaml.workspace = true
sha2.workspace = true sha2.workspace = true
ssh-key.workspace = true
thiserror.workspace = true thiserror.workspace = true
ticket.workspace = true ticket.workspace = true
memory.workspace = true memory.workspace = true
@@ -46,6 +48,7 @@ tracing.workspace = true
ts-rs = { version = "12.0.1", optional = true } ts-rs = { version = "12.0.1", optional = true }
url.workspace = true url.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
zeroize.workspace = true
webauthn-rs = { workspace = true } webauthn-rs = { workspace = true }
[dev-dependencies] [dev-dependencies]
+22 -2
View File
@@ -176,8 +176,28 @@ fn actor_for_user<S: ControlPlaneStore + ?Sized>(
})) }))
} }
pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String { #[derive(Debug, Clone, Copy, PartialEq, Eq)]
format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax") pub struct SessionCookiePolicy<'a> {
pub cookie_name: &'a str,
pub path: &'a str,
pub domain: Option<&'a str>,
pub secure: bool,
}
pub fn session_set_cookie(
policy: SessionCookiePolicy<'_>,
token: &str,
max_age_seconds: i64,
) -> String {
let domain = policy
.domain
.map(|domain| format!("; Domain={domain}"))
.unwrap_or_default();
let secure = if policy.secure { "; Secure" } else { "" };
format!(
"{}={token}; Max-Age={max_age_seconds}; Path={}; HttpOnly; SameSite=Lax{domain}{secure}",
policy.cookie_name, policy.path
)
} }
pub fn auth_error(code: &str, message: &str) -> Error { pub fn auth_error(code: &str, message: &str) -> Error {
+77 -14
View File
@@ -704,6 +704,15 @@ impl SqliteWorkspaceAuthority {
&self, &self,
reference: &str, reference: &str,
request: TicketShowRequest, request: TicketShowRequest,
) -> Result<TicketDetail> {
self.read_ticket_detail_with_backend(reference, request, &self.ticket_backend)
}
pub(crate) fn read_ticket_detail_with_backend(
&self,
reference: &str,
request: TicketShowRequest,
backend: &SqliteTicketBackend,
) -> Result<TicketDetail> { ) -> Result<TicketDetail> {
let id = self let id = self
.store .store
@@ -713,16 +722,19 @@ impl SqliteWorkspaceAuthority {
reference, reference,
)? )?
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?; .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?;
let ticket = self.ticket_backend.show(TicketIdOrSlug::Id(id))?; let ticket = backend.show(TicketIdOrSlug::Id(id))?;
self.ticket_detail_from_ticket(ticket, request) self.ticket_detail_from_ticket(ticket, request, backend)
} }
fn ticket_detail_from_ticket( fn ticket_detail_from_ticket(
&self, &self,
ticket: ticket::Ticket, ticket: ticket::Ticket,
request: TicketShowRequest, request: TicketShowRequest,
dependency_backend: &SqliteTicketBackend,
) -> Result<TicketDetail> { ) -> Result<TicketDetail> {
let id = ticket.meta.id.as_str(); let id = ticket.meta.id.as_str();
let dependency_check =
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
let (body, body_truncated) = let (body, body_truncated) =
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
let event_limit = request let event_limit = request
@@ -822,6 +834,27 @@ impl SqliteWorkspaceAuthority {
.any(|assignment| assignment.role == TicketAssignmentRole::Coder); .any(|assignment| assignment.role == TicketAssignmentRole::Coder);
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some(); let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
let has_blockers = !ticket.relations.blockers.is_empty(); let has_blockers = !ticket.relations.blockers.is_empty();
let mut queue_assignment_blockers = Vec::new();
for ticket_id in &dependency_check.queue_tickets {
let assignments = self
.store
.list_current_ticket_role_assignments(&self.workspace_id, ticket_id)?;
if !assignments
.iter()
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator)
{
queue_assignment_blockers.push(format!(
"Ticket {ticket_id} requires an active Orchestrator assignment"
));
}
if assignments
.iter()
.any(|assignment| assignment.role == TicketAssignmentRole::Coder)
{
queue_assignment_blockers
.push(format!("Ticket {ticket_id} has an active Coder assignment"));
}
}
let mut assignment_diagnostics = Vec::new(); let mut assignment_diagnostics = Vec::new();
if let Some(legacy_assignee) = ticket if let Some(legacy_assignee) = ticket
.meta .meta
@@ -833,6 +866,19 @@ impl SqliteWorkspaceAuthority {
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority" "legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
)); ));
} }
let mut action_blockers = Vec::new();
if !has_target {
action_blockers.push("Ticket target is required".to_string());
}
if !dependency_check.queue_guard.can_queue_for_orchestrator {
if let Some(reason) = dependency_check.queue_guard.blocked_reason.clone() {
action_blockers.push(reason);
} else if let Some(reason) = dependency_check.queue_guard.reason.clone() {
action_blockers.push(reason);
}
}
let queue_assignments_valid = queue_assignment_blockers.is_empty();
action_blockers.extend(queue_assignment_blockers);
let action_eligibility = TicketActionEligibility { let action_eligibility = TicketActionEligibility {
can_assign_orchestrator: matches!( can_assign_orchestrator: matches!(
ticket.meta.workflow_state, ticket.meta.workflow_state,
@@ -848,19 +894,15 @@ impl SqliteWorkspaceAuthority {
&& has_orchestrator && has_orchestrator
&& !has_coder && !has_coder
&& has_target && has_target
&& !has_blockers, && dependency_check.queue_guard.can_queue_for_orchestrator
&& queue_assignments_valid,
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
&& !has_orchestrator && !has_orchestrator
&& !has_coder && !has_coder
&& has_target && has_target
&& !has_blockers, && !has_blockers,
blockers: [ queue_tickets: dependency_check.queue_tickets.clone(),
(!has_target).then_some("Ticket target is required".to_string()), blockers: action_blockers,
has_blockers.then_some("unresolved blocking relations remain".to_string()),
]
.into_iter()
.flatten()
.collect(),
}; };
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) { let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
Ok(request) => { Ok(request) => {
@@ -1084,6 +1126,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
event_limit: Some(TICKET_EVENT_LIMIT), event_limit: Some(TICKET_EVENT_LIMIT),
event_cursor: None, event_cursor: None,
}, },
&self.ticket_backend,
)?; )?;
if ticket_matches_query( if ticket_matches_query(
&summary, &summary,
@@ -2927,7 +2970,7 @@ mod tests {
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() { async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning"); write_ticket(dir.path(), "00000000001J5", "Second ticket", "queued");
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning"); write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
let db_path = dir.path().join("workspace.db"); let db_path = dir.path().join("workspace.db");
let store = SqliteWorkspaceStore::open(&db_path).unwrap(); let store = SqliteWorkspaceStore::open(&db_path).unwrap();
@@ -3038,10 +3081,22 @@ VALUES ('workspace-test', 'ticket', 4);
.ticket_backend .ticket_backend
.add_ticket_relation( .add_ticket_relation(
TicketIdOrSlug::Id("00000000001J2".to_string()), TicketIdOrSlug::Id("00000000001J2".to_string()),
ticket::NewTicketRelation {
kind: ticket::TicketRelationKind::DependsOn,
target: "00000000001J5".to_string(),
note: Some("queued dependency with a transitive blocker".to_string()),
author: Some("tester".to_string()),
},
)
.unwrap();
authority
.ticket_backend
.add_ticket_relation(
TicketIdOrSlug::Id("00000000001J5".to_string()),
ticket::NewTicketRelation { ticket::NewTicketRelation {
kind: ticket::TicketRelationKind::DependsOn, kind: ticket::TicketRelationKind::DependsOn,
target: "00000000001J6".to_string(), target: "00000000001J6".to_string(),
note: Some("separate dependency relation".to_string()), note: Some("transitive planning dependency".to_string()),
author: Some("tester".to_string()), author: Some("tester".to_string()),
}, },
) )
@@ -3056,6 +3111,14 @@ VALUES ('workspace-test', 'ticket', 4);
assert_eq!(ticket_by_key.id, tickets.items[0].id); assert_eq!(ticket_by_key.id, tickets.items[0].id);
let ticket = authority.ticket("00000000001J2").unwrap(); let ticket = authority.ticket("00000000001J2").unwrap();
assert!(!ticket.action_eligibility.can_queue);
assert!(
ticket
.action_eligibility
.blockers
.iter()
.any(|reason| reason.contains("00000000001J6"))
);
assert!(ticket.body.contains("Ticket body")); assert!(ticket.body.contains("Ticket body"));
assert!(ticket.body_truncated); assert!(ticket.body_truncated);
assert!(!ticket.body.contains("Deep Ticket marker")); assert!(!ticket.body.contains("Deep Ticket marker"));
@@ -3139,8 +3202,8 @@ VALUES ('workspace-test', 'ticket', 4);
assert!(note_only_kind.items.is_empty()); assert!(note_only_kind.items.is_empty());
let crossed_relation_filters = authority let crossed_relation_filters = authority
.query_tickets(TicketQueryRequest { .query_tickets(TicketQueryRequest {
related_ticket_id: Some("00000000001J5".to_string()), related_ticket_id: Some("00000000001J6".to_string()),
relation_kind: Some("depends_on".to_string()), relation_kind: Some("related".to_string()),
..TicketQueryRequest::default() ..TicketQueryRequest::default()
}) })
.unwrap(); .unwrap();
+213 -563
View File
@@ -3,53 +3,52 @@ use std::path::{Path, PathBuf};
use std::{fs, io}; use std::{fs, io};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url;
use crate::hosts::RemoteRuntimeConfig; use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity; use crate::identity::WorkspaceIdentity;
use crate::repositories::ConfiguredRepository;
use crate::server::{AuthConfig, ServerConfig}; use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result}; use crate::{Error, Result};
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml"; pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str = pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787";
const DEFAULT_AUTH_RP_ID: &str = "localhost";
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session"; const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
const DEFAULT_MAX_RECORDS: usize = 200; const DEFAULT_MAX_RECORDS: usize = 200;
fn default_auth_rp_id() -> String { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
DEFAULT_AUTH_RP_ID.to_string()
}
fn default_auth_origin() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
}
fn default_auth_public_base_url() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
}
fn default_auth_cookie_name() -> String {
DEFAULT_AUTH_COOKIE_NAME.to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile { pub struct ServerHostConfigFile {
#[serde(default)] #[serde(default)]
pub server: WorkspaceBackendServerConfig, pub browser: ServerBrowserConfig,
#[serde(default)] }
pub data: WorkspaceBackendDataConfig,
#[serde(default)] impl Default for ServerHostConfigFile {
pub limits: WorkspaceBackendLimitsConfig, fn default() -> Self {
#[serde(default)] Self {
pub auth: WorkspaceBackendAuthConfig, browser: ServerBrowserConfig::default(),
#[serde(default)] }
pub repositories: Vec<WorkspaceRepositoryConfigFile>, }
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ServerBrowserConfig {
#[serde(default = "default_browser_public_url")]
pub public_url: String,
}
impl Default for ServerBrowserConfig {
fn default() -> Self {
Self {
public_url: default_browser_public_url(),
}
}
}
fn default_browser_public_url() -> String {
DEFAULT_BROWSER_PUBLIC_URL.to_string()
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@@ -59,71 +58,6 @@ pub struct BackendRuntimesConfigFile {
pub runtimes: WorkspaceBackendRuntimesConfig, pub runtimes: WorkspaceBackendRuntimesConfig,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendServerConfig {
#[serde(default)]
pub listen: Option<String>,
#[serde(default)]
pub frontend_url: Option<String>,
#[serde(default)]
pub static_assets_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendDataConfig {
#[serde(default)]
pub root: Option<PathBuf>,
#[serde(default)]
pub workspace_database_path: Option<PathBuf>,
#[serde(default)]
pub embedded_runtime_store_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendLimitsConfig {
#[serde(default)]
pub max_records: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendAuthConfig {
#[serde(default = "default_auth_rp_id")]
pub rp_id: String,
#[serde(default = "default_auth_origin")]
pub origin: String,
#[serde(default = "default_auth_public_base_url")]
pub public_base_url: String,
#[serde(default = "default_auth_cookie_name")]
pub cookie_name: String,
}
impl Default for WorkspaceBackendAuthConfig {
fn default() -> Self {
Self {
rp_id: default_auth_rp_id(),
origin: default_auth_origin(),
public_base_url: default_auth_public_base_url(),
cookie_name: default_auth_cookie_name(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryConfigFile {
pub id: String,
pub provider: String,
pub uri: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub default_selector: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig { pub struct WorkspaceBackendRuntimesConfig {
@@ -142,61 +76,6 @@ pub struct RemoteRuntimeConfigFile {
pub token_ref: Option<String>, pub token_ref: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDiff {
pub differs: bool,
pub text: String,
}
impl ConfigDiff {
fn new(default: &str, local: &str) -> Self {
if default == local {
return Self {
differs: false,
text: "workspace backend local config matches the packaged default\n".to_string(),
};
}
let mut text = String::from("--- packaged default\n+++ workspace local\n");
let default_lines = default.lines().collect::<Vec<_>>();
let local_lines = local.lines().collect::<Vec<_>>();
let max = default_lines.len().max(local_lines.len());
for index in 0..max {
match (default_lines.get(index), local_lines.get(index)) {
(Some(left), Some(right)) if left == right => {
text.push(' ');
text.push_str(left);
text.push('\n');
}
(Some(left), Some(right)) => {
text.push('-');
text.push_str(left);
text.push('\n');
text.push('+');
text.push_str(right);
text.push('\n');
}
(Some(left), None) => {
text.push('-');
text.push_str(left);
text.push('\n');
}
(None, Some(right)) => {
text.push('+');
text.push_str(right);
text.push('\n');
}
(None, None) => {}
}
}
Self {
differs: true,
text,
}
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct ResolvedWorkspaceBackendConfig { pub struct ResolvedWorkspaceBackendConfig {
pub server: ServerConfig, pub server: ServerConfig,
@@ -204,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig {
pub database_path: PathBuf, pub database_path: PathBuf,
} }
impl ServerHostConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(SERVER_HOST_CONFIG_FILE_NAME)
}
pub fn default_path() -> Option<PathBuf> {
manifest::paths::config_dir().map(Self::path_for_config_dir)
}
pub fn load_default() -> Result<Self> {
let Some(path) = Self::default_path() else {
return Ok(Self::default());
};
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let raw = fs::read_to_string(path).map_err(|error| {
Error::Config(format!(
"failed to read Server host config `{}`: {error}",
path.display()
))
})?;
Self::parse_str(&raw, path)
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse Server host config `{}`: {error}",
path.as_ref().display()
))
})
}
}
impl BackendRuntimesConfigFile { impl BackendRuntimesConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf { pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME) config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
@@ -272,151 +192,22 @@ impl BackendRuntimesConfigFile {
} }
} }
impl WorkspaceBackendConfigFile { impl ResolvedWorkspaceBackendConfig {
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf { pub fn local_dev(
workspace_root
.as_ref()
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
}
pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::path_for_workspace(workspace_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write;
file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?;
file.sync_all()?;
Ok(())
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn local_config_diff_for_workspace(workspace_root: impl AsRef<Path>) -> Result<ConfigDiff> {
let workspace_root = workspace_root.as_ref();
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
"workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first",
path.display(),
workspace_root.display()
))),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn write_for_workspace(&self, workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::path_for_workspace(workspace_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(self).map_err(|error| {
Error::Config(format!(
"failed to serialize workspace backend config: {error}"
))
})?;
fs::write(path, raw)?;
Ok(())
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse workspace backend config `{}`: {error}",
path.as_ref().display()
))
})
}
pub fn resolve(
&self,
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
) -> Result<ResolvedWorkspaceBackendConfig> {
self.resolve_with_runtime_config(
workspace_root,
identity,
&BackendRuntimesConfigFile::default(),
)
}
pub fn resolve_with_runtime_config(
&self,
workspace_root: impl AsRef<Path>, workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity, identity: WorkspaceIdentity,
host_config: &ServerHostConfigFile,
runtime_config: &BackendRuntimesConfigFile, runtime_config: &BackendRuntimesConfigFile,
) -> Result<ResolvedWorkspaceBackendConfig> { ) -> Result<Self> {
let workspace_root = workspace_root.as_ref(); let workspace_root = workspace_root.as_ref();
let data_root = self let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
.data let database_path = ServerConfig::default_server_database_path();
.root let (browser_public_url, browser_rp_id) =
.as_ref() resolve_browser_public_url(&host_config.browser.public_url)?;
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| {
ServerConfig::default_workspace_backend_data_root(&identity.workspace_id)
});
let database_path = self
.data
.workspace_database_path
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(ServerConfig::default_server_database_path);
let embedded_runtime_store_root = self
.data
.embedded_runtime_store_root
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| data_root.join("embedded-runtime"));
let listen = self
.server
.listen
.as_deref()
.unwrap_or(DEFAULT_LISTEN)
.parse::<SocketAddr>()
.map_err(|_| {
Error::Config(format!(
"invalid workspace backend server.listen `{}`",
self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN)
))
})?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
server.database_path = database_path.clone(); server.database_path = database_path.clone();
server.frontend_url = self server.embedded_runtime_store_root = data_root.join("embedded-runtime");
.server server.max_records = DEFAULT_MAX_RECORDS;
.frontend_url
.clone()
.unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string());
server.static_assets_dir = self
.server
.static_assets_dir
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path));
server.embedded_runtime_store_root = embedded_runtime_store_root;
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
server.repositories = self
.repositories
.iter()
.map(|repository| resolve_repository(workspace_root, repository))
.collect::<Result<Vec<_>>>()?;
server.remote_runtime_sources = runtime_config server.remote_runtime_sources = runtime_config
.runtimes .runtimes
.remote .remote
@@ -424,13 +215,16 @@ impl WorkspaceBackendConfigFile {
.map(resolve_remote_runtime) .map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
server.auth = AuthConfig::Passkey { server.auth = AuthConfig::Passkey {
rp_id: self.auth.rp_id.trim().to_string(), rp_id: browser_rp_id,
origin: self.auth.origin.trim().to_string(), origin: browser_public_url.clone(),
public_base_url: self.auth.public_base_url.trim().to_string(), public_base_url: browser_public_url,
cookie_name: self.auth.cookie_name.trim().to_string(), cookie_name: DEFAULT_AUTH_COOKIE_NAME.to_string(),
}; };
let listen = DEFAULT_LISTEN.parse::<SocketAddr>().map_err(|error| {
Error::Config(format!("invalid built-in Server listen address: {error}"))
})?;
Ok(ResolvedWorkspaceBackendConfig { Ok(Self {
server, server,
listen, listen,
database_path, database_path,
@@ -439,18 +233,6 @@ impl WorkspaceBackendConfigFile {
} }
impl ResolvedWorkspaceBackendConfig { impl ResolvedWorkspaceBackendConfig {
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
let path = path.into();
self.database_path = path.clone();
self.server.database_path = path;
self
}
pub fn with_static_assets_dir(mut self, path: Option<PathBuf>) -> Self {
self.server.static_assets_dir = path;
self
}
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self { pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string()); self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
self self
@@ -462,29 +244,6 @@ impl ResolvedWorkspaceBackendConfig {
} }
} }
fn resolve_repository(
workspace_root: &Path,
config: &WorkspaceRepositoryConfigFile,
) -> Result<ConfiguredRepository> {
let id = normalize_required_string("repository id", &config.id)?;
validate_repository_id(&id)?;
let provider =
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
let uri = normalize_required_string("repository uri", &config.uri)?;
let path = resolve_repository_uri(workspace_root, &id, &uri)?;
let display_name = normalize_optional_string(config.display_name.as_deref());
let default_selector = normalize_optional_string(config.default_selector.as_deref());
Ok(ConfiguredRepository {
id,
provider,
uri,
path,
display_name,
default_selector,
})
}
fn normalize_required_string(field: &str, value: &str) -> Result<String> { fn normalize_required_string(field: &str, value: &str) -> Result<String> {
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -493,45 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result<String> {
Ok(trimmed.to_string()) Ok(trimmed.to_string())
} }
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn validate_repository_id(id: &str) -> Result<()> {
if id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
Ok(())
} else {
Err(Error::Config(format!(
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
)))
}
}
fn resolve_repository_uri(workspace_root: &Path, id: &str, uri: &str) -> Result<PathBuf> {
if uri.contains("://") {
return Err(Error::Config(format!(
"repository `{id}` uses a remote URI, but remote repository materialization is not implemented"
)));
}
Ok(resolve_workspace_path(workspace_root, Path::new(uri)))
}
pub(crate) fn resolve_remote_runtime( pub(crate) fn resolve_remote_runtime(
config: &RemoteRuntimeConfigFile, config: &RemoteRuntimeConfigFile,
) -> Result<RemoteRuntimeConfig> { ) -> Result<RemoteRuntimeConfig> {
if let Some(token_ref) = config.token_ref.as_deref() { if let Some(token_ref) = config.token_ref.as_deref() {
return Err(Error::Config(format!( return Err(Error::Config(format!(
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet", "remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet",
config.id config.id
))); )));
} }
@@ -546,12 +272,34 @@ pub(crate) fn resolve_remote_runtime(
)) ))
} }
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf { fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
if path.is_absolute() { let value = normalize_required_string("browser.public_url", value)?;
path.to_path_buf() let url = Url::parse(&value).map_err(|error| {
} else { Error::Config(format!(
workspace_root.join(path) "browser.public_url must be an absolute http(s) URL: {error}"
))
})?;
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::Config(
"browser.public_url must use the http or https scheme".to_string(),
));
} }
if !url.username().is_empty() || url.password().is_some() {
return Err(Error::Config(
"browser.public_url must not contain user information".to_string(),
));
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return Err(Error::Config(
"browser.public_url must contain only an origin without a path, query, or fragment"
.to_string(),
));
}
let rp_id = url
.host_str()
.ok_or_else(|| Error::Config("browser.public_url must contain a host".to_string()))?
.to_string();
Ok((url.origin().ascii_serialization(), rp_id))
} }
#[cfg(test)] #[cfg(test)]
@@ -566,14 +314,33 @@ mod tests {
} }
} }
#[test] fn resolved_with_runtimes(
fn missing_config_path_uses_defaults() { runtimes: &BackendRuntimesConfigFile,
) -> ResolvedWorkspaceBackendConfig {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); ResolvedWorkspaceBackendConfig::local_dev(
let resolved = config.resolve(dir.path(), identity()).unwrap(); dir.path(),
identity(),
&ServerHostConfigFile::default(),
runtimes,
)
.unwrap()
}
#[test]
fn default_settings_resolve_without_a_repository_file() {
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default());
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap()); assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL); let AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "localhost");
assert_eq!(origin, DEFAULT_BROWSER_PUBLIC_URL);
assert_eq!(public_base_url, DEFAULT_BROWSER_PUBLIC_URL);
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS); assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
assert!(resolved.database_path.ends_with("server.db")); assert!(resolved.database_path.ends_with("server.db"));
assert!( assert!(
@@ -586,12 +353,8 @@ mod tests {
#[test] #[test]
fn backend_base_url_is_explicit_and_normalized() { fn backend_base_url_is_explicit_and_normalized() {
let dir = tempfile::tempdir().unwrap();
let listen = "127.0.0.1:48787".parse().unwrap(); let listen = "127.0.0.1:48787".parse().unwrap();
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path()) let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
.unwrap()
.resolve(dir.path(), identity())
.unwrap()
.with_listen(listen) .with_listen(listen)
.with_backend_base_url("http://127.0.0.1:48787/"); .with_backend_base_url("http://127.0.0.1:48787/");
@@ -603,172 +366,82 @@ mod tests {
} }
#[test] #[test]
fn rejects_unknown_fields() { fn browser_public_url_from_host_config_drives_all_browser_auth_settings() {
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test") let host_config = ServerHostConfigFile::parse_str(
.unwrap_err(); "[browser]\npublic_url = \"https://Yoi.Example:443/\"\n",
assert!( "server.toml",
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
#[test]
fn resolves_relative_paths_against_workspace_root() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[server]
static_assets_dir = "web/build"
[data]
root = ".yoi/backend-data"
workspace_database_path = ".yoi/custom.db"
embedded_runtime_store_root = ".yoi/runtime-store"
"#,
"test",
) )
.unwrap(); .unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap(); let resolved = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
assert_eq!( identity(),
resolved.server.static_assets_dir, &host_config,
Some(dir.path().join("web/build")) &BackendRuntimesConfigFile::default(),
);
assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".yoi/runtime-store")
);
}
#[test]
fn absolute_paths_are_preserved() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
workspace_database_path = "/tmp/yoi-workspace.db"
embedded_runtime_store_root = "/tmp/yoi-runtime"
"#,
"test",
) )
.unwrap(); .unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!( let AuthConfig::Passkey {
resolved.database_path, rp_id,
PathBuf::from("/tmp/yoi-workspace.db") origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "yoi.example");
assert_eq!(origin, "https://yoi.example");
assert_eq!(public_base_url, "https://yoi.example");
}
#[test]
fn browser_public_url_rejects_non_origin_urls() {
for value in [
"https://example.test/path",
"https://example.test?query=true",
"file:///tmp/web",
] {
let host_config = ServerHostConfigFile {
browser: ServerBrowserConfig {
public_url: value.to_string(),
},
};
let result = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
); );
assert_eq!( let error = match result {
resolved.server.embedded_runtime_store_root, Ok(_) => panic!("expected {value} to be rejected"),
PathBuf::from("/tmp/yoi-runtime")
);
}
#[test]
fn data_root_derives_runtime_store_path_only() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
root = ".local-data"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert!(resolved.database_path.ends_with("server.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".local-data/embedded-runtime")
);
}
#[test]
fn copies_local_config_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path());
let raw = fs::read_to_string(&path).unwrap();
assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE);
WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap();
fs::write(&path, "# custom local config\n").unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"# custom local config\n"
);
}
#[test]
fn local_config_diff_reports_match_and_difference() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let matched =
WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(!matched.differs);
fs::write(
WorkspaceBackendConfigFile::path_for_workspace(dir.path()),
"[server]\nlisten = \"127.0.0.1:9999\"\n",
)
.unwrap();
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(diff.differs);
assert!(diff.text.contains("+++ workspace local"));
assert!(diff.text.contains("127.0.0.1:9999"));
}
#[test]
fn resolves_repository_uri_relative_to_workspace_root() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
provider = "git"
uri = "."
display_name = "Main"
default_selector = "HEAD"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
let repository = resolved.server.repositories.first().unwrap();
assert_eq!(repository.id, "main");
assert_eq!(repository.provider, "git");
assert_eq!(repository.path, dir.path());
assert_eq!(repository.display_name.as_deref(), Some("Main"));
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
}
#[test]
fn remote_repository_uri_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
provider = "git"
uri = "https://example.com/org/repo.git"
"#,
"test",
)
.unwrap();
let error = match config.resolve(dir.path(), identity()) {
Ok(_) => panic!("remote repository URI should fail closed"),
Err(error) => error, Err(error) => error,
}; };
assert!(
error.to_string().contains("browser.public_url"),
"unexpected error for {value}: {error}"
);
}
}
#[test]
fn server_host_config_loads_only_from_the_explicit_host_path() {
let dir = tempfile::tempdir().unwrap();
let path = ServerHostConfigFile::path_for_config_dir(dir.path());
fs::write(
&path,
"[browser]\npublic_url = \"https://deploy.example.test\"\n",
)
.unwrap();
let loaded = ServerHostConfigFile::load_from_path(&path).unwrap();
assert_eq!(loaded.browser.public_url, "https://deploy.example.test");
assert_eq!(path, dir.path().join("server.toml"));
}
#[test]
fn explicit_missing_server_host_config_fails_closed() {
let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err();
assert!( assert!(
error error
.to_string() .to_string()
.contains("remote repository materialization is not implemented"), .contains("failed to read Server host config")
"unexpected error: {error}"
); );
} }
@@ -794,28 +467,8 @@ uri = "https://example.com/org/repo.git"
); );
} }
#[test]
fn workspace_backend_config_rejects_runtime_entries() {
let error = WorkspaceBackendConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "arc"
endpoint = "http://legacy.example.test"
display_name = "legacy arc"
"#,
"test",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field `runtimes`"),
"unexpected error: {error}"
);
}
#[test] #[test]
fn backend_runtimes_config_is_the_only_runtime_source() { fn backend_runtimes_config_is_the_only_runtime_source() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str( let runtime_config = BackendRuntimesConfigFile::parse_str(
r#" r#"
[[runtimes.remote]] [[runtimes.remote]]
@@ -826,9 +479,7 @@ display_name = "xdg arc"
"runtimes.toml", "runtimes.toml",
) )
.unwrap(); .unwrap();
let resolved = workspace_config let resolved = resolved_with_runtimes(&runtime_config);
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
.unwrap();
assert_eq!(resolved.server.remote_runtime_sources.len(), 1); assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc"); assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
assert_eq!( assert_eq!(
@@ -857,8 +508,6 @@ token = "secret"
#[test] #[test]
fn token_ref_fails_closed_until_secret_resolution_exists() { fn token_ref_fails_closed_until_secret_resolution_exists() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str( let runtime_config = BackendRuntimesConfigFile::parse_str(
r#" r#"
[[runtimes.remote]] [[runtimes.remote]]
@@ -869,9 +518,10 @@ token_ref = "local:remote-token"
"runtimes.toml", "runtimes.toml",
) )
.unwrap(); .unwrap();
let error = match workspace_config.resolve_with_runtime_config( let error = match ResolvedWorkspaceBackendConfig::local_dev(
dir.path(), tempfile::tempdir().unwrap().path(),
identity(), identity(),
&ServerHostConfigFile::default(),
&runtime_config, &runtime_config,
) { ) {
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"), Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
+94 -140
View File
@@ -25,8 +25,9 @@ use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{ use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef, ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail, ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest, WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectorySummary, WorkspaceApiRef,
}; };
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary}; use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
#[cfg(test)] #[cfg(test)]
@@ -39,11 +40,11 @@ use worker_runtime::execution::WorkerExecutionRunState;
use worker_runtime::fs_store::FsRuntimeStoreOptions; use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{ use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest, RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest, RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse,
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse,
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse,
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse, RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse, RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
}; };
@@ -177,26 +178,6 @@ impl RuntimeSourceSummary {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeCapabilitySummary {
pub can_list_hosts: bool,
pub can_list_workers: bool,
pub can_get_worker: bool,
pub can_spawn_worker: bool,
pub can_stop_worker: bool,
pub has_workspace_fs: bool,
pub has_shell: bool,
pub has_git: bool,
pub supports_worktrees: bool,
pub supports_backend_internal_tools: bool,
pub workspace_scope: String,
pub max_workers: usize,
pub os: String,
pub arch: String,
}
pub type HostCapabilitySummary = RuntimeCapabilitySummary;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeSummary { pub struct RuntimeSummary {
pub runtime_id: String, pub runtime_id: String,
@@ -205,7 +186,9 @@ pub struct RuntimeSummary {
pub status: String, pub status: String,
pub source: RuntimeSourceSummary, pub source: RuntimeSourceSummary,
pub host_ids: Vec<String>, pub host_ids: Vec<String>,
pub capabilities: RuntimeCapabilitySummary, pub worker_creation_available: bool,
pub os: String,
pub arch: String,
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
@@ -218,7 +201,8 @@ pub struct HostSummary {
pub status: String, pub status: String,
pub observed_at: String, pub observed_at: String,
pub last_seen_at: Option<String>, pub last_seen_at: Option<String>,
pub capabilities: HostCapabilitySummary, pub os: String,
pub arch: String,
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
@@ -310,27 +294,6 @@ impl From<RuntimeSourceSummary> for workspace_api::RuntimeSourceSummary {
} }
} }
impl From<RuntimeCapabilitySummary> for workspace_api::RuntimeCapabilitySummary {
fn from(capabilities: RuntimeCapabilitySummary) -> Self {
Self {
can_list_hosts: capabilities.can_list_hosts,
can_list_workers: capabilities.can_list_workers,
can_get_worker: capabilities.can_get_worker,
can_spawn_worker: capabilities.can_spawn_worker,
can_stop_worker: capabilities.can_stop_worker,
has_workspace_fs: capabilities.has_workspace_fs,
has_shell: capabilities.has_shell,
has_git: capabilities.has_git,
supports_worktrees: capabilities.supports_worktrees,
supports_backend_internal_tools: capabilities.supports_backend_internal_tools,
workspace_scope: capabilities.workspace_scope,
max_workers: capabilities.max_workers,
os: capabilities.os,
arch: capabilities.arch,
}
}
}
impl From<RuntimeSummary> for workspace_api::RuntimeSummary { impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
fn from(runtime: RuntimeSummary) -> Self { fn from(runtime: RuntimeSummary) -> Self {
Self { Self {
@@ -340,7 +303,9 @@ impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
status: runtime.status, status: runtime.status,
source: runtime.source.into(), source: runtime.source.into(),
host_ids: runtime.host_ids, host_ids: runtime.host_ids,
capabilities: runtime.capabilities.into(), worker_creation_available: runtime.worker_creation_available,
os: runtime.os,
arch: runtime.arch,
diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(), diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(),
} }
} }
@@ -854,6 +819,16 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
} }
} }
fn authorize_working_directory_repository_access(
&self,
_request: WorkingDirectoryRepositoryAccessRequest,
) -> std::result::Result<(), Error> {
Err(Error::InvalidInput(
"Runtime does not support working directory Repository access authorization"
.to_string(),
))
}
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> { fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
RuntimeList::new(Vec::new(), Vec::new()) RuntimeList::new(Vec::new(), Vec::new())
} }
@@ -1427,6 +1402,23 @@ impl RuntimeRegistry {
Ok(runtime.create_working_directory(request)) Ok(runtime.create_working_directory(request))
} }
pub fn authorize_working_directory_repository_access(
&self,
runtime_id: &str,
request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
validate_backend_identifier("working_directory_id", &request.working_directory_id)?;
let runtime = self.runtime(runtime_id)?;
runtime
.authorize_working_directory_repository_access(request)
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "working_directory_repository_access_failed".to_string(),
message: error.to_string(),
})
}
pub fn list_working_directories( pub fn list_working_directories(
&self, &self,
runtime_id: &str, runtime_id: &str,
@@ -1890,7 +1882,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "unavailable".to_string(), status: "unavailable".to_string(),
source: RuntimeSourceSummary::embedded_worker_runtime(), source: RuntimeSourceSummary::embedded_worker_runtime(),
host_ids: Vec::new(), host_ids: Vec::new(),
capabilities: embedded_runtime_capabilities(limit, false, false), worker_creation_available: false,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
diagnostics, diagnostics,
}; };
} }
@@ -1910,7 +1904,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
} else { } else {
vec![self.host_id.clone()] vec![self.host_id.clone()]
}, },
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled), worker_creation_available: true,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
diagnostics, diagnostics,
} }
} }
@@ -1928,7 +1924,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "available".to_string(), status: "available".to_string(),
observed_at: Utc::now().to_rfc3339(), observed_at: Utc::now().to_rfc3339(),
last_seen_at: None, last_seen_at: None,
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled), os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
diagnostics: vec![diagnostic( diagnostics: vec![diagnostic(
"embedded_runtime_host_boundary", "embedded_runtime_host_boundary",
DiagnosticSeverity::Info, DiagnosticSeverity::Info,
@@ -2576,7 +2573,9 @@ pub struct RemoteRuntimeConfig {
pub base_url: String, pub base_url: String,
pub bearer_token: Option<String>, pub bearer_token: Option<String>,
pub auth: Option<RemoteRuntimeAuthConfig>, pub auth: Option<RemoteRuntimeAuthConfig>,
pub cached_capabilities: RuntimeCapabilitySummary, pub cached_worker_creation_available: bool,
pub cached_os: String,
pub cached_arch: String,
pub cached_status: String, pub cached_status: String,
pub timeout: Duration, pub timeout: Duration,
} }
@@ -2598,7 +2597,12 @@ impl std::fmt::Debug for RemoteRuntimeConfig {
&self.bearer_token.as_ref().map(|_| "<redacted>"), &self.bearer_token.as_ref().map(|_| "<redacted>"),
) )
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>")) .field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
.field("cached_capabilities", &self.cached_capabilities) .field(
"cached_worker_creation_available",
&self.cached_worker_creation_available,
)
.field("cached_os", &self.cached_os)
.field("cached_arch", &self.cached_arch)
.field("cached_status", &self.cached_status) .field("cached_status", &self.cached_status)
.field("timeout", &self.timeout) .field("timeout", &self.timeout)
.finish() .finish()
@@ -2619,9 +2623,9 @@ impl RemoteRuntimeConfig {
base_url: base_url.into(), base_url: base_url.into(),
bearer_token, bearer_token,
auth: None, auth: None,
cached_capabilities: remote_runtime_capabilities( cached_worker_creation_available: false,
200, false, false, "unknown", "unknown", cached_os: "unknown".to_string(),
), cached_arch: "unknown".to_string(),
cached_status: "configured".to_string(), cached_status: "configured".to_string(),
timeout: Duration::from_secs(10), timeout: Duration::from_secs(10),
} }
@@ -2632,11 +2636,6 @@ impl RemoteRuntimeConfig {
self self
} }
pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self {
self.cached_capabilities = capabilities;
self
}
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self { pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
self.auth = Some(auth); self.auth = Some(auth);
self self
@@ -2708,7 +2707,9 @@ pub struct RemoteWorkerRuntime {
workspace_id: String, workspace_id: String,
bearer_token: Option<String>, bearer_token: Option<String>,
auth: Option<RemoteRuntimeAuthConfig>, auth: Option<RemoteRuntimeAuthConfig>,
cached_capabilities: RuntimeCapabilitySummary, cached_worker_creation_available: bool,
cached_os: String,
cached_arch: String,
cached_status: String, cached_status: String,
host_id: String, host_id: String,
resource_broker: BackendResourceBroker, resource_broker: BackendResourceBroker,
@@ -2768,7 +2769,9 @@ impl RemoteWorkerRuntime {
workspace_id, workspace_id,
bearer_token: config.bearer_token, bearer_token: config.bearer_token,
auth: config.auth, auth: config.auth,
cached_capabilities: config.cached_capabilities, cached_worker_creation_available: config.cached_worker_creation_available,
cached_os: config.cached_os,
cached_arch: config.cached_arch,
cached_status: config.cached_status, cached_status: config.cached_status,
resource_broker: BackendResourceBroker::default(), resource_broker: BackendResourceBroker::default(),
http, http,
@@ -3045,13 +3048,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} else { } else {
vec![self.host_id.clone()] vec![self.host_id.clone()]
}, },
capabilities: remote_runtime_capabilities( worker_creation_available: response.runtime.worker_creation_available,
limit, os: response.runtime.os,
true, arch: response.runtime.arch,
response.runtime.worker_creation_available,
response.runtime.os,
response.runtime.arch,
),
diagnostics: Vec::new(), diagnostics: Vec::new(),
}, },
Err(diagnostic) => RuntimeSummary { Err(diagnostic) => RuntimeSummary {
@@ -3065,7 +3064,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} else { } else {
vec![self.host_id.clone()] vec![self.host_id.clone()]
}, },
capabilities: self.cached_capabilities.clone(), worker_creation_available: self.cached_worker_creation_available,
os: self.cached_os.clone(),
arch: self.cached_arch.clone(),
diagnostics: vec![diagnostic], diagnostics: vec![diagnostic],
}, },
} }
@@ -3084,7 +3085,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
status: "configured".to_string(), status: "configured".to_string(),
observed_at: Utc::now().to_rfc3339(), observed_at: Utc::now().to_rfc3339(),
last_seen_at: None, last_seen_at: None,
capabilities: remote_runtime_capabilities(limit, true, false, "unknown", "unknown"), os: self.cached_os.clone(),
arch: self.cached_arch.clone(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
}], }],
Vec::new(), Vec::new(),
@@ -3205,6 +3207,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} }
} }
fn authorize_working_directory_repository_access(
&self,
request: WorkingDirectoryRepositoryAccessRequest,
) -> std::result::Result<(), Error> {
self.post_json::<_, RuntimeHttpRepositoryAccessResponse>(
"/v1/working-directories/repository-access",
&request,
)
.map(|_| ())
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
}
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> { fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") { match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()), Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
@@ -3553,29 +3567,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} }
} }
fn embedded_runtime_capabilities(
limit: usize,
available: bool,
execution_enabled: bool,
) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: available,
can_get_worker: available,
can_spawn_worker: available,
can_stop_worker: available && execution_enabled,
has_workspace_fs: false,
has_shell: false,
has_git: false,
supports_worktrees: false,
supports_backend_internal_tools: true,
workspace_scope: "backend_internal".to_string(),
max_workers: limit,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
}
}
fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str { fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str {
match status { match status {
RuntimeStatus::Running => "running", RuntimeStatus::Running => "running",
@@ -4122,31 +4113,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
encoded encoded
} }
fn remote_runtime_capabilities(
limit: usize,
available: bool,
worker_creation_available: bool,
os: impl Into<String>,
arch: impl Into<String>,
) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: available,
can_get_worker: available,
can_spawn_worker: available && worker_creation_available,
can_stop_worker: available,
has_workspace_fs: false,
has_shell: false,
has_git: false,
supports_worktrees: false,
supports_backend_internal_tools: false,
workspace_scope: "remote_runtime_backend_private".to_string(),
max_workers: limit,
os: os.into(),
arch: arch.into(),
}
}
fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic { fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic {
if err.is_timeout() { if err.is_timeout() {
diagnostic( diagnostic(
@@ -4460,7 +4426,7 @@ mod tests {
let handle = bundle.profile_source_archive_handle.as_ref().unwrap(); let handle = bundle.profile_source_archive_handle.as_ref().unwrap();
assert!(bundle.profile_source_archive.is_none()); assert!(bundle.profile_source_archive.is_none());
let response = broker let response = broker
.fetch_profile_source_archive(worker_runtime::resource::BackendResourceFetchRequest { .fetch_resource(worker_runtime::resource::BackendResourceFetchRequest {
handle: handle.clone(), handle: handle.clone(),
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
worker_id: None, worker_id: None,
@@ -4800,22 +4766,9 @@ mod tests {
status: "available".to_string(), status: "available".to_string(),
source: RuntimeSourceSummary::embedded_worker_runtime_reserved(), source: RuntimeSourceSummary::embedded_worker_runtime_reserved(),
host_ids: vec![self.host_id.clone()], host_ids: vec![self.host_id.clone()],
capabilities: RuntimeCapabilitySummary { worker_creation_available: false,
can_list_hosts: true,
can_list_workers: true,
can_get_worker: true,
can_spawn_worker: false,
can_stop_worker: false,
has_workspace_fs: false,
has_shell: false,
has_git: false,
supports_worktrees: false,
supports_backend_internal_tools: false,
workspace_scope: "none".to_string(),
max_workers: self.workers.len(),
os: "test".to_string(), os: "test".to_string(),
arch: "test".to_string(), arch: "test".to_string(),
},
diagnostics: Vec::new(), diagnostics: Vec::new(),
} }
} }
@@ -4830,7 +4783,8 @@ mod tests {
status: "available".to_string(), status: "available".to_string(),
observed_at: "unknown".to_string(), observed_at: "unknown".to_string(),
last_seen_at: None, last_seen_at: None,
capabilities: self.runtime_summary(1).capabilities, os: "test".to_string(),
arch: "test".to_string(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
}], }],
Vec::new(), Vec::new(),
@@ -5234,7 +5188,7 @@ mod tests {
RuntimeSourceKind::EmbeddedWorkerRuntime RuntimeSourceKind::EmbeddedWorkerRuntime
); );
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active); assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
assert!(embedded_summary.capabilities.can_spawn_worker); assert!(embedded_summary.worker_creation_available);
let spawned = registry let spawned = registry
.spawn_worker( .spawn_worker(
+2 -3
View File
@@ -46,8 +46,7 @@ impl WorkspaceIdentity {
Ok(raw) => Self::parse_str(&raw, &path), Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => { Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!( Err(Error::WorkspaceIdentity(format!(
"workspace is not initialized at {}; run `yoi-server init --workspace {}` first", "workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
workspace_root.as_ref().display(),
workspace_root.as_ref().display() workspace_root.as_ref().display()
))) )))
} }
@@ -219,7 +218,7 @@ mod tests {
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err(); let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
assert!( assert!(
error.to_string().contains("workspace is not initialized"), error.to_string().contains("workspace identity is missing"),
"unexpected error: {error}" "unexpected error: {error}"
); );
assert!(!WorkspaceIdentity::path(&workspace_root).exists()); assert!(!WorkspaceIdentity::path(&workspace_root).exists());
+7 -5
View File
@@ -20,12 +20,16 @@ pub mod records;
#[cfg(feature = "typescript")] #[cfg(feature = "typescript")]
pub use records::ticket_api_typescript; pub use records::ticket_api_typescript;
pub mod repositories; pub mod repositories;
pub mod repository_access;
pub mod repository_source;
pub mod resource_broker; pub mod resource_broker;
pub mod retention; pub mod retention;
pub mod runtime_settings;
pub mod runtime_subscription; pub mod runtime_subscription;
pub mod server; pub mod server;
pub mod skills; pub mod skills;
pub mod store; pub mod store;
pub mod workdir_create_operations;
pub mod worker_source; pub mod worker_source;
pub mod workspace_catalog; pub mod workspace_catalog;
mod workspace_subscription; mod workspace_subscription;
@@ -35,11 +39,7 @@ pub use authority::{
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
WorkspaceAuthority, WorkspaceAuthority,
}; };
pub use config::{ pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile,
};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{ pub use repositories::{
@@ -113,6 +113,8 @@ pub enum Error {
TicketAssignmentConflict(String), TicketAssignmentConflict(String),
#[error("Workdir attachment conflict: {0}")] #[error("Workdir attachment conflict: {0}")]
WorkdirAttachmentConflict(String), WorkdirAttachmentConflict(String),
#[error("Workspace permission denied: {0}")]
WorkspacePermissionDenied(String),
#[error("Workspace config update conflict: {0}")] #[error("Workspace config update conflict: {0}")]
WorkspaceConfigConflict(String), WorkspaceConfigConflict(String),
#[error("Registry inconsistency: {0}")] #[error("Registry inconsistency: {0}")]
+48 -240
View File
@@ -11,17 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord}; use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
use yoi_workspace_server::{ use yoi_workspace_server::{
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig, BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService, ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
}; };
#[derive(Debug)] #[derive(Debug)]
enum Command { enum Command {
Serve(ServeOptions), Serve(ServeOptions),
Init(InitOptions),
ConfigDefault,
ConfigDiff(WorkspacePathOptions),
Identity(Vec<String>), Identity(Vec<String>),
TrustRuntime(Vec<String>), TrustRuntime(Vec<String>),
MigrateDryRun { database: Option<PathBuf> }, MigrateDryRun { database: Option<PathBuf> },
@@ -32,16 +28,7 @@ enum Command {
#[derive(Debug)] #[derive(Debug)]
struct ServeOptions { struct ServeOptions {
listen: Option<SocketAddr>, listen: Option<SocketAddr>,
} config: Option<PathBuf>,
#[derive(Debug)]
struct InitOptions {
workspace: PathBuf,
}
#[derive(Debug)]
struct WorkspacePathOptions {
workspace: PathBuf,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -82,9 +69,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>(); let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? { match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await, Command::Serve(options) => run_serve(options).await,
Command::Init(options) => run_init(options).await,
Command::ConfigDefault => run_config_default(),
Command::ConfigDiff(options) => run_config_diff(options),
Command::Identity(args) => run_identity_command(args), Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args), Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::MigrateDryRun { database } => { Command::MigrateDryRun { database } => {
@@ -110,14 +94,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
}; };
match command.as_str() { match command.as_str() {
"init" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_init_help();
return Ok(Command::Help);
}
Ok(Command::Init(parse_init_options(rest)?))
}
"config" => parse_config_command(rest),
"identity" => Ok(Command::Identity(rest.to_vec())), "identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())), "trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_command(rest), "migrate" => parse_migrate_command(rest),
@@ -134,61 +110,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help) Ok(Command::Help)
} }
other => Err(CliError(format!( other => Err(CliError(format!(
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`" "unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
))), ))),
} }
} }
async fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
run_init_with_database_path(options, ServerConfig::default_server_database_path()).await
}
async fn run_init_with_database_path(
options: InitOptions,
database_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
if let Some(parent) = database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let service = WorkspaceCatalogService::new(store);
service.create_with_workspace_id(
WorkspaceCreateRequest {
operation_key: format!("cli-init:{}", identity.workspace_id),
display_name: identity.display_name.clone(),
repository: InitialRepositoryIntent {
uri: options.workspace.display().to_string(),
display_name: Some("Main repository".to_string()),
default_ref: Some("HEAD".to_string()),
},
},
None,
Some(identity.workspace_id.clone()),
)?;
eprintln!(
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
options.workspace.display(),
identity.workspace_id,
database_path.display()
);
Ok(())
}
fn run_config_default() -> Result<(), Box<dyn std::error::Error>> {
print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}");
Ok(())
}
fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::error::Error>> {
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?;
print!("{}", diff.text);
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct ServerIdentityFile { struct ServerIdentityFile {
identity: RuntimeIdentityMaterial, identity: RuntimeIdentityMaterial,
@@ -626,10 +552,15 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
.to_path_buf(), .to_path_buf(),
) )
}; };
let host_config = match options.config.as_ref() {
Some(path) => ServerHostConfigFile::load_from_path(path)?,
None => ServerHostConfigFile::load_default()?,
};
let runtime_config = BackendRuntimesConfigFile::load_default()?; let runtime_config = BackendRuntimesConfigFile::load_default()?;
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config( let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
&workspace_root, &workspace_root,
identity, identity,
&host_config,
&runtime_config, &runtime_config,
)?; )?;
resolved.database_path = database_path.clone(); resolved.database_path = database_path.clone();
@@ -638,7 +569,6 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
if let Some(listen) = options.listen { if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen); resolved = resolved.with_listen(listen);
} }
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
let listener = TcpListener::bind(resolved.listen).await?; let listener = TcpListener::bind(resolved.listen).await?;
let local_addr = listener.local_addr()?; let local_addr = listener.local_addr()?;
@@ -711,49 +641,15 @@ fn infer_workspace_root_from_repositories(
))); )));
}; };
let repository_path = PathBuf::from(&repository.uri); if repository.source.kind == workspace_api::RepositorySourceKind::Invalid {
if !repository_path.is_absolute() {
return Err(CliError(format!( return Err(CliError(format!(
"repository `{}` has relative URI `{}`; repository records used by serve must be absolute paths", "repository `{}` has an invalid migrated source and cannot be used by serve",
repository.repository_id, repository.uri repository.repository_id
))); )));
} }
Ok(repository_path) Ok(ServerConfig::default_workspace_backend_data_root(
} &workspace.workspace_id,
))
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else {
print_config_help();
return Ok(Command::Help);
};
match subcommand.as_str() {
"default" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
if !rest.is_empty() {
return Err(CliError(
"config default does not accept options".to_string(),
));
}
Ok(Command::ConfigDefault)
}
"diff" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?))
}
"--help" | "-h" => {
print_config_help();
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown config subcommand `{other}`; expected `default` or `diff`"
))),
}
} }
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> { fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
@@ -839,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
Ok(SkillWorkspaceOptions { workspace_id }) Ok(SkillWorkspaceOptions { workspace_id })
} }
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--workspace" => {
let value = iter
.next()
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
workspace = PathBuf::from(value);
}
value if value.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
other => return Err(CliError(format!("unknown workspace option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(WorkspacePathOptions { workspace })
}
fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--workspace" => {
let value = iter
.next()
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
workspace = PathBuf::from(value);
}
value if value.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
other => return Err(CliError(format!("unknown init option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(InitOptions { workspace })
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> { fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut listen = None; let mut listen = None;
let mut config = None;
let mut index = 0; let mut index = 0;
while index < args.len() { while index < args.len() {
@@ -905,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
_ if arg.starts_with("--listen=") => { _ if arg.starts_with("--listen=") => {
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?); listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
} }
"--config" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--config requires a path".to_string()))?;
config = Some(PathBuf::from(value));
}
_ if arg.starts_with("--config=") => {
config = Some(PathBuf::from(value_after_equals(arg, "--config")?));
}
_ if arg.starts_with('-') => { _ if arg.starts_with('-') => {
return Err(CliError(format!("unknown serve option `{arg}`"))); return Err(CliError(format!("unknown serve option `{arg}`")));
} }
@@ -917,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
index += 1; index += 1;
} }
Ok(ServeOptions { listen }) Ok(ServeOptions { listen, config })
} }
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> { fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
@@ -939,23 +797,11 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>] "yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
); );
} }
fn print_init_help() {
println!(
"yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
);
}
fn print_config_help() {
println!(
"yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
);
}
fn print_skills_help() { fn print_skills_help() {
println!( println!(
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help" "yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
@@ -965,24 +811,23 @@ fn print_skills_help() {
fn print_serve_help() { fn print_serve_help() {
println!( println!(
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>] "yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help" yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
); );
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use yoi_workspace_server::{
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WORKSPACE_IDENTITY_RELATIVE_PATH,
};
#[test] #[test]
fn parse_init_defaults_workspace_to_cwd_or_flag() { fn removed_repository_local_commands_are_rejected() {
let temp = tempfile::tempdir().unwrap(); for command in ["init", "config"] {
let args = vec!["--workspace".to_string(), temp.path().display().to_string()]; let error = parse_command(&[command.to_string()]).unwrap_err();
let options = parse_init_options(&args).unwrap(); assert!(
assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); error.to_string().contains("unknown command"),
"unexpected error for {command}: {error}"
);
}
} }
#[test] #[test]
@@ -1022,10 +867,18 @@ mod tests {
} }
#[test] #[test]
fn parse_serve_accepts_listen_only() { fn parse_serve_accepts_listen_and_host_config() {
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()]; let args = vec![
"--listen".to_string(),
"127.0.0.1:0".to_string(),
"--config=/etc/yoi/server.toml".to_string(),
];
let options = parse_serve_options(&args).unwrap(); let options = parse_serve_options(&args).unwrap();
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap()); assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
assert_eq!(
options.config.unwrap(),
PathBuf::from("/etc/yoi/server.toml")
);
} }
#[test] #[test]
@@ -1080,49 +933,4 @@ mod tests {
); );
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap(); ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
} }
#[tokio::test]
async fn init_creates_identity_local_config_and_server_records() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("data").join("server").join("server.db");
std::fs::create_dir(temp.path().join(".git")).unwrap();
run_init_with_database_path(
InitOptions {
workspace: temp.path().canonicalize().unwrap(),
},
database_path.clone(),
)
.await
.unwrap();
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH);
assert!(local_config_path.exists());
assert_eq!(
std::fs::read_to_string(local_config_path).unwrap(),
WORKSPACE_BACKEND_CONFIG_TEMPLATE
);
assert!(
!temp
.path()
.join(".yoi/workspace-backend.default.toml")
.exists()
);
assert!(!temp.path().join(".yoi/workspace.db").exists());
assert!(!temp.path().join(".yoi/embedded-runtime").exists());
assert!(database_path.exists());
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
let workspaces = store.list_workspaces().unwrap();
assert_eq!(workspaces.len(), 1);
let repositories = store
.list_repositories(&workspaces[0].workspace_id)
.unwrap();
assert_eq!(repositories.len(), 1);
assert_eq!(repositories[0].repository_id, "main");
assert_eq!(
repositories[0].uri,
temp.path().canonicalize().unwrap().display().to_string()
);
}
} }
+1
View File
@@ -296,6 +296,7 @@ pub struct TicketActionEligibility {
pub can_unassign_orchestrator: bool, pub can_unassign_orchestrator: bool,
pub can_queue: bool, pub can_queue: bool,
pub can_start_manual_coder: bool, pub can_start_manual_coder: bool,
pub queue_tickets: Vec<String>,
pub blockers: Vec<String>, pub blockers: Vec<String>,
} }

Some files were not shown because too many files have changed in this diff Show More