94 Commits
Author SHA1 Message Date
Hare 116d610ad0 fix: project Ticket mutation outputs to human keys 2026-08-27 14:18:48 +09:00
Hare 75c570962d Merge commit '7edc588202dfbfd4c834f677f510ddda7f3d6451' into work/00001M10HW6BV-model-facing-resource-projection 2026-08-27 13:46:19 +09:00
Hare cae8ac1799 fix: allow missing Objective query snippets 2026-08-27 13:46:10 +09:00
Hare 917cc222a3 fix: resolve relation summaries to Ticket keys 2026-08-27 13:22:08 +09:00
Hare 7edc588202 chore: refresh T-528 after T-541 2026-08-27 13:09:44 +09:00
Hare c83461508b Merge commit '21b3dd1da1b1bbf18799a0623bf67dbe6266067c' into work/00001M10HW6BV-model-facing-resource-projection 2026-08-27 13:09:15 +09:00
Hare 4c876a201b fix: validate projected resource keys canonically 2026-08-27 12:49:59 +09:00
Hare 21b3dd1da1 Merge commit '0496cd907bc7bb96e9aa1c6d385bedb616bf3233' into work/00001M10FJVA2-orchestrator-queue-notice 2026-08-27 12:46:27 +09:00
Hare 5ca0ea9228 fix: sanitize orchestrator queue attention 2026-08-27 12:46:10 +09:00
Hare d5c3a68a37 fix: use Ticket resource keys in handoffs 2026-08-27 12:45:08 +09:00
Hare 2b33b9158d chore: refresh T-528 against develop 2026-08-27 12:45:01 +09:00
Hare b31642e284 feat: project Ticket and Objective human references 2026-08-27 12:39:28 +09:00
Hare 3a7a3307ef fix: map internal worker terminal lifecycles 2026-08-27 12:27:55 +09:00
Hare 0496cd907b fix: use Ticket resource keys in handoffs 2026-08-27 12:24:19 +09:00
Hare 975b4fa700 feat: add typed engine run exits 2026-08-27 11:42:49 +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
144 changed files with 21703 additions and 3919 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
+4 -5
View File
@@ -24,18 +24,17 @@ agen = { version = "0.2.1", features = ["codex"] }
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), 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;
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) {
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("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 _exit = engine.run("Give a Rust example.").await;
Ok(())
} }
``` ```
+9 -10
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, EngineRunExit, StopReason};
use std::time::Duration; use std::time::Duration;
#[tokio::main] #[tokio::main]
@@ -45,16 +45,15 @@ 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 { let output = engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await;
Ok(out) => match out.result { match output.result {
EngineResult::Finished => println!("✅ Task completed normally"), EngineRunExit::Finished => println!("✅ Task completed normally"),
EngineResult::Paused => println!("⏸️ Task paused"), EngineRunExit::Paused => println!("⏸️ Task paused"),
EngineResult::LimitReached => println!("🔒 Turn limit reached"), EngineRunExit::Yielded => println!("↩️ Task yielded"),
EngineResult::Yielded => println!("↩️ Task yielded"), EngineRunExit::Interrupted(StopReason::LimitReached) => {
}, println!("🔒 Turn limit reached")
Err(e) => {
println!("❌ Task error: {}", e);
} }
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
} }
println!("\n✨ Demo complete!"); println!("\n✨ Demo complete!");
+8 -19
View File
@@ -39,7 +39,7 @@ use tracing::info;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use agen::{ use agen::{
Engine, Engine, EngineRunExit, StopReason,
interceptor::{Interceptor, PostToolAction, ToolResultInfo}, interceptor::{Interceptor, PostToolAction, ToolResultInfo},
llm_client::{ llm_client::{
LlmClient, LlmClient,
@@ -476,12 +476,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// One-shot mode // One-shot mode
if let Some(prompt) = args.prompt { if let Some(prompt) = args.prompt {
match engine.run(&prompt).await { let output = engine.run(&prompt).await;
Ok(_) => {} if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
Err(e) => { eprintln!("\n❌ Error: {error}");
eprintln!("\n❌ Error: {}", e);
std::process::exit(1);
}
} }
return Ok(()); return Ok(());
@@ -500,13 +497,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(()); return Ok(());
} }
let mut locked = match engine.run(first_input).await { let output = engine.run(first_input).await;
Ok(out) => out.engine, let mut locked = output.engine;
Err(e) => {
eprintln!("\n❌ Error: {}", e);
return Ok(());
}
};
loop { loop {
print!("\n👤 You: "); print!("\n👤 You: ");
@@ -525,11 +517,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
break; break;
} }
match locked.run(input).await { if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = locked.run(input).await {
Ok(_) => {} eprintln!("\n❌ Error: {error}");
Err(e) => {
eprintln!("\n❌ Error: {}", e);
}
} }
} }
+122 -94
View File
@@ -70,24 +70,50 @@ pub struct EngineConfig {
_private: (), _private: (),
} }
/// Engine execution result (status) /// Legacy serializable outcome used by the Worker session-log compatibility boundary.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum EngineResult { pub enum EngineResult {
/// Completed (waiting for user input)
Finished, Finished,
/// Paused (can be resumed)
Paused, Paused,
/// Turn limit reached (max_turns exceeded)
LimitReached, LimitReached,
/// Yielded to caller for external processing (e.g. context compaction).
///
/// Distinct from `Paused`: internal machinery, not user-facing. The
/// caller is expected to perform some side work and then call `resume()`
/// to continue the turn loop.
Yielded, Yielded,
} }
/// The public termination boundary for one logical engine run.
#[derive(Debug)]
pub enum EngineRunExit {
Finished,
Paused,
Yielded,
Interrupted(StopReason),
}
/// A typed reason why an engine run could not finish normally.
#[derive(Debug)]
pub enum StopReason {
LimitReached,
ContextWindowExceeded,
Cancelled,
Unexpected(EngineError),
}
impl From<Result<EngineResult, EngineError>> for EngineRunExit {
fn from(result: Result<EngineResult, EngineError>) -> Self {
match result {
Ok(EngineResult::Finished) => Self::Finished,
Ok(EngineResult::Paused) => Self::Paused,
Ok(EngineResult::Yielded) => Self::Yielded,
Ok(EngineResult::LimitReached) => Self::Interrupted(StopReason::LimitReached),
Err(EngineError::Client(ClientError::ContextWindowExceeded)) => {
Self::Interrupted(StopReason::ContextWindowExceeded)
}
Err(EngineError::Cancelled) => Self::Interrupted(StopReason::Cancelled),
Err(error) => Self::Interrupted(StopReason::Unexpected(error)),
}
}
}
/// 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.
@@ -95,7 +121,7 @@ pub struct EngineRunOutput<C: LlmClient> {
/// The Engine, now in Locked state. /// The Engine, now in Locked state.
pub engine: Engine<C, Locked>, pub engine: Engine<C, Locked>,
/// Outcome of the turn. /// Outcome of the turn.
pub result: EngineResult, pub result: EngineRunExit,
} }
/// Internal: tool execution result /// Internal: tool execution result
@@ -126,16 +152,16 @@ const MAX_STREAM_CONTINUATIONS: u32 = 3;
/// 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("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?; /// let _exit = engine.run("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); /// engine.truncate_history(5);
/// let out = engine.run("Continue").await?; /// let out = engine.run("Continue").await;
/// let mut engine = out.engine; /// let mut engine = out.engine;
/// ``` /// ```
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -179,14 +205,20 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
history: Vec<Item>, 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,
@@ -231,8 +263,6 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>, history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>,
/// Request configuration (max_tokens, temperature, etc.) /// Request configuration (max_tokens, temperature, etc.)
request_config: RequestConfig, request_config: RequestConfig,
/// Whether the previous run was interrupted
last_run_interrupted: bool,
/// Cancel notification channel (for interrupting execution) /// Cancel notification channel (for interrupting execution)
cancel_tx: mpsc::Sender<()>, cancel_tx: mpsc::Sender<()>,
cancel_rx: mpsc::Receiver<()>, cancel_rx: mpsc::Receiver<()>,
@@ -264,8 +294,18 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
} }
impl<C: LlmClient, S: EngineState> Engine<C, S> { impl<C: LlmClient, S: EngineState> Engine<C, S> {
fn reset_interruption_state(&mut self) { fn start_logical_run(&mut self) {
self.last_run_interrupted = false; 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) {
@@ -650,6 +690,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 {
@@ -768,11 +825,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
self.try_cancelled() self.try_cancelled()
} }
/// Whether the previous run was interrupted
pub fn last_run_interrupted(&self) -> bool {
self.last_run_interrupted
}
/// Generate list of ToolDefinitions for LLM from registered tools /// Generate list of ToolDefinitions for LLM from registered tools
fn build_tool_definitions(&self) -> Vec<ToolDefinition> { fn build_tool_definitions(&self) -> Vec<ToolDefinition> {
self.tool_server.tool_definitions_sorted() self.tool_server.tool_definitions_sorted()
@@ -865,7 +917,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
match result { match result {
Ok(value) => Ok(value), Ok(value) => Ok(value),
Err(err) => { Err(err) => {
self.last_run_interrupted = true;
let reason = match &err { let reason = match &err {
EngineError::Aborted(reason) => reason.clone(), EngineError::Aborted(reason) => reason.clone(),
EngineError::Cancelled => "Cancelled".to_string(), EngineError::Cancelled => "Cancelled".to_string(),
@@ -963,11 +1014,9 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
continue; continue;
} }
PreToolAction::Abort(reason) => { PreToolAction::Abort(reason) => {
self.last_run_interrupted = true;
return Err(EngineError::Aborted(reason)); return Err(EngineError::Aborted(reason));
} }
PreToolAction::Pause => { PreToolAction::Pause => {
self.last_run_interrupted = true;
return Ok(ToolExecutionResult::Paused); return Ok(ToolExecutionResult::Paused);
} }
} }
@@ -1020,7 +1069,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
info!("Tool execution cancelled"); info!("Tool execution cancelled");
} }
self.timeline.abort_current_block(); self.timeline.abort_current_block();
self.last_run_interrupted = true;
return Err(EngineError::Cancelled); return Err(EngineError::Cancelled);
} }
}; };
@@ -1042,7 +1090,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
match self.interceptor.post_tool_call(&mut info).await { match self.interceptor.post_tool_call(&mut info).await {
PostToolAction::Continue => {} PostToolAction::Continue => {}
PostToolAction::Abort(reason) => { PostToolAction::Abort(reason) => {
self.last_run_interrupted = true;
return Err(EngineError::Aborted(reason)); return Err(EngineError::Aborted(reason));
} }
} }
@@ -1096,7 +1143,6 @@ 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) -> Result<EngineResult, EngineError> {
self.reset_interruption_state();
let tool_definitions = self.build_tool_definitions(); let tool_definitions = self.build_tool_definitions();
info!( info!(
@@ -1119,10 +1165,21 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
if self.try_cancelled() { if self.try_cancelled() {
info!("Execution cancelled"); info!("Execution cancelled");
self.timeline.abort_current_block(); self.timeline.abort_current_block();
self.last_run_interrupted = true;
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"
);
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");
@@ -1213,7 +1270,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
for cb in &self.turn_end_cbs { for cb in &self.turn_end_cbs {
cb(current_turn); cb(current_turn);
} }
self.last_run_interrupted = true;
return Err(EngineError::Aborted(reason)); return Err(EngineError::Aborted(reason));
} }
PreRequestAction::YieldWith(items) => { PreRequestAction::YieldWith(items) => {
@@ -1223,7 +1279,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
for cb in &self.turn_end_cbs { for cb in &self.turn_end_cbs {
cb(current_turn); cb(current_turn);
} }
self.last_run_interrupted = true;
return Ok(EngineResult::Yielded); return Ok(EngineResult::Yielded);
} }
PreRequestAction::Yield => { PreRequestAction::Yield => {
@@ -1231,7 +1286,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
for cb in &self.turn_end_cbs { for cb in &self.turn_end_cbs {
cb(current_turn); cb(current_turn);
} }
self.last_run_interrupted = true;
return Ok(EngineResult::Yielded); return Ok(EngineResult::Yielded);
} }
PreRequestAction::ContinueWith(items) => { PreRequestAction::ContinueWith(items) => {
@@ -1276,7 +1330,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
if let StreamCompletion::Interrupted { reason } = stream_outcome { if let StreamCompletion::Interrupted { reason } = stream_outcome {
stream_continuations += 1; stream_continuations += 1;
if stream_continuations > MAX_STREAM_CONTINUATIONS { if stream_continuations > MAX_STREAM_CONTINUATIONS {
self.last_run_interrupted = true;
return Err(EngineError::Client(ClientError::Api { return Err(EngineError::Client(ClientError::Api {
status: None, status: None,
code: None, code: None,
@@ -1314,6 +1367,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.
@@ -1327,7 +1381,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
if tool_calls.is_empty() { if tool_calls.is_empty() {
match self.interceptor.on_turn_end(&self.history).await { match self.interceptor.on_turn_end(&self.history).await {
TurnEndAction::Finish => { TurnEndAction::Finish => {
self.last_run_interrupted = false;
return Ok(EngineResult::Finished); return Ok(EngineResult::Finished);
} }
TurnEndAction::ContinueWithMessages(additional) => { TurnEndAction::ContinueWithMessages(additional) => {
@@ -1335,7 +1388,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
continue; continue;
} }
TurnEndAction::Pause => { TurnEndAction::Pause => {
self.last_run_interrupted = true;
return Ok(EngineResult::Paused); return Ok(EngineResult::Paused);
} }
} }
@@ -1344,18 +1396,6 @@ 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(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);
}
}
} }
} }
@@ -1397,7 +1437,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
}), }),
); );
self.timeline.abort_current_block(); self.timeline.abort_current_block();
self.last_run_interrupted = true;
return Err(EngineError::Cancelled); return Err(EngineError::Cancelled);
} }
}; };
@@ -1429,7 +1468,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
}), }),
); );
self.timeline.abort_current_block(); self.timeline.abort_current_block();
self.last_run_interrupted = true;
return Err(EngineError::Cancelled); return Err(EngineError::Cancelled);
} }
}; };
@@ -1471,7 +1509,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
let next_failed_attempt = failed_attempt + 1; let next_failed_attempt = failed_attempt + 1;
if next_failed_attempt >= policy.max_attempts || !is_retryable(&err) { if next_failed_attempt >= policy.max_attempts || !is_retryable(&err) {
self.last_run_interrupted = true;
return Err(EngineError::Client(err)); return Err(EngineError::Client(err));
} }
@@ -1480,7 +1517,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
.unwrap_or_else(|| policy.backoff(failed_attempt)); .unwrap_or_else(|| policy.backoff(failed_attempt));
let elapsed = started.elapsed(); let elapsed = started.elapsed();
if elapsed + wait > policy.total_timeout { if elapsed + wait > policy.total_timeout {
self.last_run_interrupted = true;
return Err(EngineError::Client(err)); return Err(EngineError::Client(err));
} }
@@ -1509,7 +1545,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
info!("Cancelled during LLM retry backoff"); info!("Cancelled during LLM retry backoff");
} }
self.timeline.abort_current_block(); self.timeline.abort_current_block();
self.last_run_interrupted = true;
return Err(EngineError::Cancelled); return Err(EngineError::Cancelled);
} }
} }
@@ -1552,7 +1587,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
let event = match result { let event = match result {
Ok(event) => event, Ok(event) => event,
Err(err) => { Err(err) => {
self.last_run_interrupted = true;
// 部分情報でも発火しておく(料金会計用) // 部分情報でも発火しておく(料金会計用)
self.timeline.flush_usage(); self.timeline.flush_usage();
return Ok(StreamCompletion::Interrupted { return Ok(StreamCompletion::Interrupted {
@@ -1573,7 +1607,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
if let Event::Error(err) = &event { if let Event::Error(err) = &event {
self.timeline.abort_current_block(); self.timeline.abort_current_block();
self.timeline.flush_usage(); self.timeline.flush_usage();
self.last_run_interrupted = true;
return Err(EngineError::Client(ClientError::Api { return Err(EngineError::Client(ClientError::Api {
status: None, status: None,
code: err.code.clone(), code: err.code.clone(),
@@ -1591,7 +1624,6 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
} }
self.timeline.abort_current_block(); self.timeline.abort_current_block();
self.timeline.flush_usage(); self.timeline.flush_usage();
self.last_run_interrupted = true;
return Err(EngineError::Cancelled); return Err(EngineError::Cancelled);
} }
} }
@@ -1610,10 +1642,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
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 {
Ok(ToolExecutionResult::Paused) => { Ok(ToolExecutionResult::Paused) => Ok(Some(EngineResult::Paused)),
self.last_run_interrupted = true;
Ok(Some(EngineResult::Paused))
}
Ok(ToolExecutionResult::Completed(results)) => { Ok(ToolExecutionResult::Completed(results)) => {
// Route per-result pushes through the callback path so // Route per-result pushes through the callback path so
// observers see each tool result as it lands. // observers see each tool result as it lands.
@@ -1629,10 +1658,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
self.append_history_items(items)?; self.append_history_items(items)?;
Ok(None) Ok(None)
} }
Err(err) => { Err(err) => Err(err),
self.last_run_interrupted = true;
Err(err)
}
} }
} }
} }
@@ -1664,6 +1690,7 @@ impl<C: LlmClient> Engine<C, Mutable> {
history: Vec::new(), 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,
@@ -1679,7 +1706,6 @@ impl<C: LlmClient> Engine<C, Mutable> {
tool_result_cbs: Vec::new(), tool_result_cbs: Vec::new(),
history_append_cbs: Vec::new(), history_append_cbs: Vec::new(),
request_config: RequestConfig::default(), request_config: RequestConfig::default(),
last_run_interrupted: false,
cancel_tx, cancel_tx,
cancel_rx, cancel_rx,
tool_output_limits: None, tool_output_limits: None,
@@ -1863,11 +1889,6 @@ impl<C: LlmClient> Engine<C, Mutable> {
self.max_turns = max_turns; self.max_turns = max_turns;
} }
/// Set the last_run_interrupted flag (for session restoration)
pub fn set_last_run_interrupted(&mut self, interrupted: bool) {
self.last_run_interrupted = interrupted;
}
/// Apply configuration (reserved for future extensions) /// Apply configuration (reserved for future extensions)
#[allow(dead_code)] #[allow(dead_code)]
pub fn config(self, _config: EngineConfig) -> Self { pub fn config(self, _config: EngineConfig) -> Self {
@@ -1881,28 +1902,25 @@ impl<C: LlmClient> Engine<C, Mutable> {
/// ///
/// Subsequent runs can call [`Engine::run`] directly. /// Subsequent runs can call [`Engine::run`] directly.
/// To edit state between turns, call [`unlock()`](Engine::unlock) first. /// To edit state between turns, call [`unlock()`](Engine::unlock) first.
pub async fn run( pub async fn run(self, user_input: impl Into<String>) -> EngineRunOutput<C> {
self,
user_input: impl Into<String>,
) -> Result<EngineRunOutput<C>, EngineError> {
let mut locked = self.lock(); let mut locked = self.lock();
let result = locked.run(user_input).await?; let result = locked.run(user_input).await;
Ok(EngineRunOutput { EngineRunOutput {
engine: locked, engine: locked,
result, result,
}) }
} }
/// 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(self) -> EngineRunOutput<C> {
let mut locked = self.lock(); let mut locked = self.lock();
let result = locked.resume().await?; let result = locked.resume().await;
Ok(EngineRunOutput { EngineRunOutput {
engine: locked, engine: locked,
result, result,
}) }
} }
/// Lock and transition to Locked state /// Lock and transition to Locked state
@@ -1934,6 +1952,7 @@ impl<C: LlmClient> Engine<C, Mutable> {
history: self.history, 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,
@@ -1949,7 +1968,6 @@ impl<C: LlmClient> Engine<C, Mutable> {
tool_result_cbs: self.tool_result_cbs, tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs, history_append_cbs: self.history_append_cbs,
request_config: self.request_config, request_config: self.request_config,
last_run_interrupted: self.last_run_interrupted,
cancel_tx: self.cancel_tx, cancel_tx: self.cancel_tx,
cancel_rx: self.cancel_rx, cancel_rx: self.cancel_rx,
@@ -1970,16 +1988,17 @@ impl<C: LlmClient> Engine<C, Locked> {
/// ///
/// 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(&mut self, user_input: impl Into<String>) -> EngineRunExit {
&mut self, self.run_result(user_input.into()).await.into()
user_input: impl Into<String>, }
) -> Result<EngineResult, EngineError> {
self.reset_interruption_state(); async fn run_result(&mut self, user_input: String) -> Result<EngineResult, EngineError> {
// Supplying new user input abandons any paused/yielded logical run.
self.active_run_turn_count = None;
// 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);
let extras = match self.interceptor.on_prompt_submit(&mut user_item).await { let extras = match self.interceptor.on_prompt_submit(&mut user_item).await {
PromptAction::Cancel(reason) => { PromptAction::Cancel(reason) => {
self.last_run_interrupted = true;
return self return self
.finalize_interruption(Err(EngineError::Aborted(reason))) .finalize_interruption(Err(EngineError::Aborted(reason)))
.await; .await;
@@ -1991,17 +2010,26 @@ impl<C: LlmClient> Engine<C, Locked> {
if !extras.is_empty() { if !extras.is_empty() {
self.append_history_items(extras)?; self.append_history_items(extras)?;
} }
self.start_logical_run();
let result = self.run_turn_loop().await; let result = self.run_turn_loop().await;
self.finalize_interruption(result).await let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
} }
/// Resume execution (from Paused state) /// 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(&mut self) -> EngineRunExit {
self.reset_interruption_state(); self.resume_result().await.into()
}
async fn resume_result(&mut self) -> Result<EngineResult, EngineError> {
self.ensure_logical_run();
let result = self.run_turn_loop().await; let result = self.run_turn_loop().await;
self.finalize_interruption(result).await let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
} }
/// Get the prefix length at lock time /// Get the prefix length at lock time
@@ -2027,6 +2055,7 @@ impl<C: LlmClient> Engine<C, Locked> {
history: self.history, 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,
@@ -2042,7 +2071,6 @@ impl<C: LlmClient> Engine<C, Locked> {
tool_result_cbs: self.tool_result_cbs, tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs, history_append_cbs: self.history_append_cbs,
request_config: self.request_config, request_config: self.request_config,
last_run_interrupted: self.last_run_interrupted,
cancel_tx: self.cancel_tx, cancel_tx: self.cancel_tx,
cancel_rx: self.cancel_rx, cancel_rx: self.cancel_rx,
+2 -2
View File
@@ -20,8 +20,8 @@ pub mod usage_record;
pub use agen_macros::{description, tool, tool_registry}; pub use agen_macros::{description, tool, tool_registry};
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope}; pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
pub use engine::{ pub use engine::{
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice, Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
ToolRegistryError, LlmRetryNotice, StopReason, ToolRegistryError,
}; };
pub use handler::ToolUseBlockStart; pub use handler::ToolUseBlockStart;
pub use interceptor::Interceptor; pub use interceptor::Interceptor;
+8 -1
View File
@@ -18,6 +18,9 @@ pub enum ClientError {
message: String, message: String,
retry_after: Option<Duration>, retry_after: Option<Duration>,
}, },
/// The provider rejected the request because it exceeded the model context window.
/// Classified only from a structured provider error code, never message text.
ContextWindowExceeded,
/// A request lifecycle phase exceeded its hard timeout. /// A request lifecycle phase exceeded its hard timeout.
Timeout { Timeout {
phase: &'static str, phase: &'static str,
@@ -48,6 +51,7 @@ impl fmt::Display for ClientError {
} }
write!(f, ": {}", message) write!(f, ": {}", message)
} }
ClientError::ContextWindowExceeded => write!(f, "Model context window reached"),
ClientError::Timeout { phase, timeout } => { ClientError::Timeout { phase, timeout } => {
write!(f, "{phase} timed out after {}s", timeout.as_secs()) write!(f, "{phase} timed out after {}s", timeout.as_secs())
} }
@@ -112,7 +116,10 @@ pub fn is_retryable(error: &ClientError) -> bool {
ClientError::Api { status: None, .. } => false, ClientError::Api { status: None, .. } => false,
ClientError::Timeout { .. } => true, ClientError::Timeout { .. } => true,
ClientError::Http(e) => e.is_connect() || e.is_timeout(), ClientError::Http(e) => e.is_connect() || e.is_timeout(),
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false, ClientError::ContextWindowExceeded
| ClientError::Json(_)
| ClientError::Sse(_)
| ClientError::Config(_) => false,
} }
} }
+4 -7
View File
@@ -431,13 +431,7 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
} }
fn is_context_length_exceeded(error: &ClientError) -> bool { fn is_context_length_exceeded(error: &ClientError) -> bool {
match error { matches!(error, ClientError::ContextWindowExceeded)
ClientError::Api { code, message, .. } => {
code.as_deref() == Some("context_length_exceeded")
|| message.contains("context_length_exceeded")
}
_ => false,
}
} }
async fn response_with_timeout( async fn response_with_timeout(
@@ -487,6 +481,9 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(&text) .unwrap_or(&text)
.to_string(); .to_string();
if code.as_deref() == Some("context_length_exceeded") {
return ClientError::ContextWindowExceeded;
}
ClientError::Api { ClientError::Api {
status: Some(status), status: Some(status),
code, code,
+13 -7
View File
@@ -66,7 +66,10 @@ async fn test_callback_llm_retry_event() {
}); });
let result = engine.run("retry once").await; let result = engine.run("retry once").await;
assert!(result.is_ok(), "engine should succeed after one retry"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"engine should succeed after one retry"
);
let notices = notices.lock().unwrap(); let notices = notices.lock().unwrap();
assert_eq!(notices.len(), 1); assert_eq!(notices.len(), 1);
@@ -108,9 +111,12 @@ async fn test_callback_text_block_events() {
}); });
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let result = engine.run("Greet me").await; let result = engine.run("Greet me").await;
assert!(result.is_ok(), "Engine should complete"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete"
);
let deltas = text_deltas.lock().unwrap(); let deltas = text_deltas.lock().unwrap();
assert_eq!(deltas.len(), 2); assert_eq!(deltas.len(), 2);
@@ -154,7 +160,7 @@ async fn test_callback_tool_call_complete() {
}); });
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let _ = engine.run("Weather please").await; let _ = engine.run("Weather please").await;
let starts = tool_starts.lock().unwrap(); let starts = tool_starts.lock().unwrap();
@@ -197,9 +203,9 @@ async fn test_callback_turn_events() {
ends.lock().unwrap().push(turn); ends.lock().unwrap().push(turn);
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let result = engine.run("Do something").await; let result = engine.run("Do something").await;
assert!(result.is_ok()); assert!(matches!(result.result, agen::EngineRunExit::Finished));
let starts = turn_starts.lock().unwrap(); let starts = turn_starts.lock().unwrap();
let ends = turn_ends.lock().unwrap(); let ends = turn_ends.lock().unwrap();
@@ -382,7 +388,7 @@ async fn test_callback_usage_events() {
usages.lock().unwrap().push(event.clone()); usages.lock().unwrap().push(event.clone());
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let _ = engine.run("Hello").await; let _ = engine.run("Hello").await;
let usages = usage_events.lock().unwrap(); let usages = usage_events.lock().unwrap();
+8 -2
View File
@@ -138,7 +138,10 @@ async fn test_engine_simple_text_response() {
// 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("Hello").await;
assert!(result.is_ok(), "Engine should complete successfully"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete successfully"
);
} }
/// Verify that Engine can correctly process responses containing tool calls /// Verify that Engine can correctly process responses containing tool calls
@@ -199,7 +202,10 @@ async fn test_engine_with_programmatic_events() {
// Mutable::run consumes self, returns tuple // Mutable::run consumes self, returns tuple
let result = engine.run("Greet me").await; let result = engine.run("Greet me").await;
assert!(result.is_ok(), "Engine should complete successfully"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete successfully"
);
} }
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events /// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
+244 -13
View File
@@ -9,12 +9,48 @@ 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::ClientError;
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, EngineRunExit, StopReason};
use async_trait::async_trait; use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
#[test]
fn engine_source_has_no_worker_owned_interruption_marker() {
let source = include_str!("../src/engine.rs");
assert!(!source.contains("last_run_interrupted"));
assert!(!source.contains("set_last_run_interrupted"));
}
#[test]
fn run_exit_classifies_known_and_unexpected_stops_without_message_parsing() {
assert!(matches!(
EngineRunExit::from(Err(EngineError::Cancelled)),
EngineRunExit::Interrupted(StopReason::Cancelled)
));
assert!(matches!(
EngineRunExit::from(Err(EngineError::Client(ClientError::ContextWindowExceeded))),
EngineRunExit::Interrupted(StopReason::ContextWindowExceeded)
));
let message_only = EngineError::Client(ClientError::Api {
status: Some(400),
code: None,
message: "context_length_exceeded".to_string(),
retry_after: None,
});
assert!(matches!(
EngineRunExit::from(Err(message_only)),
EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::Client(
ClientError::Api { .. }
)))
));
}
// ============================================================================= // =============================================================================
// Mutable State Tests // Mutable State Tests
// ============================================================================= // =============================================================================
@@ -192,11 +228,13 @@ async fn history_append_failure_stops_before_tool_execution() {
}); });
let mut engine = engine.lock(); let mut engine = engine.lock();
let error = engine.run("use the tool").await.unwrap_err(); let exit = engine.run("use the tool").await;
assert!( assert!(matches!(
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC") exit,
); EngineRunExit::Interrupted(StopReason::Unexpected(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!(engine.history().len(), 1);
assert_eq!(engine.history()[0].as_text(), Some("use the tool")); assert_eq!(engine.history()[0].as_text(), Some("use the tool"));
@@ -271,7 +309,7 @@ async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
let engine = Engine::new(client); let engine = Engine::new(client);
// 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("Hi there").await;
let engine = out.engine; let engine = out.engine;
// History is updated // History is updated
@@ -320,12 +358,12 @@ async fn test_locked_multi_turn_history_accumulation() {
// Turn 1 // Turn 1
let result1 = locked_engine.run("Hello!").await; let result1 = locked_engine.run("Hello!").await;
assert!(result1.is_ok()); assert!(matches!(result1, EngineRunExit::Finished));
assert_eq!(locked_engine.history().len(), 2); // user + assistant assert_eq!(locked_engine.history().len(), 2); // user + assistant
// Turn 2 // Turn 2
let result2 = locked_engine.run("Can you help me?").await; let result2 = locked_engine.run("Can you help me?").await;
assert!(result2.is_ok()); assert!(matches!(result2, EngineRunExit::Finished));
assert_eq!(locked_engine.history().len(), 4); // 2 * (user + assistant) assert_eq!(locked_engine.history().len(), 4); // 2 * (user + assistant)
// Verify history contents // Verify history contents
@@ -383,7 +421,7 @@ async fn test_locked_prefix_len_tracking() {
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("New message").await;
// 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!(locked_engine.history().len(), 4); // 2 + 2
@@ -418,13 +456,13 @@ async fn test_turn_count_increment() -> Result<(), EngineError> {
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("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("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);
@@ -512,7 +550,7 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
engine.register_tool(tool_a.definition()); engine.register_tool(tool_a.definition());
let mut locked = engine.lock(); let mut locked = engine.lock();
locked.run("first").await.expect("first run"); locked.run("first").await;
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();
@@ -520,7 +558,7 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
unlocked.register_tool(tool_b.definition()); unlocked.register_tool(tool_b.definition());
let mut relocked = unlocked.lock(); let mut relocked = unlocked.lock();
relocked.run("second").await.expect("second run"); relocked.run("second").await;
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");
@@ -561,3 +599,196 @@ fn test_system_prompt_change_after_unlock() {
let relocked = unlocked.lock(); let relocked = unlocked.lock();
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));
engine.set_max_turns(Some(1));
let mut engine = engine.lock();
assert!(matches!(engine.run("first").await, EngineRunExit::Finished));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
assert!(matches!(
engine.run("second").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.active_run_turn_count(), None);
}
#[tokio::test]
async fn yielded_resume_keeps_the_same_unspent_turn_budget() {
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(1));
engine.set_interceptor(YieldOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock();
assert!(matches!(engine.run("start").await, EngineRunExit::Yielded));
assert_eq!(engine.turn_count(), 0);
assert_eq!(engine.active_run_turn_count(), Some(0));
assert!(matches!(engine.resume().await, EngineRunExit::Finished));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
}
#[tokio::test]
async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
let events = vec![
Event::tool_use_start(0, "call_1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(MockLlmClient::new(events));
engine.set_max_turns(Some(1));
engine.register_tool(tool.definition());
engine.set_interceptor(PauseToolOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock();
assert!(matches!(engine.run("call it").await, EngineRunExit::Paused));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), Some(1));
assert_eq!(tool.call_count(), 0);
assert!(matches!(
engine.resume().await,
EngineRunExit::Interrupted(StopReason::LimitReached)
));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(tool.call_count(), 1, "the consumed turn's tool still runs");
}
#[tokio::test]
async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
let tool_events = vec![
Event::tool_use_start(0, "call_1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![tool_events, completed_text_events()]);
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(client);
engine.set_max_turns(Some(1));
engine.register_tool(tool.definition());
engine.set_interceptor(PauseToolOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock();
assert!(matches!(engine.run("pause").await, EngineRunExit::Paused));
assert_eq!(engine.active_run_turn_count(), Some(1));
assert!(matches!(
engine.run("replace").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(tool.call_count(), 1, "pending-tool semantics are unchanged");
}
#[tokio::test]
async fn interceptor_continuation_consumes_the_logical_run_budget() {
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(1));
engine.set_interceptor(ContinueTurnOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock();
assert!(matches!(
engine.run("start").await,
EngineRunExit::Interrupted(StopReason::LimitReached)
));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.llm_call_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
}
#[tokio::test]
async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(1));
engine.set_turn_count(7);
engine.set_active_run_turn_count(Some(1));
let mut engine = engine.lock();
assert!(matches!(
engine.resume().await,
EngineRunExit::Interrupted(StopReason::LimitReached)
));
assert_eq!(engine.turn_count(), 7);
assert_eq!(engine.llm_call_count(), 0);
assert_eq!(engine.active_run_turn_count(), None);
}
+8 -5
View File
@@ -158,7 +158,7 @@ async fn test_parallel_tool_execution() {
engine.register_tool(tool3.definition()); engine.register_tool(tool3.definition());
let start = Instant::now(); let start = Instant::now();
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let _result = engine.run("Run all tools").await; let _result = engine.run("Run all tools").await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
@@ -415,7 +415,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, EngineRunExit)
let _result = engine.run("Test hook").await; let _result = engine.run("Test hook").await;
// allowed_tool is called, but blocked_tool is not // allowed_tool is called, but blocked_tool is not
@@ -502,10 +502,13 @@ async fn test_post_tool_call_modification() {
modified_content: modified_content.clone(), modified_content: modified_content.clone(),
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let result = engine.run("Test modification").await; let result = engine.run("Test modification").await;
assert!(result.is_ok(), "Engine should complete"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete"
);
// Verify hook was called and content was modified // Verify hook was called and content was modified
let content = modified_content.lock().unwrap().clone(); let content = modified_content.lock().unwrap().clone();
@@ -558,7 +561,7 @@ 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("Test synthetic result").await;
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!(result.engine.history().iter().any(|item| matches!(
@@ -65,7 +65,7 @@ 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 out = engine.run("question?").await;
let engine = out.engine; let engine = out.engine;
let history = engine.history(); let history = engine.history();
@@ -109,7 +109,7 @@ 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 out = engine.run("q").await;
let engine = out.engine; let engine = out.engine;
let history = engine.history(); let history = engine.history();
@@ -155,7 +155,7 @@ 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 out = engine.run("q").await;
let engine = out.engine; let engine = out.engine;
let history = engine.history(); let history = engine.history();
@@ -214,7 +214,7 @@ async fn injected_reasoning_survives_into_outgoing_request() {
Item::assistant_message("prior answer"), Item::assistant_message("prior answer"),
]); ]);
let _ = engine.run("follow up").await.expect("run ok"); let _ = engine.run("follow up").await;
let req = captured let req = captured
.lock() .lock()
+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());
+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();
+86 -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,
} }
@@ -895,6 +923,7 @@ pub enum WorkerStatus {
Idle, Idle,
Running, Running,
Paused, Paused,
Stopped,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -1377,7 +1406,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 +1761,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);
+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,
}, },
) )
} }
+132 -2
View File
@@ -125,11 +125,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 +146,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 +223,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 +254,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(),
@@ -265,6 +282,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
// 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,
@@ -290,16 +308,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 +443,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 +742,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]
@@ -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,
}, },
]; ];
+30 -10
View File
@@ -102,7 +102,7 @@ async fn run_and_persist(
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) { ) -> (Engine<MockLlmClient>, agen::EngineRunExit) {
// 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.
@@ -125,30 +125,49 @@ async fn run_and_persist(
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();
match &result { match &result {
Ok(r) => { agen::EngineRunExit::Finished
| agen::EngineRunExit::Paused
| agen::EngineRunExit::Yielded => {
let (legacy_result, interrupted) = match &result {
agen::EngineRunExit::Finished => (agen::EngineResult::Finished, false),
agen::EngineRunExit::Paused => (agen::EngineResult::Paused, true),
agen::EngineRunExit::Yielded => (agen::EngineResult::Yielded, true),
agen::EngineRunExit::Interrupted(_) => unreachable!(),
};
session_store::save_run_completed( session_store::save_run_completed(
store, store,
session_id, session_id,
segment_id, segment_id,
r.clone(), legacy_result,
worker.last_run_interrupted(), interrupted,
worker.active_run_turn_count(),
) )
.unwrap(); .unwrap();
} }
Err(e) => { agen::EngineRunExit::Interrupted(agen::StopReason::LimitReached) => {
session_store::save_run_completed(
store,
session_id,
segment_id,
agen::EngineResult::LimitReached,
false,
worker.active_run_turn_count(),
)
.unwrap();
}
agen::EngineRunExit::Interrupted(reason) => {
session_store::save_run_errored( session_store::save_run_errored(
store, store,
session_id, session_id,
segment_id, segment_id,
e.to_string(), format!("{reason:?}"),
worker.last_run_interrupted(), true,
) )
.unwrap(); .unwrap();
} }
} }
let r = result.unwrap(); (worker, result)
(worker, r)
} }
// ============================================================================= // =============================================================================
@@ -291,7 +310,7 @@ async fn session_resume_after_pause() {
.unwrap(); .unwrap();
let (_worker, result) = run_and_persist(worker, &store, sid, segid, "Weather?").await; let (_worker, result) = run_and_persist(worker, &store, sid, segid, "Weather?").await;
assert!(matches!(result, agen::EngineResult::Paused)); assert!(matches!(result, agen::EngineRunExit::Paused));
// Check RunCompleted is Paused // Check RunCompleted is Paused
let entries = store.read_all(sid, segid).unwrap(); let entries = store.read_all(sid, segid).unwrap();
@@ -309,6 +328,7 @@ 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]
+1137 -232
View File
File diff suppressed because it is too large Load Diff
+169 -21
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)
} }
@@ -406,7 +410,7 @@ struct TicketCreateParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketEditItemParams { struct TicketEditItemParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Optional replacement title. /// Optional replacement title.
#[serde(default)] #[serde(default)]
@@ -535,7 +539,7 @@ impl QueryTicketParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ShowTicketParams { struct ShowTicketParams {
/// Ticket id. Exactly one of `id` or `query` must be provided. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
#[serde(default)] #[serde(default)]
id: Option<String>, id: Option<String>,
/// Exact ticket id query. Exactly one of `id` or `query` must be provided. /// Exact ticket id query. Exactly one of `id` or `query` must be provided.
@@ -554,7 +558,7 @@ struct ShowTicketParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketThreadEventParams { struct TicketThreadEventParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Markdown event body. /// Markdown event body.
body: String, body: String,
@@ -562,7 +566,7 @@ struct TicketThreadEventParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketMarkReadyParams { struct TicketMarkReadyParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Optional reason attached to the state_changed event. /// Optional reason attached to the state_changed event.
#[serde(default)] #[serde(default)]
@@ -571,7 +575,7 @@ struct TicketMarkReadyParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams { struct TicketIntakeReadyParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Concise bounded intake summary appended before the ready transition. /// Concise bounded intake summary appended before the ready transition.
intake_summary: String, intake_summary: String,
@@ -582,13 +586,13 @@ struct TicketIntakeReadyParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketQueueParams { struct TicketQueueParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketWorkflowStateParams { struct TicketWorkflowStateParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Expected current state. The backend rejects stale transitions. /// Expected current state. The backend rejects stale transitions.
from: TicketWorkflowStateParam, from: TicketWorkflowStateParam,
@@ -602,7 +606,7 @@ struct TicketWorkflowStateParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCloseParams { struct TicketCloseParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Markdown resolution written to resolution.md and thread.md. /// Markdown resolution written to resolution.md and thread.md.
resolution: String, resolution: String,
@@ -610,7 +614,7 @@ struct TicketCloseParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketDependencyCheckParams { struct TicketDependencyCheckParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
} }
@@ -642,7 +646,7 @@ struct TicketRelationRecordParams {
ticket: String, ticket: String,
/// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of. /// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of.
kind: TicketRelationKindParam, kind: TicketRelationKindParam,
/// Target canonical Ticket id. Title/slug words are not accepted as relation authority. /// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
target: String, target: String,
/// Optional bounded rationale/note. /// Optional bounded rationale/note.
#[serde(default)] #[serde(default)]
@@ -655,7 +659,7 @@ struct TicketRelationRemoveParams {
ticket: String, ticket: String,
/// Forward relation kind to remove. /// Forward relation kind to remove.
kind: TicketRelationKindParam, kind: TicketRelationKindParam,
/// Target canonical Ticket id. /// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
target: String, target: String,
} }
@@ -1219,12 +1223,29 @@ 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 mut 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))?;
outcome.requested_ticket =
model_ticket_reference(&self.backend, &outcome.requested_ticket, "TicketQueue")?;
outcome.queued_tickets = outcome
.queued_tickets
.into_iter()
.map(|ticket| model_ticket_reference(&self.backend, &ticket, "TicketQueue"))
.collect::<Result<Vec<_>, _>>()?;
Ok(json_output( 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
}),
)) ))
} }
} }
@@ -1250,15 +1271,17 @@ impl Tool for TicketWorkflowStateTool {
self.backend self.backend
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change) .set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
.map_err(|error| backend_error("TicketWorkflowState", error))?; .map_err(|error| backend_error("TicketWorkflowState", error))?;
let ticket_ref =
model_ticket_reference(&self.backend, &params.ticket, "TicketWorkflowState")?;
Ok(json_output( Ok(json_output(
format!( format!(
"Transitioned ticket {} state {} -> {}", "Transitioned ticket {} state {} -> {}",
params.ticket, ticket_ref,
from.as_str(), from.as_str(),
to.as_str() to.as_str()
), ),
json!({ json!({
"ticket": params.ticket, "ticket": ticket_ref,
"from": from.as_str(), "from": from.as_str(),
"to": to.as_str(), "to": to.as_str(),
"state": to.as_str(), "state": to.as_str(),
@@ -1282,9 +1305,10 @@ impl Tool for TicketCloseTool {
MarkdownText::new(params.resolution), MarkdownText::new(params.resolution),
) )
.map_err(|error| backend_error("TicketClose", error))?; .map_err(|error| backend_error("TicketClose", error))?;
let ticket_ref = model_ticket_reference(&self.backend, &params.ticket, "TicketClose")?;
Ok(json_output( Ok(json_output(
format!("Closed ticket {}", params.ticket), format!("Closed ticket {ticket_ref}"),
json!({ "ticket": params.ticket, "state": "closed", "ok": true }), json!({ "ticket": ticket_ref, "state": "closed", "ok": true }),
)) ))
} }
} }
@@ -1511,6 +1535,29 @@ impl Tool for TicketDependencyCheckTool {
} }
} }
fn model_ticket_reference(
backend: &TicketToolBackend,
reference: &str,
tool_name: &str,
) -> Result<String, ToolError> {
let ticket = backend
.show(TicketIdOrSlug::Id(reference.to_string()))
.map_err(|error| backend_error(tool_name, error))?;
match ticket.meta.resource_key {
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
Some(_) => Err(ToolError::ExecutionFailed(format!(
"{tool_name} failed: required Ticket human key is unavailable"
))),
None => Ok(ticket.meta.id),
}
}
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
resource_key.strip_prefix("T-").is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> { fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
serde_json::from_str(input_json) serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}"))) .map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
@@ -1908,6 +1955,12 @@ mod tests {
.with_target_authority(Arc::new(TestTargetAuthority)) .with_target_authority(Arc::new(TestTargetAuthority))
} }
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
crate::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace")
.unwrap()
.with_target_authority(Arc::new(TestTargetAuthority))
}
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> { fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
let (_, tool) = definition(); let (_, tool) = definition();
tool tool
@@ -2535,6 +2588,101 @@ mod tests {
); );
} }
#[tokio::test]
async fn queue_workflow_and_close_project_internal_inputs_to_ticket_keys() {
let temp = TempDir::new().unwrap();
let inner = sqlite_backend(&temp);
let mut dependency_input = NewTicket::new("Dependency");
dependency_input.repository_id = Some("main".to_string());
let dependency = inner.create(dependency_input).unwrap();
let mut target_input = NewTicket::new("Target");
target_input.repository_id = Some("main".to_string());
let target = inner.create(target_input).unwrap();
inner
.add_ticket_relation(
TicketIdOrSlug::Id(target.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: None,
},
)
.unwrap();
for id in [&dependency.id, &target.id] {
inner
.mark_ready(
TicketIdOrSlug::Id(id.clone()),
TicketMarkReady {
operation_key: format!("ready-{id}"),
reason: None,
author: None,
intake_summary: None,
},
)
.unwrap();
}
let target_key = target.resource_key.clone().unwrap();
let dependency_key = dependency.resource_key.clone().unwrap();
let backend = inner;
let queue = tool_by_name(TicketToolBackend::new(backend.clone()), "TicketQueue");
let workflow = tool_by_name(
TicketToolBackend::new(backend.clone()),
"TicketWorkflowState",
);
let close = tool_by_name(TicketToolBackend::new(backend), "TicketClose");
let queued = queue
.execute(
&json!({"ticket": target.id.clone()}).to_string(),
Default::default(),
)
.await
.unwrap();
assert!(queued.summary.contains("2 ticket(s)"));
let queued_content = queued.content.unwrap();
assert!(queued_content.contains(&target_key));
assert!(queued_content.contains(&dependency_key));
assert!(!queued_content.contains(&target.id));
assert!(!queued_content.contains(&dependency.id));
for (from, to) in [("queued", "inprogress"), ("inprogress", "done")] {
let transitioned = workflow
.execute(
&json!({
"ticket": target.id.clone(),
"from": from,
"to": to,
"reason": "test_transition",
"body": "transitioned",
"author": "tester"
})
.to_string(),
Default::default(),
)
.await
.unwrap();
assert!(transitioned.summary.contains(&target_key));
assert!(!transitioned.summary.contains(&target.id));
let content = transitioned.content.unwrap();
assert!(content.contains(&target_key));
assert!(!content.contains(&target.id));
}
let closed = close
.execute(
&json!({"ticket": target.id.clone(), "resolution": "Done"}).to_string(),
Default::default(),
)
.await
.unwrap();
assert!(closed.summary.contains(&target_key));
assert!(!closed.summary.contains(&target.id));
let content = closed.content.unwrap();
assert!(content.contains(&target_key));
assert!(!content.contains(&target.id));
}
#[tokio::test] #[tokio::test]
async fn ticket_workflow_tools_mark_ready_and_transition_state() { async fn ticket_workflow_tools_mark_ready_and_transition_state() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
+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.
+51 -16
View File
@@ -1342,13 +1342,20 @@ impl App {
} }
} }
} }
Event::CompactStart => { Event::CompactStart { .. } => {
self.blocks.push(Block::Compact(CompactEvent::Streaming { if self.last_streaming_compact_mut().is_none() {
started_at: Instant::now(), self.blocks.push(Block::Compact(CompactEvent::Streaming {
})); 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);
} }
+1 -1
View File
@@ -1016,7 +1016,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.clear_queued_inputs(); app.clear_queued_inputs();
Some(Method::Cancel) Some(Method::Cancel)
} }
WorkerStatus::Idle => Some(Method::Shutdown), WorkerStatus::Idle | WorkerStatus::Stopped => Some(Method::Shutdown),
}), }),
KeyCode::Char('d') if ctrl => { KeyCode::Char('d') if ctrl => {
app.quit = true; app.quit = true;
+223 -130
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::{
@@ -28,7 +29,6 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
use serde::Serialize;
use session_store::FsStore; use session_store::FsStore;
use session_store::FsWorkerStore; use session_store::FsWorkerStore;
use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig}; use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig};
@@ -69,10 +69,6 @@ use render::{PanelListRow, row_hit_boxes};
const MAX_ENTRIES: usize = 50; const MAX_ENTRIES: usize = 50;
const CLOSED_VISIBLE_ROWS: usize = 3; const CLOSED_VISIBLE_ROWS: usize = 3;
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT: &str = "panel.orchestrator_idle_queue_notice";
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120;
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400;
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3); const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500); const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100); const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -580,6 +576,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,
} }
@@ -909,6 +906,7 @@ struct OrchestratorActiveWorkItem {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct OrchestratorQueuedWorkItem { struct OrchestratorQueuedWorkItem {
id: String, id: String,
resource_key: Option<String>,
title: String, title: String,
classification: OrchestratorQueuedClassification, classification: OrchestratorQueuedClassification,
waiting_reason: Option<String>, waiting_reason: Option<String>,
@@ -973,22 +971,6 @@ impl OrchestratorQueueAttentionNoticeResult {
} }
} }
#[derive(Debug, Serialize)]
struct OrchestratorQueueTemplateContext {
workspace: String,
actionable_tickets: Vec<OrchestratorQueueTemplateTicket>,
waiting_tickets: Vec<OrchestratorQueueTemplateTicket>,
omitted_ticket_count: usize,
}
#[derive(Debug, Serialize)]
struct OrchestratorQueueTemplateTicket {
id: String,
title: String,
classification: &'static str,
waiting_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct PanelRowHitBox { struct PanelRowHitBox {
rect: Rect, rect: Rect,
@@ -1324,7 +1306,16 @@ impl DashboardApp {
if self.orchestrator_work_set.is_empty() { if self.orchestrator_work_set.is_empty() {
self.refresh_orchestrator_work_set(); self.refresh_orchestrator_work_set();
} }
let notice = orchestrator_queue_attention_notice(&self.panel, &self.orchestrator_work_set)?; let notice = match orchestrator_queue_attention_notice(&self.orchestrator_work_set) {
Ok(Some(notice)) => notice,
Ok(None) => return None,
Err(error) => {
self.notice = Some(format!(
"Orchestrator queued-work attention not delivered: {error}"
));
return None;
}
};
if self if self
.orchestrator_queue_attention .orchestrator_queue_attention
.as_ref() .as_ref()
@@ -2041,11 +2032,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 +2056,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 +2084,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 +2155,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 +2217,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 +2241,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,
}) })
@@ -3644,6 +3650,7 @@ fn derive_orchestrator_work_set(
}; };
Some(OrchestratorQueuedWorkItem { Some(OrchestratorQueuedWorkItem {
id: ticket.id.clone(), id: ticket.id.clone(),
resource_key: ticket.resource_key.clone(),
title: ticket.title.clone(), title: ticket.title.clone(),
classification, classification,
waiting_reason, waiting_reason,
@@ -3727,72 +3734,46 @@ fn orchestrator_work_set_fingerprint(
} }
fn orchestrator_queue_attention_notice( fn orchestrator_queue_attention_notice(
panel: &WorkspacePanelViewModel,
work_set: &OrchestratorWorkSet, work_set: &OrchestratorWorkSet,
) -> Option<OrchestratorQueueAttentionNotice> { ) -> Result<Option<OrchestratorQueueAttentionNotice>, &'static str> {
if work_set.has_active_inprogress() { if work_set.has_active_inprogress() {
return None; return Ok(None);
} }
let actionable = work_set.actionable_queued(); let actionable = work_set.actionable_queued();
if actionable.is_empty() { if actionable.is_empty() {
return None; return Ok(None);
} }
let waiting = work_set let waiting = work_set
.queued .queued
.iter() .iter()
.filter(|item| item.waiting_reason.is_some()) .filter(|item| item.waiting_reason.is_some());
.collect::<Vec<_>>(); let tickets = actionable
let ticket_count = actionable.len() + waiting.len(); .into_iter()
let actionable_tickets = actionable .chain(waiting)
.iter() .map(|item| {
.take(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS) let resource_key = item
.map(|item| orchestrator_queue_template_ticket(item)) .resource_key
.collect::<Vec<_>>(); .clone()
let remaining_capacity = .ok_or("queued Ticket is missing its required resource key")?;
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS.saturating_sub(actionable_tickets.len()); worker::OrchestratorQueueAttentionTicket::new(resource_key, item.title.clone())
let waiting_tickets = waiting .map_err(|_| "queued Ticket has an invalid resource key")
.iter()
.take(remaining_capacity)
.map(|item| orchestrator_queue_template_ticket(item))
.collect::<Vec<_>>();
let rendered =
render_orchestrator_queue_attention_template(&OrchestratorQueueTemplateContext {
workspace: bounded_progress_text(
&panel.header.workspace_label,
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS,
),
actionable_tickets,
waiting_tickets,
omitted_ticket_count: ticket_count
.saturating_sub(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS),
}) })
.ok()?; .collect::<Result<Vec<_>, _>>()?;
let message = bounded_progress_text(&rendered, ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS); let context = worker::OrchestratorQueueAttentionContext::new(tickets);
let message = render_orchestrator_queue_attention_template(&context)
.map_err(|_| "queued-work attention prompt rendering failed")?;
let fingerprint = format!("idle-queue:{}", work_set.fingerprint); let fingerprint = format!("idle-queue:{}", work_set.fingerprint);
Some(OrchestratorQueueAttentionNotice { Ok(Some(OrchestratorQueueAttentionNotice {
message, message,
fingerprint, fingerprint,
}) }))
}
fn orchestrator_queue_template_ticket(
item: &&OrchestratorQueuedWorkItem,
) -> OrchestratorQueueTemplateTicket {
OrchestratorQueueTemplateTicket {
id: bounded_progress_text(&item.id, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
title: bounded_progress_text(&item.title, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
classification: item.classification.as_str(),
waiting_reason: item.waiting_reason.as_ref().map(|reason| {
bounded_progress_text(reason, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS)
}),
}
} }
fn render_orchestrator_queue_attention_template( fn render_orchestrator_queue_attention_template(
context: &OrchestratorQueueTemplateContext, context: &worker::OrchestratorQueueAttentionContext,
) -> Result<String, worker::CatalogError> { ) -> Result<String, worker::CatalogError> {
worker::PromptCatalog::builtins_only()? worker::PromptCatalog::builtins_only()?
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context) .orchestrator_queue_attention(worker::OrchestratorQueueAttentionPrompt::Tui, context)
} }
fn orchestrator_work_set_detail( fn orchestrator_work_set_detail(
@@ -3917,21 +3898,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 +3955,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 +3969,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 +3983,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 +3993,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 +4016,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 +4025,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 +4122,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 +4201,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 +4235,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 +4448,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
if !ticket_record_dir.join("item.md").is_file() { .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( return Err(queue_check_failed(
"target-ticket-record", "dependency-queue-plan",
ticket_id, ticket_id,
&ticket_record_dir, &root_top_level,
"target Ticket item.md is missing".to_string(), dependency_check
.queue_guard
.blocked_reason
.or(dependency_check.queue_guard.reason)
.unwrap_or_else(|| "Queue dependency validation failed".to_string()),
)); ));
} }
ensure_git_path_clean( let queue_tickets = dependency_check.queue_tickets;
"root-ticket-clean", let mut ticket_record_dirs = Vec::with_capacity(queue_tickets.len());
ticket_id, for queue_ticket in &queue_tickets {
&root_top_level, let ticket_record_dir = backend.root().join(queue_ticket);
&ticket_record_dir, if !ticket_record_dir.join("item.md").is_file() {
)?; return Err(queue_check_failed(
"target-ticket-record",
&queue_ticket,
&ticket_record_dir,
"Queue Ticket item.md is missing".to_string(),
));
}
let clean_stage = if queue_ticket == ticket_id {
"root-ticket-clean"
} else {
"queue-dependency-clean"
};
ensure_git_path_clean(
clean_stage,
&queue_ticket,
&root_top_level,
&ticket_record_dir,
)?;
ticket_record_dirs.push(ticket_record_dir);
}
Ok(PanelQueueHandoffPreflight { 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 +4585,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
&preflight.root_top_level, .ticket_record_dirs
&preflight.ticket_record_dir, .iter()
"target-ticket-record", .map(|ticket_record_dir| {
&preflight.ticket_id, path_relative_to_root(
)?; &preflight.root_top_level,
ticket_record_dir,
"target-ticket-record",
&preflight.ticket_id,
)
})
.collect::<Result<Vec<_>, _>>()?;
let mut add = Command::new("git"); 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 +4625,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 +4659,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,
@@ -5108,6 +5200,7 @@ fn row_status_label(entry: &WorkerListEntry) -> (&'static str, Style) {
.fg(Color::Cyan) .fg(Color::Cyan)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Some(WorkerStatus::Stopped) => ("live stopped", Style::default().fg(Color::DarkGray)),
None => ("live", Style::default().fg(Color::DarkGray)), None => ("live", Style::default().fg(Color::DarkGray)),
}; };
} }
+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());
} }
+118 -16
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]
@@ -2970,7 +2972,7 @@ fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
} }
#[test] #[test]
fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() { fn idle_orchestrator_gets_sanitized_attention_for_new_queued_work() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]); let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
app.panel.rows = vec![panel_test_ticket_row( app.panel.rows = vec![panel_test_ticket_row(
"00001QUEUE", "00001QUEUE",
@@ -2990,11 +2992,87 @@ fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
request request
.notice .notice
.message .message
.starts_with("Workspace Dashboard observed") .starts_with("Queued Tickets require attention:")
); );
assert!(request.notice.message.contains("00001QUEUE")); assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(request.notice.message.contains("new_queued")); assert!(
assert!(request.notice.message.contains("queued -> inprogress")); request
.notice
.message
.contains("Reread the current Ticket state before acting")
);
assert!(
!request
.notice
.message
.contains(&app.panel.header.workspace_label)
);
for hidden in [
"00001QUEUE",
"Workspace:",
"workspace_id",
"new_queued",
"bounded",
"queued -> inprogress",
] {
assert!(!request.notice.message.contains(hidden), "leaked {hidden}");
}
}
#[test]
fn queued_attention_missing_resource_key_fails_closed_with_panel_notice() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
let mut row = panel_test_ticket_row(
"00001QUEUE",
"Queued work",
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.ticket.as_mut().unwrap().resource_key = None;
app.panel.rows = vec![row];
app.refresh_orchestrator_work_set();
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
assert_eq!(
app.notice.as_deref(),
Some(
"Orchestrator queued-work attention not delivered: queued Ticket is missing its required resource key"
)
);
}
#[test]
fn queued_attention_truncates_only_when_tickets_are_omitted() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
app.panel.rows = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
.map(|index| {
let mut row = panel_test_ticket_row(
&format!("opaque-{index}"),
&format!("Queued work {index}"),
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.ticket.as_mut().unwrap().resource_key = Some(format!("T-{index}"));
row
})
.collect();
app.refresh_orchestrator_work_set();
let request = app
.prepare_orchestrator_queue_attention_notice()
.expect("bounded queued-work attention");
assert!(request.notice.message.contains("- T-20 — Queued work 20"));
assert!(!request.notice.message.contains("T-21"));
assert!(
request
.notice
.message
.contains("were omitted from this notice: 1")
);
assert!(!request.notice.message.contains("opaque-"));
} }
#[test] #[test]
@@ -3084,7 +3162,9 @@ fn planned_queued_prompts_when_active_work_clears() {
.prepare_orchestrator_queue_attention_notice() .prepare_orchestrator_queue_attention_notice()
.expect("planned queued work should prompt after active work clears"); .expect("planned queued work should prompt after active work clears");
assert!(request.notice.message.contains("planned_queued")); assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(!request.notice.message.contains("planned_queued"));
assert!(!request.notice.message.contains("00001QUEUE"));
assert!( assert!(
!request !request
.notice .notice
@@ -3139,8 +3219,9 @@ fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() {
.prepare_orchestrator_queue_attention_notice() .prepare_orchestrator_queue_attention_notice()
.expect("queued ticket state should be rediscovered safely"); .expect("queued ticket state should be rediscovered safely");
assert!(request.notice.message.contains("new_queued")); assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(request.notice.message.contains("00001QUEUE")); assert!(!request.notice.message.contains("new_queued"));
assert!(!request.notice.message.contains("00001QUEUE"));
} }
#[test] #[test]
@@ -3426,6 +3507,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()
+11 -12
View File
@@ -1530,6 +1530,7 @@ fn worker_status_label(entry: &WorkerListEntry) -> &'static str {
Some(WorkerStatus::Idle) => "live idle", Some(WorkerStatus::Idle) => "live idle",
Some(WorkerStatus::Running) => "live running", Some(WorkerStatus::Running) => "live running",
Some(WorkerStatus::Paused) => "live paused", Some(WorkerStatus::Paused) => "live paused",
Some(WorkerStatus::Stopped) => "live stopped",
None => "live", None => "live",
}; };
} }
@@ -2203,7 +2204,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 +2236,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 +2249,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 +2282,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")
+55 -13
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))?;
factory = factory.with_remote_worker_mutation_identity(identity); 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);
}
} }
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( _ => {
"unsupported Runtime catalog store selection".to_string(), return Err(ProcessError::usage(
)), "unsupported Runtime catalog store selection".to_string(),
));
}
};
if let Some(client) = backend_resource_client {
runtime
.install_backend_resource_client(client)
.map_err(ProcessError::Runtime)?;
} }
Ok(runtime)
} }
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions { 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),
}) })
} }
+449 -4
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(),
@@ -2619,6 +2742,7 @@ impl RuntimeState {
protocol::WorkerStatus::Running => Some(WorkerStatus::Running), protocol::WorkerStatus::Running => Some(WorkerStatus::Running),
protocol::WorkerStatus::Idle => Some(WorkerStatus::Idle), protocol::WorkerStatus::Idle => Some(WorkerStatus::Idle),
protocol::WorkerStatus::Paused => Some(WorkerStatus::Paused), protocol::WorkerStatus::Paused => Some(WorkerStatus::Paused),
protocol::WorkerStatus::Stopped => Some(WorkerStatus::Stopped),
}, },
protocol::Event::RunEnd { result } => match result { protocol::Event::RunEnd { result } => match result {
protocol::RunResult::Finished | protocol::RunResult::RolledBack => { protocol::RunResult::Finished | protocol::RunResult::RolledBack => {
@@ -2714,6 +2838,33 @@ fn worker_status_from_run_state(run_state: WorkerExecutionRunState) -> WorkerSta
} }
} }
fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
let category = match error {
BackendResourceError::Expired => "expired",
BackendResourceError::Unauthorized { .. } => "unauthorized",
BackendResourceError::UnsupportedKind => "unsupported_kind",
BackendResourceError::MissingResource => "missing_resource",
BackendResourceError::Oversized { .. } => "oversized",
BackendResourceError::DigestMismatch { .. } => "digest_mismatch",
BackendResourceError::ContentTypeMismatch { .. } => "content_type_mismatch",
BackendResourceError::InvalidResponse { .. } => "invalid_response",
BackendResourceError::Transport { .. } => "transport",
};
RuntimeError::InvalidRequest(format!(
"Backend Repository SSH access resource fetch failed: {category}"
))
}
fn durable_create_worker_request(request: &CreateWorkerRequest) -> CreateWorkerRequest {
let mut durable = request.clone();
if let Some(working_directory) = durable.working_directory_request.as_mut()
&& let Some(materialization) = working_directory.materialization.as_mut()
{
materialization.ssh = None;
}
durable
}
fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> { fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
request request
.working_directory .working_directory
@@ -2884,7 +3035,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 +3047,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};
@@ -2950,7 +3105,7 @@ mod tests {
&mut activity, &mut activity,
&internal_worker_status_event( &internal_worker_status_event(
internal_worker_ref("child-b", None), internal_worker_ref("child-b", None),
protocol::WorkerStatus::Idle, protocol::WorkerStatus::Stopped,
), ),
)); ));
} }
@@ -3115,6 +3270,243 @@ mod tests {
} }
} }
#[test]
fn durable_worker_request_omits_repository_credentials() {
let mut request = task_request("worker-secret-redaction");
request.working_directory_request = Some(WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: repository_resource_handle(),
private_key: SensitiveString::new("private-key-bytes"),
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
}),
}),
});
let durable = durable_create_worker_request(&request);
assert!(
request
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_some()
);
assert!(
durable
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_none()
);
let serialized = serde_json::to_string(&durable).unwrap();
assert!(!serialized.contains("private-key-bytes"));
assert!(!serialized.contains("known-hosts-entry"));
}
fn repository_resource_handle() -> crate::resource::BackendResourceHandle {
crate::resource::BackendResourceHandle {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
workspace_id: "workspace-1".to_string(),
scope_id: Some("repository-ssh-access".to_string()),
runtime_id: Some("runtime-1".to_string()),
worker_id: None,
resource_id: "repository-access-1".to_string(),
digest: "opaque:repository-access-1".to_string(),
operation: crate::resource::BackendResourceOperation::FetchOnce,
expires_at_unix_seconds: i64::MAX,
nonce: "repository-access-1".to_string(),
revision: "1".to_string(),
generation: None,
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id: "repository-access-1".to_string(),
profile_source_graph: None,
}
}
#[tokio::test]
async fn repository_access_resource_is_fetched_before_provider_authorization() {
let (runtime, backend) = runtime_and_backend();
backend
.repository_access_available
.store(true, Ordering::SeqCst);
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "private-key-bytes".to_string(),
known_hosts_entry: "known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
.unwrap();
let request = WorkingDirectoryRepositoryAccessRequest {
working_directory_id: "working-directory-1".to_string(),
materialization: RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
},
};
let replay = request.clone();
runtime
.authorize_working_directory_repository_access_from_resource(request)
.await
.unwrap();
assert!(
runtime
.authorize_working_directory_repository_access_from_resource(replay)
.await
.is_err()
);
let accesses = backend.repository_accesses.lock().unwrap();
assert_eq!(accesses.len(), 1);
let access = accesses[0].materialization.ssh.as_ref().unwrap();
assert_eq!(access.private_key.expose(), "private-key-bytes");
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
}
#[tokio::test]
async fn working_directory_create_fetches_repository_access_before_provider_call() {
let (runtime, backend) = runtime_and_backend();
backend
.repository_access_available
.store(true, Ordering::SeqCst);
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "create-private-key-bytes".to_string(),
known_hosts_entry: "create-known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
.unwrap();
let request = WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-create".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
}),
};
assert!(
runtime
.create_working_directory_from_resource(request)
.await
.is_err()
);
let requests = backend.working_directory_requests.lock().unwrap();
let access = requests[0]
.materialization
.as_ref()
.and_then(|materialization| materialization.ssh.as_ref())
.unwrap();
assert_eq!(access.private_key.expose(), "create-private-key-bytes");
assert_eq!(
access.known_hosts_entry.expose(),
"create-known-hosts-entry"
);
}
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest { 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 +3588,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 +3631,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 +3770,24 @@ mod tests {
} }
} }
struct TestRepositoryResourceClient {
response: Mutex<Option<crate::resource::BackendResourceFetchResponse>>,
}
#[async_trait]
impl BackendResourceClient for TestRepositoryResourceClient {
async fn fetch_resource(
&self,
_request: BackendResourceFetchRequest,
) -> Result<crate::resource::BackendResourceFetchResponse, BackendResourceError> {
self.response
.lock()
.unwrap()
.take()
.ok_or(BackendResourceError::MissingResource)
}
}
fn runtime_with_backend() -> Runtime { fn runtime_with_backend() -> Runtime {
let runtime = Runtime::with_execution_backend( let runtime = Runtime::with_execution_backend(
RuntimeOptions::default(), RuntimeOptions::default(),
+159 -30
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,
@@ -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,12 +512,19 @@ 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(
.await? location,
.ok_or_else(|| { None,
"profile source archive HTTP revalidation returned 304 without a cached archive" self.worker_mutation_identity.as_ref(),
.to_string() self.runtime_request_audience
})?; .as_deref()
.or(request_audience),
)
.await?
.ok_or_else(|| {
"profile source archive HTTP revalidation returned 304 without a cached archive"
.to_string()
})?;
self.profile_archive_cache.insert(archive.clone()); self.profile_archive_cache.insert(archive.clone());
archive archive
.verify() .verify()
@@ -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);
@@ -1456,7 +1546,9 @@ fn accepted_notify_run_state(status: WorkerStatus, auto_run: bool) -> WorkerExec
match status { match status {
WorkerStatus::Running => WorkerExecutionRunState::Busy, WorkerStatus::Running => WorkerExecutionRunState::Busy,
WorkerStatus::Idle if auto_run => WorkerExecutionRunState::Busy, WorkerStatus::Idle if auto_run => WorkerExecutionRunState::Busy,
WorkerStatus::Idle | WorkerStatus::Paused => WorkerExecutionRunState::Idle, WorkerStatus::Idle | WorkerStatus::Paused | WorkerStatus::Stopped => {
WorkerExecutionRunState::Idle
}
} }
} }
@@ -1500,6 +1592,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 +1647,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 +2167,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 +2294,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 +2496,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((
@@ -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");
} }
@@ -3237,7 +3360,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 +3515,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 +3554,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 +3590,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 +3604,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
View File
@@ -66,6 +66,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
WorkerRunResult::Finished => println!("(finished)"), WorkerRunResult::Finished => println!("(finished)"),
WorkerRunResult::Paused => println!("(paused)"), WorkerRunResult::Paused => println!("(paused)"),
WorkerRunResult::LimitReached => println!("(turn limit reached)"), WorkerRunResult::LimitReached => println!("(turn limit reached)"),
WorkerRunResult::Interrupted { message, .. } => println!("(interrupted: {message})"),
WorkerRunResult::RolledBack => println!("(empty turn rolled back)"), WorkerRunResult::RolledBack => println!("(empty turn rolled back)"),
} }
+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), "");
}
} }
+41 -5
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();
@@ -1338,7 +1354,7 @@ async fn controller_loop<C, St>(
}); });
} }
}, },
WorkerStatus::Idle => { WorkerStatus::Idle | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = event_tx.send(Event::Error {
code: ErrorCode::NotRunning, code: ErrorCode::NotRunning,
message: "Worker is not running".into(), message: "Worker is not running".into(),
@@ -1379,7 +1395,7 @@ async fn controller_loop<C, St>(
.into(), .into(),
}); });
} }
WorkerStatus::Running => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: message:
@@ -1393,7 +1409,7 @@ async fn controller_loop<C, St>(
WorkerStatus::Idle | WorkerStatus::Paused => { WorkerStatus::Idle | WorkerStatus::Paused => {
emit_rewind_targets(&worker, &event_tx) emit_rewind_targets(&worker, &event_tx)
} }
WorkerStatus::Running => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused" message: "Worker is already executing a turn; rewind can only run while idle or paused"
@@ -1422,7 +1438,7 @@ async fn controller_loop<C, St>(
.into(), .into(),
}); });
} }
WorkerStatus::Running => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused" message: "Worker is already executing a turn; rewind can only run while idle or paused"
@@ -1634,6 +1650,26 @@ where
WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused), WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused),
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached), WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack), WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack),
WorkerRunResult::Interrupted { .. } if pause_requested => {
let _ = event_tx.send(Event::RunEnd { result: RunResult::Paused });
return (WorkerStatus::Paused, shutdown_requested);
}
WorkerRunResult::Interrupted { code, message } => {
let _ = event_tx.send(Event::Error {
code,
message: message.clone(),
});
if parent_originated {
crate::ipc::event::fire_and_forget(
parent_socket.cloned(),
protocol::WorkerEvent::Errored {
worker_name: self_name.to_string(),
message,
},
);
}
return (WorkerStatus::Idle, shutdown_requested);
}
}; };
let _ = event_tx.send(Event::RunEnd { result: run_result }); let _ = event_tx.send(Event::RunEnd { result: run_result });
if parent_originated && matches!(run_result, RunResult::Finished) { if parent_originated && matches!(run_result, RunResult::Finished) {
+632 -83
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,30 +1440,34 @@ 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()
report || self.reports.iter().any(|report| {
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
})
}
pub fn error_message(&self) -> String {
self.reports
.iter()
.flat_map(|report| {
report report
.diagnostics .diagnostics
.iter() .iter()
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error) .any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
}) })
.collect::<Vec<_>>() }
.join("; ")
pub fn error_message(&self) -> String {
let mut errors = self
.plan_error
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
errors.extend(self.reports.iter().flat_map(|report| {
report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
}));
errors.join("; ")
} }
pub fn installed_tool_names(&self) -> Vec<String> { 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,6 +1790,10 @@ 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>(
self, self,
@@ -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()
.position(|(_, descriptor)| {
descriptor
.requires_services
.iter()
.filter(|requirement| requirement.required)
.all(|requirement| declared_services.contains(&requirement.id))
})
.unwrap_or(0);
let entry = pending_modules.remove(next);
declared_services.extend(
entry
.1
.provides_services
.iter() .iter()
.map(|service| service.id.clone()), .map(|descriptor| {
); let mut report = FeatureInstallReport::new(descriptor);
ordered_modules.push(entry); report
} .diagnostics
.push(FeatureDiagnostic::error(message.clone()));
report.mark_skipped(
FeatureContributionKind::Diagnostic,
descriptor.id.to_string(),
"feature installation plan rejected before installation",
);
report
})
.collect();
return FeatureRegistryInstallReport {
reports,
services: FeatureServiceRegistry::default(),
plan_error: Some(error),
};
}
};
let mut service_registry = FeatureServiceRegistry::default();
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
let ordered_modules = plan
.ordered_indices
.into_iter()
.map(|index| {
(
modules[index]
.take()
.expect("planned feature module is selected exactly once"),
descriptors[index].clone(),
)
})
.collect::<Vec<_>>();
for (module, descriptor) in ordered_modules { 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]
+1
View File
@@ -12,6 +12,7 @@ pub mod memory_extract;
pub mod merge_request; pub mod merge_request;
pub mod objective; pub mod objective;
pub mod orchestration; pub mod orchestration;
mod resource_projection;
pub mod session_explore; pub mod session_explore;
pub mod task; pub mod task;
pub mod ticket; pub mod ticket;
@@ -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 {
repository_id: "main".to_string(), runtime_id: Some("runtime/one".to_string()),
selector: Some("refs/heads/topic".to_string()), repository_id: "main".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 {
repository_id: "main".to_string(), runtime_id: Some(" ".to_string()),
selector: None, repository_id: "main".to_string(),
}) 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());
@@ -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();
+210 -25
View File
@@ -14,6 +14,8 @@ use serde_json::json;
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use super::resource_projection::{project_objective_detail, project_objective_query};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct WorkspaceHttpObjectiveBackend { pub struct WorkspaceHttpObjectiveBackend {
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
@@ -37,6 +39,7 @@ impl WorkspaceHttpObjectiveBackend {
) )
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
let response = project_objective_query(response).map_err(ToolError::ExecutionFailed)?;
Ok(ToolOutput { Ok(ToolOutput {
summary: "Queried Objectives".to_string(), summary: "Queried Objectives".to_string(),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
@@ -58,8 +61,10 @@ impl WorkspaceHttpObjectiveBackend {
) )
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
let response = project_objective_detail(response).map_err(ToolError::ExecutionFailed)?;
let objective_ref = response.objective_ref().to_string();
Ok(ToolOutput { Ok(ToolOutput {
summary: format!("Read objective {id}"), summary: format!("Read objective {objective_ref}"),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(), attachments: Vec::new(),
}) })
@@ -84,7 +89,7 @@ impl WorkspaceHttpObjectiveBackend {
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Created objective {}", response.id), format!("Created objective {}", &response.resource_key),
response, response,
)?) )?)
} }
@@ -112,7 +117,7 @@ impl WorkspaceHttpObjectiveBackend {
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Edited objective {}", response.id), format!("Edited objective {}", &response.resource_key),
response, response,
)?) )?)
} }
@@ -134,7 +139,7 @@ impl WorkspaceHttpObjectiveBackend {
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Updated objective {} state", response.id), format!("Updated objective {} state", &response.resource_key),
response, response,
)?) )?)
} }
@@ -142,6 +147,7 @@ impl WorkspaceHttpObjectiveBackend {
async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> { async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveLinkTicket")?; let id = validate_id(&input.id, "ObjectiveLinkTicket")?;
let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?;
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
let url = format!("{}/ticket-links", self.objective_url(id)); let url = format!("{}/ticket-links", self.objective_url(id));
let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>( let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
self.client.as_ref(), self.client.as_ref(),
@@ -154,7 +160,10 @@ impl WorkspaceHttpObjectiveBackend {
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Linked ticket {ticket_id} to objective {}", response.id), format!(
"Linked ticket {ticket_resource_key} to objective {}",
&response.resource_key
),
response, response,
)?) )?)
} }
@@ -165,16 +174,46 @@ impl WorkspaceHttpObjectiveBackend {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?; let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?;
let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?;
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id); let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id);
let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url) let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url)
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Unlinked ticket {ticket_id} from objective {}", response.id), format!(
"Unlinked ticket {ticket_resource_key} from objective {}",
&response.resource_key
),
response, response,
)?) )?)
} }
async fn ticket_resource_key(&self, ticket_reference: &str) -> Result<String, ToolError> {
let workspace_id = self.client.workspace_id().unwrap_or_default();
let response: serde_json::Value = decode_response(
self.client
.execute(WorkspaceRequest::get(format!(
"/api/w/{workspace_id}/tickets/{ticket_reference}"
)))
.map_err(WorkspaceObjectiveBackendError::from)
.map_err(backend_error)?,
)
.map_err(backend_error)?;
response
.get("resource_key")
.or_else(|| {
response
.get("meta")
.and_then(|meta| meta.get("resource_key"))
})
.and_then(serde_json::Value::as_str)
.filter(|key| is_canonical_resource_key(key, "T-"))
.map(ToOwned::to_owned)
.ok_or_else(|| {
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
})
}
fn objective_url(&self, id: &str) -> String { fn objective_url(&self, id: &str) -> String {
let workspace_id = self.client.workspace_id().unwrap_or_default(); let workspace_id = self.client.workspace_id().unwrap_or_default();
format!("/api/w/{workspace_id}/objectives/{id}") format!("/api/w/{workspace_id}/objectives/{id}")
@@ -185,7 +224,7 @@ impl WorkspaceHttpObjectiveBackend {
pub enum WorkspaceObjectiveBackendError { pub enum WorkspaceObjectiveBackendError {
#[error("workspace objective backend request failed: {0}")] #[error("workspace objective backend request failed: {0}")]
Request(#[from] crate::worker::WorkspaceClientError), Request(#[from] crate::worker::WorkspaceClientError),
#[error("workspace objective backend returned HTTP {status}: {body}")] #[error("workspace objective backend returned HTTP {status}")]
Http { Http {
status: reqwest::StatusCode, status: reqwest::StatusCode,
body: String, body: String,
@@ -247,10 +286,26 @@ fn decode_response<T: for<'de> Deserialize<'de>>(
serde_json::from_str(&response.body).map_err(Into::into) serde_json::from_str(&response.body).map_err(Into::into)
} }
fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
resource_key.strip_prefix(prefix).is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> { fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
if !is_canonical_resource_key(&response.resource_key, "O-") {
return Err(ToolError::ExecutionFailed(
"required O- human key is unavailable".to_string(),
));
}
let projected = serde_json::json!({
"objective": &response.resource_key,
"title": response.title,
"state": response.state,
});
Ok(ToolOutput { Ok(ToolOutput {
summary, summary,
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), content: Some(serde_json::to_string_pretty(&projected).map_err(decode_error)?),
attachments: Vec::new(), attachments: Vec::new(),
}) })
@@ -260,7 +315,7 @@ fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> {
let id = id.trim(); let id = id.trim();
if id.is_empty() || id.contains('/') { if id.is_empty() || id.contains('/') {
return Err(ToolError::InvalidArgument(format!( return Err(ToolError::InvalidArgument(format!(
"{tool_name} requires non-empty canonical id without '/'" "{tool_name} requires a non-empty Objective reference without '/'"
))); )));
} }
Ok(id) Ok(id)
@@ -411,9 +466,9 @@ const EDIT_DESCRIPTION: &str =
const SET_STATE_DESCRIPTION: &str = const SET_STATE_DESCRIPTION: &str =
"Set an Objective state through Backend Workspace API authority."; "Set an Objective state through Backend Workspace API authority.";
const LINK_TICKET_DESCRIPTION: &str = const LINK_TICKET_DESCRIPTION: &str =
"Link a Ticket id to an Objective through Backend Workspace API authority."; "Link a Ticket reference to an Objective through Backend Workspace API authority.";
const UNLINK_TICKET_DESCRIPTION: &str = const UNLINK_TICKET_DESCRIPTION: &str =
"Unlink a Ticket id from an Objective through Backend Workspace API authority."; "Unlink a Ticket reference from an Objective through Backend Workspace API authority.";
fn list_schema() -> serde_json::Value { fn list_schema() -> serde_json::Value {
json!({ json!({
@@ -422,7 +477,7 @@ fn list_schema() -> serde_json::Value {
"properties":{ "properties":{
"query":{"type":["string","null"]}, "query":{"type":["string","null"]},
"states":{"type":"array","items":{"type":"string"},"default":[]}, "states":{"type":"array","items":{"type":"string"},"default":[]},
"linked_ticket_id":{"type":["string","null"]}, "linked_ticket_id":{"type":["string","null"],"description":"Linked Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."},
"updated_after":{"type":["string","null"]}, "updated_after":{"type":["string","null"]},
"updated_before":{"type":["string","null"]}, "updated_before":{"type":["string","null"]},
"sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]}, "sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]},
@@ -438,7 +493,7 @@ fn show_schema() -> serde_json::Value {
"additionalProperties": false, "additionalProperties": false,
"required":["id"], "required":["id"],
"properties":{ "properties":{
"id":{"type":"string"}, "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"event_limit":{"type":["integer","null"],"minimum":1,"maximum":50}, "event_limit":{"type":["integer","null"],"minimum":1,"maximum":50},
"event_cursor":{"type":["string","null"]} "event_cursor":{"type":["string","null"]}
} }
@@ -454,7 +509,7 @@ fn create_schema() -> serde_json::Value {
"title":{"type":"string","minLength":1}, "title":{"type":"string","minLength":1},
"body_md":{"type":"string"}, "body_md":{"type":"string"},
"state":{"type":"string","default":"active"}, "state":{"type":"string","default":"active"},
"linked_tickets":{"type":"array","items":{"type":"string"}} "linked_tickets":{"type":"array","items":{"type":"string"},"description":"Linked Ticket references. Prefer T-*; canonical internal ids remain accepted for compatibility."}
} }
}) })
} }
@@ -465,7 +520,7 @@ fn edit_schema() -> serde_json::Value {
"additionalProperties": false, "additionalProperties": false,
"required":["id"], "required":["id"],
"properties":{ "properties":{
"id":{"type":"string"}, "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"title":{"type":["string","null"]}, "title":{"type":["string","null"]},
"old_string":{"type":["string","null"]}, "old_string":{"type":["string","null"]},
"new_string":{"type":["string","null"]}, "new_string":{"type":["string","null"]},
@@ -480,7 +535,7 @@ fn set_state_schema() -> serde_json::Value {
"additionalProperties": false, "additionalProperties": false,
"required":["id","state"], "required":["id","state"],
"properties":{ "properties":{
"id":{"type":"string"}, "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"state":{"type":"string","minLength":1} "state":{"type":"string","minLength":1}
} }
}) })
@@ -500,8 +555,8 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
"additionalProperties": false, "additionalProperties": false,
"required": required, "required": required,
"properties":{ "properties":{
"id":{"type":"string"}, "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"ticket_id":{"type":"string"} "ticket_id":{"type":"string","description":"Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."}
} }
}) })
} }
@@ -595,21 +650,20 @@ fn default_state() -> String {
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveDetail { struct ObjectiveDetail {
id: String, resource_key: String,
title: String, title: String,
state: String, state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<String>,
body: String,
body_truncated: bool,
record_source: String,
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use agen::tool::ToolDefinition; use agen::tool::ToolDefinition;
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> { fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
let mut names = definitions let mut names = definitions
@@ -656,4 +710,135 @@ mod tests {
let link = link_ticket_schema(); let link = link_ticket_schema();
assert_eq!(link["required"], json!(["id", "ticket_id"])); assert_eq!(link["required"], json!(["id", "ticket_id"]));
} }
#[tokio::test(flavor = "multi_thread")]
async fn objective_show_summary_uses_projected_human_key() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(
request.starts_with("POST /api/w/workspace/objectives/00001INTERNAL/show HTTP/1.1")
);
let body = serde_json::json!({
"id": "00001INTERNAL",
"resource_key": "O-3",
"title": "Objective",
"body": "Body",
"state": "active",
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
"events": [],
"event_page": {"next_cursor": null, "has_more": false}
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
));
let output = backend
.show(ShowObjectiveInput {
id: "00001INTERNAL".to_string(),
event_limit: None,
event_cursor: None,
})
.await
.unwrap();
server.join().unwrap();
assert_eq!(output.summary, "Read objective O-3");
assert!(!output.summary.contains("00001INTERNAL"));
assert!(!output.content.unwrap().contains("00001INTERNAL"));
}
#[tokio::test(flavor = "multi_thread")]
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
for mutation in ["POST", "DELETE"] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("GET /api/w/workspace/tickets/00001INTERNAL HTTP/1.1"));
let response_body = serde_json::json!({"resource_key": "T-7"}).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with(&format!(
"{mutation} /api/w/workspace/objectives/O-3/ticket-links"
)));
let response_body = serde_json::json!({
"resource_key": "O-3",
"title": "Objective",
"state": "active"
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
}
});
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
));
let linked = backend
.link_ticket(ObjectiveLinkTicketInput {
id: "O-3".to_string(),
ticket_id: "00001INTERNAL".to_string(),
})
.await
.unwrap();
let unlinked = backend
.unlink_ticket(ObjectiveUnlinkTicketInput {
id: "O-3".to_string(),
ticket_id: "00001INTERNAL".to_string(),
})
.await
.unwrap();
server.join().unwrap();
for output in [linked, unlinked] {
assert!(output.summary.contains("T-7"));
assert!(!output.summary.contains("00001INTERNAL"));
assert!(!output.content.unwrap().contains("00001INTERNAL"));
}
}
#[test]
fn objective_output_rejects_noncanonical_human_keys() {
let response = ObjectiveDetail {
resource_key: "O-internal".to_string(),
title: "Objective".to_string(),
state: "active".to_string(),
};
assert!(objective_output("created".to_string(), response).is_err());
}
} }
@@ -89,18 +89,19 @@ impl Tool for SpawnTicketCoderTool {
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| { 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]
@@ -0,0 +1,794 @@
use serde::Serialize;
use serde_json::{Map, Value};
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketQueryResponse {
tickets: Vec<ModelTicketQueryItem>,
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelTicketQueryItem {
ticket: String,
title: String,
state: String,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
workspace_action_priority: Option<String>,
matched_fields: Vec<String>,
snippet: Option<String>,
current_coder: Option<ModelWorkerSummary>,
linked_objectives: Vec<String>,
relation_count: usize,
blocker_count: usize,
unresolved_blocker_count: usize,
unresolved_review_count: usize,
evidence: Option<ModelTicketEvidence>,
merge_request: Option<ModelMergeRequest>,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail {
ticket: String,
title: String,
body: String,
state: String,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
thread: Vec<ModelTicketEvent>,
relations: ModelTicketRelations,
linked_objectives: Vec<ModelObjectiveSummary>,
assignments: Vec<ModelAssignment>,
current_coder: Option<ModelWorkerSummary>,
implementation_reports: Vec<ModelEvidenceEvent>,
merge_request: Option<ModelMergeRequest>,
evidence: Option<ModelTicketEvidence>,
actions: Option<ModelTicketActions>,
event_page: Option<ModelEventPage>,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelObjectiveQueryResponse {
objectives: Vec<ModelObjectiveQueryItem>,
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveQueryItem {
objective: String,
title: String,
summary: Option<String>,
state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<String>,
linked_ticket_count: usize,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelObjectiveDetail {
objective: String,
title: String,
body: String,
state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<ModelTicketSummary>,
events: Vec<ModelObjectiveEvent>,
event_page: ModelObjectiveEventPage,
}
impl ModelObjectiveDetail {
pub(super) fn objective_ref(&self) -> &str {
&self.objective
}
}
#[derive(Debug, Serialize)]
struct ModelWorkerSummary {
worker: String,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvent {
sequence: usize,
kind: String,
body: Option<String>,
created_at: Option<String>,
}
#[derive(Debug, Serialize, Default)]
struct ModelTicketRelations {
outgoing: Vec<ModelRelation>,
incoming: Vec<ModelRelation>,
blockers: Vec<ModelBlocker>,
notices: Vec<ModelNotice>,
}
#[derive(Debug, Serialize)]
struct ModelRelation {
ticket: String,
kind: String,
note: Option<String>,
created_at: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelBlocker {
ticket: String,
kind: String,
state: Option<String>,
resolved: bool,
}
#[derive(Debug, Serialize)]
struct ModelNotice {
kind: String,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveSummary {
objective: String,
title: String,
state: String,
}
#[derive(Debug, Serialize)]
struct ModelTicketSummary {
ticket: String,
title: String,
state: String,
}
#[derive(Debug, Serialize)]
struct ModelAssignment {
role: String,
principal: String,
assigned_at: String,
}
#[derive(Debug, Serialize)]
struct ModelEvidenceEvent {
sequence: usize,
kind: String,
created_at: Option<String>,
excerpt: String,
}
#[derive(Debug, Serialize)]
struct ModelMergeRequest {
state: String,
selector_from: Option<String>,
selector_to: String,
review_status: String,
subject_ref: Option<String>,
review_excerpt: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvidence {
has_merge_request: bool,
has_current_subject_ref: bool,
has_review_request: bool,
has_commit: bool,
review_status: Option<String>,
approved_current_subject: bool,
unresolved_request_changes: bool,
complete_for_integration: bool,
missing: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ModelTicketActions {
can_assign_orchestrator: bool,
can_unassign_orchestrator: bool,
can_queue: bool,
can_start_manual_coder: bool,
}
#[derive(Debug, Serialize)]
struct ModelEventPage {
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveEvent {
kind: String,
created_at: String,
body: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveEventPage {
next_cursor: Option<String>,
has_more: bool,
}
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
let root = object(&value, "Ticket query response")?;
let page = object_field(root, "page")?;
let tickets = array_field(root, "items")?
.iter()
.map(project_ticket_query_item)
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketQueryResponse {
tickets,
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
let item = object(value, "Ticket query item")?;
Ok(ModelTicketQueryItem {
ticket: human_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?,
state: string_field(item, "state")?,
readiness: optional_string(item, "readiness")?,
priority: optional_string(item, "priority")?,
created_at: optional_string(item, "created_at")?,
updated_at: optional_string(item, "updated_at")?,
workspace_action_priority: optional_string(item, "workspace_action_priority")?,
matched_fields: string_array(item, "matched_fields")?,
snippet: optional_string(item, "snippet")?,
current_coder: item
.get("current_coder")
.filter(|value| !value.is_null())
.map(project_worker)
.transpose()?,
linked_objectives: string_array(item, "linked_objective_keys")?
.into_iter()
.map(|key| validate_human_ref(key, "O-"))
.collect::<Result<Vec<_>, _>>()?,
relation_count: usize_field(item, "relation_count")?,
blocker_count: usize_field(item, "blocker_count")?,
unresolved_blocker_count: usize_field(item, "unresolved_blocker_count")?,
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
evidence: item.get("evidence").map(project_evidence).transpose()?,
merge_request: item
.get("merge_request")
.filter(|value| !value.is_null())
.map(project_merge_request)
.transpose()?,
})
}
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
let root = object(&value, "Ticket detail response")?;
let current_coder = root
.get("current_coder")
.filter(|value| !value.is_null())
.map(project_worker)
.transpose()?;
let assignments = array_field(root, "assignments")?
.iter()
.map(|assignment| project_assignment(assignment, current_coder.as_ref()))
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketDetail {
ticket: human_ref(root, "resource_key", "T-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
readiness: optional_string(root, "readiness")?,
priority: optional_string(root, "priority")?,
created_at: optional_string(root, "created_at")?,
updated_at: optional_string(root, "updated_at")?,
thread: array_field(root, "events")?
.iter()
.map(project_ticket_event)
.collect::<Result<Vec<_>, _>>()?,
relations: project_relations(root.get("relations"))?,
linked_objectives: array_field(root, "linked_objectives")?
.iter()
.map(project_objective_summary)
.collect::<Result<Vec<_>, _>>()?,
assignments,
current_coder,
implementation_reports: array_field(root, "implementation_reports")?
.iter()
.map(project_evidence_event)
.collect::<Result<Vec<_>, _>>()?,
merge_request: root
.get("merge_request")
.filter(|value| !value.is_null())
.map(project_merge_request)
.transpose()?,
evidence: root.get("evidence").map(project_evidence).transpose()?,
actions: root
.get("action_eligibility")
.filter(|value| !value.is_null())
.map(project_actions)
.transpose()?,
event_page: root
.get("event_page")
.filter(|value| !value.is_null())
.map(project_event_page)
.transpose()?,
})
}
pub(super) fn project_objective_query(value: Value) -> Result<ModelObjectiveQueryResponse, String> {
let root = object(&value, "Objective query response")?;
let page = object_field(root, "page")?;
Ok(ModelObjectiveQueryResponse {
objectives: array_field(root, "items")?
.iter()
.map(project_objective_query_item)
.collect::<Result<Vec<_>, _>>()?,
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem, String> {
let item = object(value, "Objective query item")?;
let linked_tickets = string_array(item, "linked_ticket_keys")?
.into_iter()
.map(|key| validate_human_ref(key, "T-"))
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelObjectiveQueryItem {
objective: human_ref(item, "resource_key", "O-")?,
title: string_field(item, "title")?,
summary: optional_string(item, "snippet")?,
state: string_field(item, "state")?,
created_at: optional_string(item, "created_at")?,
updated_at: optional_string(item, "updated_at")?,
linked_ticket_count: linked_tickets.len(),
linked_tickets,
})
}
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
let root = object(&value, "Objective detail response")?;
Ok(ModelObjectiveDetail {
objective: human_ref(root, "resource_key", "O-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
created_at: optional_string(root, "created_at")?,
updated_at: optional_string(root, "updated_at")?,
linked_tickets: array_field(root, "linked_ticket_summaries")?
.iter()
.map(project_ticket_summary)
.collect::<Result<Vec<_>, _>>()?,
events: array_field(root, "events")?
.iter()
.map(project_objective_event)
.collect::<Result<Vec<_>, _>>()?,
event_page: project_objective_event_page(
root.get("event_page")
.ok_or_else(|| "Objective detail response is missing event_page".to_string())?,
)?,
})
}
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
let worker = object(value, "Worker summary")?;
Ok(ModelWorkerSummary {
worker: human_ref(worker, "worker_resource_key", "W-")?,
})
}
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
let event = object(value, "Ticket event")?;
Ok(ModelTicketEvent {
sequence: usize_field(event, "sequence")?,
kind: string_field(event, "kind")?,
body: match event.get("body") {
None | Some(Value::Null) => None,
Some(Value::String(body)) => Some(body.clone()),
Some(_) => return Err("invalid Ticket event body".to_string()),
},
created_at: optional_string(event, "at")?,
})
}
fn project_relations(value: Option<&Value>) -> Result<ModelTicketRelations, String> {
let Some(value) = value else {
return Ok(ModelTicketRelations::default());
};
let relations = object(value, "Ticket relations")?;
Ok(ModelTicketRelations {
outgoing: array_field(relations, "outgoing")?
.iter()
.map(|value| project_relation(value, "target_resource_key", "kind"))
.collect::<Result<Vec<_>, _>>()?,
incoming: array_field(relations, "incoming")?
.iter()
.map(|value| project_relation(value, "source_resource_key", "forward_kind"))
.collect::<Result<Vec<_>, _>>()?,
blockers: array_field(relations, "blockers")?
.iter()
.map(project_blocker)
.collect::<Result<Vec<_>, _>>()?,
notices: array_field(relations, "notices")?
.iter()
.map(project_notice)
.collect::<Result<Vec<_>, _>>()?,
})
}
fn project_relation(
value: &Value,
ticket_key: &str,
kind_key: &str,
) -> Result<ModelRelation, String> {
let relation = object(value, "Ticket relation")?;
let relation_data = relation.get("relation").and_then(Value::as_object);
let kind = if kind_key == "kind" {
relation_data
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
.and_then(|data| string_field(data, "kind"))?
} else {
string_field(relation, kind_key)?
};
let note = match relation_data {
Some(data) => optional_string(data, "note")?,
None => optional_string(relation, "note")?,
};
let created_at = match relation_data {
Some(data) => optional_string(data, "at")?,
None => optional_string(relation, "at")?,
};
Ok(ModelRelation {
ticket: human_ref(relation, ticket_key, "T-")?,
kind,
note,
created_at,
})
}
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
let blocker = object(value, "Ticket blocker")?;
Ok(ModelBlocker {
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
kind: string_field(blocker, "relation_kind")?,
state: optional_string(blocker, "blocking_state")?,
resolved: bool_field(blocker, "resolved")?,
})
}
fn project_notice(value: &Value) -> Result<ModelNotice, String> {
let notice = object(value, "Ticket notice")?;
Ok(ModelNotice {
kind: string_field(notice, "kind")?,
})
}
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
let summary = object(value, "Objective summary")?;
Ok(ModelObjectiveSummary {
objective: human_ref(summary, "resource_key", "O-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
}
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
let summary = object(value, "Ticket summary")?;
Ok(ModelTicketSummary {
ticket: human_ref(summary, "resource_key", "T-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
}
fn project_assignment(
value: &Value,
current_coder: Option<&ModelWorkerSummary>,
) -> Result<ModelAssignment, String> {
let assignment = object(value, "Ticket assignment")?;
let principal = object_field(assignment, "principal")?;
let kind = string_field(principal, "kind")?;
let principal = match kind.as_str() {
"worker" => current_coder
.map(|coder| coder.worker.clone())
.ok_or_else(|| {
"Worker assignment is missing a Workspace human key projection".to_string()
})?,
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
"user" => "user".to_string(),
other => format!("source:{other}"),
};
Ok(ModelAssignment {
role: string_field(assignment, "role")?,
principal,
assigned_at: string_field(assignment, "assigned_at")?,
})
}
fn project_evidence_event(value: &Value) -> Result<ModelEvidenceEvent, String> {
let event = object(value, "Ticket evidence event")?;
Ok(ModelEvidenceEvent {
sequence: usize_field(event, "sequence")?,
kind: string_field(event, "kind")?,
created_at: optional_string(event, "at")?,
excerpt: string_field(event, "excerpt")?,
})
}
fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
let merge = object(value, "Merge Request summary")?;
Ok(ModelMergeRequest {
state: string_field(merge, "state")?,
selector_from: optional_string(merge, "selector_from")?,
selector_to: string_field(merge, "selector_to")?,
review_status: string_field(merge, "review_status")?,
subject_ref: optional_string(merge, "subject_ref")?,
review_excerpt: optional_string(merge, "review_excerpt")?,
})
}
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
let evidence = object(value, "Ticket evidence")?;
Ok(ModelTicketEvidence {
has_merge_request: bool_field(evidence, "has_merge_request")?,
has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
has_review_request: bool_field(evidence, "has_review_request")?,
has_commit: bool_field(evidence, "has_commit")?,
review_status: optional_string(evidence, "review_status")?,
approved_current_subject: bool_field(evidence, "approved_current_subject")?,
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
missing: string_array(evidence, "missing")?,
})
}
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
let actions = object(value, "Ticket actions")?;
Ok(ModelTicketActions {
can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?,
can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?,
can_queue: bool_field(actions, "can_queue")?,
can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?,
})
}
fn project_event_page(value: &Value) -> Result<ModelEventPage, String> {
let page = object(value, "Ticket event page")?;
Ok(ModelEventPage {
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_objective_event(value: &Value) -> Result<ModelObjectiveEvent, String> {
let event = object(value, "Objective event")?;
let body = optional_string(event, "body")?;
Ok(ModelObjectiveEvent {
kind: string_field(event, "kind")?,
created_at: string_field(event, "created_at")?,
body,
})
}
fn project_objective_event_page(value: &Value) -> Result<ModelObjectiveEventPage, String> {
let page = object(value, "Objective event page")?;
Ok(ModelObjectiveEventPage {
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map<String, Value>, String> {
value
.as_object()
.ok_or_else(|| format!("{context} must be an object"))
}
fn object_field<'a>(
object: &'a Map<String, Value>,
key: &str,
) -> Result<&'a Map<String, Value>, String> {
object
.get(key)
.and_then(Value::as_object)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
object
.get(key)
.and_then(Value::as_array)
.map(Vec::as_slice)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn string_field(object: &Map<String, Value>, key: &str) -> Result<String, String> {
object
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn optional_string(object: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
match object.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value.clone())),
Some(_) => Err(format!("invalid {key}")),
}
}
fn bool_field(object: &Map<String, Value>, key: &str) -> Result<bool, String> {
object
.get(key)
.and_then(Value::as_bool)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn usize_field(object: &Map<String, Value>, key: &str) -> Result<usize, String> {
object
.get(key)
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, String> {
array_field(object, key)?
.iter()
.map(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| format!("invalid {key}"))
})
.collect()
}
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
let value = object
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
validate_human_ref(value, prefix)
}
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> {
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
});
if valid {
Ok(value)
} else {
Err(format!("required {prefix} human key is unavailable"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn objective_projection_exposes_only_human_resource_references() {
let projected = project_objective_detail(json!({
"id": "00001M10HW6BV",
"resource_key": "O-543",
"title": "Objective",
"body": "Body",
"state": "active",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-02T00:00:00Z",
"linked_tickets": ["00001M0E82D1V"],
"linked_ticket_summaries": [{
"id": "00001M0E82D1V",
"resource_key": "T-496",
"title": "Ticket",
"state": "done",
"updated_at": "2026-01-02T00:00:00Z"
}],
"events": [{
"sequence": 3,
"event_ref": "objective-event-3",
"kind": "linked_ticket",
"created_at": "2026-01-02T00:00:00Z",
"body": "linked"
}],
"event_page": {"next_cursor": null, "has_more": false, "window_start_sequence": 3, "window_end_sequence": 3}
})).expect("projection");
let json = serde_json::to_value(projected).expect("serialize");
let text = json.to_string();
assert!(text.contains("O-543"));
assert!(text.contains("T-496"));
assert!(!text.contains("00001M10HW6BV"));
assert!(!text.contains("00001M0E82D1V"));
assert!(!text.contains("event_ref"));
}
#[test]
fn query_projections_accept_workspace_api_shapes_and_scrub_internal_ids() {
let ticket = project_ticket_query(json!({
"page": {"next_cursor": null, "has_more": false},
"record_authority": "workspace_sqlite",
"items": [{
"id": "00001TICKETINTERNAL",
"resource_key": "T-543",
"title": "Ticket",
"state": "inprogress",
"readiness": null,
"priority": "high",
"created_at": null,
"updated_at": "2026-01-01T00:00:00Z",
"workspace_action_priority": "active_work",
"matched_fields": ["title"],
"snippet": "Ticket",
"current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
"linked_objective_keys": ["O-6"],
"relation_count": 0,
"blocker_count": 0,
"unresolved_blocker_count": 0,
"unresolved_review_count": 0,
"evidence": {
"has_merge_request": false,
"has_current_subject_ref": false,
"has_review_request": false,
"has_commit": false,
"review_status": null,
"approved_current_subject": false,
"unresolved_request_changes": false,
"complete_for_integration": false,
"missing": ["merge_request"]
},
"merge_request": null
}]
})).expect("Ticket query projection");
let ticket_json = serde_json::to_string(&ticket).expect("serialize Ticket query");
assert!(ticket_json.contains("T-543"));
assert!(ticket_json.contains("O-6"));
assert!(ticket_json.contains("W-12"));
assert!(!ticket_json.contains("00001TICKETINTERNAL"));
assert!(!ticket_json.contains("runtime-internal"));
assert!(!ticket_json.contains("worker-internal"));
let objective = project_objective_query(json!({
"page": {"next_cursor": null, "has_more": false},
"record_authority": "workspace_sqlite",
"items": [{
"id": "00001OBJECTIVEINTERNAL",
"resource_key": "O-6",
"title": "Objective",
"state": "active",
"created_at": null,
"updated_at": null,
"matched_fields": [],
"snippet": null,
"linked_ticket_count": 1,
"linked_tickets": ["00001TICKETINTERNAL"],
"linked_ticket_keys": ["T-543"]
}]
}))
.expect("Objective query projection");
let objective_json = serde_json::to_string(&objective).expect("serialize Objective query");
assert!(objective_json.contains("O-6"));
assert!(objective_json.contains("T-543"));
assert!(objective_json.contains("\"summary\":null"));
assert!(!objective_json.contains("00001OBJECTIVEINTERNAL"));
assert!(!objective_json.contains("00001TICKETINTERNAL"));
}
#[test]
fn human_resource_projection_rejects_noncanonical_keys() {
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
assert!(validate_human_ref(key.to_string(), prefix).is_err());
}
}
#[test]
fn ticket_projection_fails_closed_without_worker_resource_key() {
let error = project_worker(&json!({"worker_resource_key": null}))
.expect_err("missing W-key must fail");
assert!(error.contains("W-"));
}
}
+271 -30
View File
@@ -33,6 +33,8 @@ use crate::feature::{
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use super::resource_projection::{project_ticket_detail, project_ticket_query};
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum WorkspaceTicketReadKind { enum WorkspaceTicketReadKind {
Query, Query,
@@ -153,8 +155,10 @@ struct WorkspaceQueryTicketInput {
/// stale_after_rescope, and missing_evidence. /// stale_after_rescope, and missing_evidence.
#[serde(default)] #[serde(default)]
attention: Vec<WorkspaceTicketAttentionFilter>, attention: Vec<WorkspaceTicketAttentionFilter>,
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
related_ticket_id: Option<String>, related_ticket_id: Option<String>,
relation_kind: Option<WorkspaceTicketRelationFilter>, relation_kind: Option<WorkspaceTicketRelationFilter>,
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
linked_objective_id: Option<String>, linked_objective_id: Option<String>,
updated_after: Option<String>, updated_after: Option<String>,
updated_before: Option<String>, updated_before: Option<String>,
@@ -169,6 +173,7 @@ struct WorkspaceQueryTicketInput {
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct WorkspaceShowTicketInput { struct WorkspaceShowTicketInput {
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
id: String, id: String,
/// Most-recent thread entries to return, bounded by the Backend to 1..=50. /// Most-recent thread entries to return, bounded by the Backend to 1..=50.
event_limit: Option<usize>, event_limit: Option<usize>,
@@ -229,13 +234,27 @@ impl Tool for WorkspaceTicketReadTool {
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() { if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"Workspace Ticket API returned HTTP {}: {}", "Workspace Ticket API request failed with HTTP status {}",
response.status, response.body response.status
))); )));
} }
let response_value: Value = serde_json::from_str(&response.body).map_err(|error| {
ToolError::ExecutionFailed(format!(
"Workspace Ticket API returned invalid JSON: {error}"
))
})?;
let content = match self.kind {
WorkspaceTicketReadKind::Query => serde_json::to_string(
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
),
WorkspaceTicketReadKind::Show => serde_json::to_string(
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
),
}
.map_err(|error| ToolError::Internal(error.to_string()))?;
Ok(ToolOutput { Ok(ToolOutput {
summary: self.kind.name().to_string(), summary: self.kind.name().to_string(),
content: Some(response.body), content: Some(content),
attachments: Vec::new(), attachments: Vec::new(),
}) })
} }
@@ -267,7 +286,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 +307,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,
})
} }
} }
@@ -712,14 +752,69 @@ impl WorkspaceHttpTicketBackend {
})?; })?;
if !response.is_success() { if !response.is_success() {
return Err(TicketError::Conflict(format!( return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}", "ticket REST API request failed with HTTP status {}",
response.status, response.body response.status
))); )));
} }
serde_json::from_str(&response.body) let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
TicketError::Conflict(format!("decode ticket REST response: {error}"))
})?;
Self::canonicalize_ticket_references(&mut value);
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}"))) .map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
} }
fn canonicalize_ticket_references(value: &mut Value) {
match value {
Value::Array(values) => {
for value in values {
Self::canonicalize_ticket_references(value);
}
}
Value::Object(object) => {
for value in object.values_mut() {
Self::canonicalize_ticket_references(value);
}
if let Some(resource_key) = object
.get("resource_key")
.and_then(Value::as_str)
.filter(|key| is_canonical_ticket_resource_key(key))
.map(ToOwned::to_owned)
&& object.contains_key("id")
{
object.insert("id".to_string(), Value::String(resource_key));
}
}
_ => {}
}
}
fn resolve_ticket_resource_key(
client: Arc<dyn WorkspaceClient>,
base: &str,
reference: &TicketIdOrSlug,
) -> TicketResult<String> {
let response: Value = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}", Self::ticket_path(reference)),
None,
)?;
response
.get("resource_key")
.or_else(|| {
response
.get("meta")
.and_then(|meta| meta.get("resource_key"))
})
.and_then(Value::as_str)
.filter(|key| is_canonical_ticket_resource_key(key))
.map(ToOwned::to_owned)
.ok_or_else(|| {
TicketError::Conflict("required Ticket human key is unavailable".to_string())
})
}
fn request_unit( fn request_unit(
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod, method: WorkspaceRequestMethod,
@@ -739,8 +834,8 @@ impl WorkspaceHttpTicketBackend {
})?; })?;
if !response.is_success() { if !response.is_success() {
return Err(TicketError::Conflict(format!( return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}", "ticket REST API request failed with HTTP status {}",
response.status, response.body response.status
))); )));
} }
Ok(TicketBackendOperationResult::Unit) Ok(TicketBackendOperationResult::Unit)
@@ -781,12 +876,22 @@ impl WorkspaceHttpTicketBackend {
Ok(TicketBackendOperationResult::Tickets(tickets)) Ok(TicketBackendOperationResult::Tickets(tickets))
} }
TicketBackendOperation::Show { id } => { TicketBackendOperation::Show { id } => {
let ticket = Self::request( let ticket: Ticket = Self::request(
client, client,
WorkspaceRequestMethod::Get, WorkspaceRequestMethod::Get,
format!("{base}/{}/record", Self::ticket_path(&id)), format!("{base}/{}/record", Self::ticket_path(&id)),
None, None,
)?; )?;
if !ticket
.meta
.resource_key
.as_deref()
.is_some_and(is_canonical_ticket_resource_key)
{
return Err(TicketError::Conflict(
"required Ticket human key is unavailable".to_string(),
));
}
Ok(TicketBackendOperationResult::Ticket(ticket)) Ok(TicketBackendOperationResult::Ticket(ticket))
} }
TicketBackendOperation::Create { input } => { TicketBackendOperation::Create { input } => {
@@ -873,12 +978,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,
@@ -888,7 +994,14 @@ impl WorkspaceHttpTicketBackend {
})?), })?),
), ),
TicketBackendOperation::AddTicketRelation { id, relation } => { TicketBackendOperation::AddTicketRelation { id, relation } => {
let relation = Self::request( let source_resource_key =
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
let target_resource_key = Self::resolve_ticket_resource_key(
client.clone(),
&base,
&TicketIdOrSlug::Id(relation.target.clone()),
)?;
let mut relation: TicketRelation = Self::request(
client, client,
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!("{base}/{}/relations", Self::ticket_path(&id)), format!("{base}/{}/relations", Self::ticket_path(&id)),
@@ -896,20 +1009,30 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("serialize Ticket relation: {error}")) TicketError::Conflict(format!("serialize Ticket relation: {error}"))
})?), })?),
)?; )?;
relation.ticket_id = source_resource_key;
relation.target = target_resource_key;
relation.author = "workspace".to_string();
Ok(TicketBackendOperationResult::Relation(relation)) Ok(TicketBackendOperationResult::Relation(relation))
} }
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => { TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
let source_resource_key =
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
let target_resource_key =
Self::resolve_ticket_resource_key(client.clone(), &base, &target)?;
let target = match target { let target = match target {
TicketIdOrSlug::Id(value) TicketIdOrSlug::Id(value)
| TicketIdOrSlug::Slug(value) | TicketIdOrSlug::Slug(value)
| TicketIdOrSlug::Query(value) => value, | TicketIdOrSlug::Query(value) => value,
}; };
let relation = Self::request( let mut relation: TicketRelation = Self::request(
client, client,
WorkspaceRequestMethod::Delete, WorkspaceRequestMethod::Delete,
format!("{base}/{}/relations", Self::ticket_path(&id)), format!("{base}/{}/relations", Self::ticket_path(&id)),
Some(serde_json::json!({ "kind": kind, "target": target })), Some(serde_json::json!({ "kind": kind, "target": target })),
)?; )?;
relation.ticket_id = source_resource_key;
relation.target = target_resource_key;
relation.author = "workspace".to_string();
Ok(TicketBackendOperationResult::Relation(relation)) Ok(TicketBackendOperationResult::Relation(relation))
} }
TicketBackendOperation::QueryTicketRelations { ticket, kind } => { TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
@@ -1099,16 +1222,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, id: TicketIdOrSlug,
queued_by: queued_by.to_string(), queued_by: &str,
})? { ) -> TicketResult<ticket::TicketQueueOutcome> {
TicketBackendOperationResult::Unit => Ok(()), expect_ticket_result!(
other => Err(TicketError::Conflict(format!( self.invoke(TicketBackendOperation::QueueReady {
"unexpected ticket backend response: {other:?}" id,
))), queued_by: queued_by.to_string(),
} }),
TicketBackendOperationResult::QueueOutcome
)
} }
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> { fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
@@ -1242,6 +1367,23 @@ mod tests {
.expect("tool exists") .expect("tool exists")
} }
#[test]
fn workspace_ticket_backend_canonicalizes_model_facing_ticket_ids() {
let mut value = serde_json::json!({
"id": "00001INTERNAL",
"resource_key": "T-42",
"nested": {
"id": "00002INTERNAL",
"resource_key": "T-43"
},
"body": "user-authored 00003BODY stays unchanged"
});
WorkspaceHttpTicketBackend::canonicalize_ticket_references(&mut value);
assert_eq!(value["id"], "T-42");
assert_eq!(value["nested"]["id"], "T-43");
assert_eq!(value["body"], "user-authored 00003BODY stays unchanged");
}
#[test] #[test]
fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() { fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() {
let client: Arc<dyn WorkspaceClient> = Arc::new( let client: Arc<dyn WorkspaceClient> = Arc::new(
@@ -1718,11 +1860,102 @@ provider = "github"
server.join().unwrap(); server.join().unwrap();
} }
#[test]
fn workspace_http_backend_records_relation_with_authoritative_human_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
for (expected_path, resource_key) in [
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with(expected_path));
let body = serde_json::json!({"meta": {"resource_key": resource_key}}).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(), body
)
.unwrap();
}
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(
request.starts_with("POST /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1")
);
let body = serde_json::to_string(&TicketRelation {
ticket_id: "01SOURCE".to_string(),
kind: TicketRelationKind::DependsOn,
target: "01TARGET".to_string(),
note: None,
author: "worker-internal".to_string(),
at: "2026-08-06T00:00:00Z".to_string(),
})
.unwrap();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", format!("http://{addr}")),
));
let relation = backend
.add_ticket_relation(
TicketIdOrSlug::Id("01SOURCE".to_string()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: "01TARGET".to_string(),
note: None,
author: None,
},
)
.unwrap();
server.join().unwrap();
assert_eq!(relation.ticket_id, "T-1");
assert_eq!(relation.target, "T-2");
assert_eq!(relation.author, "workspace");
}
#[test] #[test]
fn workspace_http_backend_deletes_exact_ticket_relation() { fn workspace_http_backend_deletes_exact_ticket_relation() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap()); let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || { let server = thread::spawn(move || {
for (expected_path, resource_key) in [
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with(expected_path));
let response_body = serde_json::json!({
"meta": {"resource_key": resource_key}
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
}
let (mut stream, _) = listener.accept().unwrap(); let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192]; let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap(); let len = stream.read(&mut buffer).unwrap();
@@ -1763,8 +1996,16 @@ provider = "github"
.unwrap(); .unwrap();
server.join().unwrap(); server.join().unwrap();
assert_eq!(removed.ticket_id, "01SOURCE"); assert_eq!(removed.ticket_id, "T-1");
assert_eq!(removed.target, "01TARGET"); assert_eq!(removed.target, "T-2");
}
#[test]
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
assert!(is_canonical_ticket_resource_key("T-482"));
for invalid in ["", "00001KZVNXFNK", "T-", "T-key", "O-482"] {
assert!(!is_canonical_ticket_resource_key(invalid));
}
} }
#[test] #[test]
+274 -23
View File
@@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use agen::timeline::event::UsageEvent; use agen::timeline::event::UsageEvent;
use agen::{Engine, llm_client::LlmClient}; use agen::{Engine, EngineError, llm_client::LlmClient};
use manifest::{Scope, WorkerManifest}; use manifest::{Scope, WorkerManifest};
use protocol::{Event, InFlightSnapshot, WorkerStatus}; use protocol::{Event, InFlightSnapshot, WorkerStatus};
use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry}; use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry};
@@ -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,7 @@ 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>>) + 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 +105,7 @@ where
input, input,
cache_key, cache_key,
max_turns, max_turns,
engine_configurator,
features, features,
required_tools, required_tools,
authority, authority,
@@ -128,7 +132,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 +152,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();
@@ -188,12 +199,28 @@ where
on_cancel_sender(worker.engine_mut().cancel_sender()); on_cancel_sender(worker.engine_mut().cancel_sender());
match worker.run_text(&input).await { match worker.run_text(&input).await {
Ok(lifecycle) => Ok(InternalWorkerResult { Ok(lifecycle @ WorkerRunResult::Finished)
| Ok(lifecycle @ WorkerRunResult::Paused)
| Ok(lifecycle @ WorkerRunResult::RolledBack) => Ok(InternalWorkerResult {
usage: last_usage.lock().ok().and_then(|slot| slot.clone()), usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity, identity,
lifecycle, lifecycle,
history_entries: store.entries_count(session_id, segment_id), history_entries: store.entries_count(session_id, segment_id),
}), }),
Ok(WorkerRunResult::LimitReached) => Err(InternalWorkerError {
source: WorkerError::Engine(EngineError::Aborted(
"internal Worker reached its turn limit".to_string(),
)),
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity,
history_entries: store.entries_count(session_id, segment_id),
}),
Ok(WorkerRunResult::Interrupted { message, .. }) => Err(InternalWorkerError {
source: WorkerError::Engine(EngineError::Aborted(message)),
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity,
history_entries: store.entries_count(session_id, segment_id),
}),
Err(source) => Err(InternalWorkerError { Err(source) => Err(InternalWorkerError {
source, source,
usage: last_usage.lock().ok().and_then(|slot| slot.clone()), usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
@@ -221,6 +248,7 @@ impl Default for InternalWorkerVisibility {
pub(crate) enum InternalWorkerSessionStatus { pub(crate) enum InternalWorkerSessionStatus {
Idle, Idle,
Running, Running,
Paused,
Stopping, Stopping,
Stopped, Stopped,
Failed, Failed,
@@ -231,9 +259,10 @@ impl InternalWorkerSessionStatus {
match self { match self {
Self::Idle => 0, Self::Idle => 0,
Self::Running => 1, Self::Running => 1,
Self::Stopping => 2, Self::Paused => 2,
Self::Stopped => 3, Self::Stopping => 3,
Self::Failed => 4, Self::Stopped => 4,
Self::Failed => 5,
} }
} }
@@ -241,16 +270,37 @@ impl InternalWorkerSessionStatus {
match value { match value {
0 => Self::Idle, 0 => Self::Idle,
1 => Self::Running, 1 => Self::Running,
2 => Self::Stopping, 2 => Self::Paused,
3 => Self::Stopped, 3 => Self::Stopping,
4 => Self::Stopped,
_ => Self::Failed, _ => Self::Failed,
} }
} }
} }
fn classify_internal_turn_result(
result: Result<WorkerRunResult, WorkerError>,
) -> (InternalWorkerSessionStatus, Option<String>) {
match result {
Ok(WorkerRunResult::Finished) => (InternalWorkerSessionStatus::Idle, None),
Ok(WorkerRunResult::Paused) => (InternalWorkerSessionStatus::Paused, None),
Ok(WorkerRunResult::LimitReached) => (
InternalWorkerSessionStatus::Stopped,
Some("internal Worker reached its turn limit".to_string()),
),
Ok(WorkerRunResult::Interrupted { message, .. }) => {
(InternalWorkerSessionStatus::Stopped, Some(message))
}
Ok(WorkerRunResult::RolledBack) => (
InternalWorkerSessionStatus::Stopped,
Some("internal Worker run was cancelled before AI output".to_string()),
),
Err(error) => (InternalWorkerSessionStatus::Failed, Some(error.to_string())),
}
}
#[derive(Debug, thiserror::Error)] #[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")]
@@ -343,10 +393,11 @@ impl InternalWorkerSessionHandle {
entries, entries,
status: match self.status() { status: match self.status() {
InternalWorkerSessionStatus::Running => WorkerStatus::Running, InternalWorkerSessionStatus::Running => WorkerStatus::Running,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle, InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
InternalWorkerSessionStatus::Stopping InternalWorkerSessionStatus::Stopping
| InternalWorkerSessionStatus::Stopped | InternalWorkerSessionStatus::Stopped
| InternalWorkerSessionStatus::Failed => WorkerStatus::Paused, | InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
}, },
error: self.last_error.lock().unwrap().clone(), error: self.last_error.lock().unwrap().clone(),
in_flight, in_flight,
@@ -378,6 +429,7 @@ impl InternalWorkerSessionHandle {
.map_err( .map_err(
|current| match InternalWorkerSessionStatus::decode(current) { |current| match InternalWorkerSessionStatus::decode(current) {
InternalWorkerSessionStatus::Running InternalWorkerSessionStatus::Running
| InternalWorkerSessionStatus::Paused
| InternalWorkerSessionStatus::Stopping => InternalWorkerSessionError::Busy, | InternalWorkerSessionStatus::Stopping => InternalWorkerSessionError::Busy,
InternalWorkerSessionStatus::Stopped | InternalWorkerSessionStatus::Failed => { InternalWorkerSessionStatus::Stopped | InternalWorkerSessionStatus::Failed => {
InternalWorkerSessionError::Stopped InternalWorkerSessionError::Stopped
@@ -410,7 +462,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 +526,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,
@@ -492,7 +544,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 +561,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 +598,102 @@ 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::new(client).system_prompt(system_prompt);
engine.set_cache_key(cache_key);
engine.set_max_turns(max_turns);
if let Some(configure) = engine_configurator {
configure(&mut engine);
}
let store = EphemeralSessionStore::default();
let inherited_workdir_session = authority.workdir_session.clone();
let mut worker = Worker::new(
manifest,
engine,
store.clone(),
authority.workspace,
authority.filesystem,
authority.scope,
)
.await
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
let install_report = worker.install_features(features);
let installed_tools = install_report.installed_tool_names();
let install_failed = install_report
.reports
.iter()
.any(|report| !report.installed);
let missing = required_tools
.iter()
.filter(|required| {
!installed_tools
.iter()
.any(|installed| installed == **required)
})
.copied()
.collect::<Vec<_>>();
if install_failed || !missing.is_empty() {
let diagnostics = install_report
.reports
.iter()
.flat_map(|report| report.diagnostics.iter())
.map(|diagnostic| diagnostic.message.as_str())
.collect::<Vec<_>>()
.join("; ");
return Err(InternalWorkerSessionError::Build {
message: format!(
"internal Worker feature installation failed: {diagnostics}; missing tools: {}",
missing.join(", ")
),
});
}
Box::pin(prepare_internal_worker_session(
worker, store, visibility, None, None,
))
.await
})
}
fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Sender<Event>) { 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 {
@@ -620,13 +775,7 @@ pub(crate) async fn prepare_internal_worker_session(
loop { loop {
tokio::select! { tokio::select! {
result = &mut run => { result = &mut run => {
let (turn_status, error) = match result { let (turn_status, error) = classify_internal_turn_result(result);
Ok(_) => (InternalWorkerSessionStatus::Idle, None),
Err(error) => (
InternalWorkerSessionStatus::Failed,
Some(error.to_string()),
),
};
actor_in_flight.clear(); actor_in_flight.clear();
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release); status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
if let Some(message) = error { if let Some(message) = error {
@@ -635,11 +784,20 @@ pub(crate) async fn prepare_internal_worker_session(
code: protocol::ErrorCode::Internal, code: protocol::ErrorCode::Internal,
message, message,
}); });
} else {
let _ = event_tx.send(Event::Status {
status: WorkerStatus::Idle,
});
} }
let protocol_status = match turn_status {
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
InternalWorkerSessionStatus::Stopped
| InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
InternalWorkerSessionStatus::Running
| InternalWorkerSessionStatus::Stopping => {
unreachable!("run completion cannot remain active")
}
};
let _ = event_tx.send(Event::Status {
status: protocol_status,
});
if let Some(callback) = &on_turn_end { if let Some(callback) = &on_turn_end {
callback(turn_status); callback(turn_status);
} }
@@ -653,7 +811,7 @@ pub(crate) async fn prepare_internal_worker_session(
let _ = (&mut run).await; let _ = (&mut run).await;
actor_in_flight.clear(); actor_in_flight.clear();
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release); status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
let _ = event_tx.send(Event::Status { status: WorkerStatus::Paused }); let _ = event_tx.send(Event::Status { status: WorkerStatus::Stopped });
let _ = event_tx.send(Event::Shutdown); let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters(); state_changed.notify_waiters();
let _ = done.send(()); let _ = done.send(());
@@ -679,7 +837,7 @@ pub(crate) async fn prepare_internal_worker_session(
std::sync::atomic::Ordering::Release, std::sync::atomic::Ordering::Release,
); );
let _ = event_tx.send(Event::Status { let _ = event_tx.send(Event::Status {
status: WorkerStatus::Paused, status: WorkerStatus::Stopped,
}); });
let _ = event_tx.send(Event::Shutdown); let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters(); state_changed.notify_waiters();
@@ -989,6 +1147,26 @@ mod tests {
} }
} }
#[derive(Clone)]
struct FailingClient;
#[async_trait]
impl LlmClient for FailingClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
Err(ClientError::Config(
"intentional internal failure".to_string(),
))
}
}
#[derive(Clone)] #[derive(Clone)]
struct CancelBeforeAiClient { struct CancelBeforeAiClient {
calls: Arc<AtomicUsize>, calls: Arc<AtomicUsize>,
@@ -1076,12 +1254,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,
}, },
} }
} }
@@ -1100,6 +1280,77 @@ permission = "write"
assert_eq!(result.identity.kind, "test"); assert_eq!(result.identity.kind, "test");
} }
#[test]
fn internal_turn_result_mapping_is_exhaustive() {
let cases = [
(
WorkerRunResult::Finished,
InternalWorkerSessionStatus::Idle,
false,
),
(
WorkerRunResult::Paused,
InternalWorkerSessionStatus::Paused,
false,
),
(
WorkerRunResult::LimitReached,
InternalWorkerSessionStatus::Stopped,
true,
),
(
WorkerRunResult::Interrupted {
code: protocol::ErrorCode::Internal,
message: "cancelled".to_string(),
},
InternalWorkerSessionStatus::Stopped,
true,
),
(
WorkerRunResult::RolledBack,
InternalWorkerSessionStatus::Stopped,
true,
),
];
for (result, expected_status, expects_error) in cases {
let (status, error) = classify_internal_turn_result(Ok(result));
assert_eq!(status, expected_status);
assert_eq!(error.is_some(), expects_error);
}
let (status, error) = classify_internal_turn_result(Err(WorkerError::Engine(
EngineError::Aborted("fatal".to_string()),
)));
assert_eq!(status, InternalWorkerSessionStatus::Failed);
assert!(error.is_some_and(|message| message.contains("fatal")));
}
#[tokio::test]
async fn fatal_internal_run_transitions_to_stopped_protocol_status() {
let calls = Arc::new(AtomicUsize::new(0));
let mut internal_spec = spec(calls, &[]);
internal_spec.client = Box::new(FailingClient);
let handle = spawn_internal_worker_session(internal_spec)
.await
.expect("spawn failing Internal Worker session");
assert_eq!(
handle.wait_until_idle().await,
InternalWorkerSessionStatus::Stopped
);
assert_eq!(handle.status(), InternalWorkerSessionStatus::Stopped);
assert_eq!(handle.protocol_snapshot().status, WorkerStatus::Stopped);
assert!(
handle
.last_error
.lock()
.unwrap()
.as_ref()
.is_some_and(|message| message.contains("intentional internal failure"))
);
}
#[tokio::test] #[tokio::test]
async fn session_accepts_follow_up_turns_and_stops_without_runtime_registration() { async fn session_accepts_follow_up_turns_and_stops_without_runtime_registration() {
let calls = Arc::new(AtomicUsize::new(0)); let calls = Arc::new(AtomicUsize::new(0));
+3 -2
View File
@@ -33,8 +33,9 @@ pub use manifest::{
}; };
pub use model_client::{ProviderError, build_client}; pub use model_client::{ProviderError, build_client};
pub use prompt::catalog::{ pub use prompt::catalog::{
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, WorkspacePromptProjection, CatalogError, EffectivePromptCatalog, OrchestratorQueueAttentionContext,
prompt_schema_source, OrchestratorQueueAttentionPrompt, OrchestratorQueueAttentionTicket, PromptCatalog,
WorkerPrompt, WorkspacePromptProjection, prompt_schema_source,
}; };
pub use prompt::source::PromptCatalogSource; pub use prompt::source::PromptCatalogSource;
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
+196 -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,13 +137,97 @@ impl WorkerPrompt {
WorkerPrompt::AgentsMdSection, WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection, WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::TicketEventCompanionNotice,
WorkerPrompt::SubWorkerSpawnToolDescription, WorkerPrompt::SubWorkerSpawnToolDescription,
]; ];
} }
/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionTicket {
resource_key: String,
title: String,
}
impl OrchestratorQueueAttentionTicket {
pub fn new(
resource_key: impl Into<String>,
title: impl Into<String>,
) -> Result<Self, CatalogError> {
let resource_key = resource_key.into();
if !is_ticket_resource_key(&resource_key) {
return Err(CatalogError::InvalidQueueAttentionResourceKey);
}
Ok(Self {
resource_key,
title: bounded_queue_attention_text(&title.into(), 240),
})
}
}
/// Shared model-visible context for every Orchestrator backlog attention renderer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionContext {
tickets: Vec<OrchestratorQueueAttentionTicket>,
separator: &'static str,
omitted_ticket_count: usize,
}
impl OrchestratorQueueAttentionContext {
pub const MAX_TICKETS: usize = 20;
pub fn new(tickets: Vec<OrchestratorQueueAttentionTicket>) -> Self {
let omitted_ticket_count = tickets.len().saturating_sub(Self::MAX_TICKETS);
Self {
tickets: tickets.into_iter().take(Self::MAX_TICKETS).collect(),
separator: "",
omitted_ticket_count,
}
}
}
/// Prompt-catalog entries that must share the same backlog-attention body contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrchestratorQueueAttentionPrompt {
Server,
Tui,
}
impl OrchestratorQueueAttentionPrompt {
fn key(self) -> &'static str {
match self {
Self::Server => "internal.workspace_orchestrator_queue_attention",
Self::Tui => "panel.orchestrator_idle_queue_notice",
}
}
}
fn is_ticket_resource_key(input: &str) -> bool {
input.len() <= 32
&& input.strip_prefix("T-").is_some_and(|suffix| {
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn bounded_queue_attention_text(input: &str, max_chars: usize) -> String {
let mut output = String::new();
for (index, character) in input.chars().enumerate() {
if index == max_chars {
output.push('…');
break;
}
output.push(if character.is_control() {
' '
} else {
character
});
}
output
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum CatalogError { pub enum CatalogError {
#[error("queued Ticket resource key is missing or invalid")]
InvalidQueueAttentionResourceKey,
#[error("failed to build builtin Prompt source tree: {0}")] #[error("failed to build builtin Prompt source tree: {0}")]
BuiltinTree(String), BuiltinTree(String),
#[error("failed to evaluate builtin Prompt source tree: {0}")] #[error("failed to evaluate builtin Prompt source tree: {0}")]
@@ -322,6 +404,14 @@ impl PromptCatalog {
self.render_name(key, Value::from_serialize(context)) self.render_name(key, Value::from_serialize(context))
} }
pub fn orchestrator_queue_attention(
&self,
prompt: OrchestratorQueueAttentionPrompt,
context: &OrchestratorQueueAttentionContext,
) -> Result<String, CatalogError> {
self.render_serializable(prompt.key(), context)
}
pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> { pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> {
let template = self let template = self
.env .env
@@ -593,6 +683,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 +705,103 @@ 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]
fn queue_attention_prompts_share_sanitized_contract_and_true_truncation() {
let catalog = PromptCatalog::builtins_only().unwrap();
let tickets = (1..=OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
.map(|index| {
OrchestratorQueueAttentionTicket::new(
format!("T-{index}"),
format!("Ticket {index}\nwith control\u{7}"),
)
.unwrap()
})
.collect();
let context = OrchestratorQueueAttentionContext::new(tickets);
let server = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
let tui = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context)
.unwrap();
assert_eq!(server, tui);
assert!(server.starts_with("Queued Tickets require attention:"));
assert!(server.contains("- T-1 — Ticket 1 with control "));
assert!(!server.contains("T-21"));
assert!(server.contains("were omitted from this notice: 1"));
assert!(server.contains("Re-query current Ticket authority"));
assert!(server.contains("Reread the current Ticket state before acting"));
for secret in [
"workspace_id",
"Workspace:",
"runtime_id",
"worker_id",
"bounded",
] {
assert!(!server.contains(secret), "leaked {secret}: {server}");
}
}
#[test]
fn queue_attention_prompt_omits_truncation_text_for_complete_list() {
let catalog = PromptCatalog::builtins_only().unwrap();
let context = OrchestratorQueueAttentionContext::new(vec![
OrchestratorQueueAttentionTicket::new("T-541", "Attention contract").unwrap(),
]);
let rendered = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
assert!(rendered.contains("- T-541 — Attention contract"));
assert!(!rendered.contains("omitted"));
assert!(matches!(
OrchestratorQueueAttentionTicket::new("opaque-id", "must fail"),
Err(CatalogError::InvalidQueueAttentionResourceKey)
));
}
#[test] #[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([
+2
View File
@@ -143,6 +143,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]),
} }
@@ -418,6 +419,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()
+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();
+24 -22
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(())
@@ -499,7 +499,10 @@ impl Tool for SubWorkerSpawnTool {
InternalWorkerVisibility::ParentClient, InternalWorkerVisibility::ParentClient,
Some(child_registry.clone()), Some(child_registry.clone()),
Some(Arc::new(move |status| { Some(Arc::new(move |status| {
if status == InternalWorkerSessionStatus::Failed { if matches!(
status,
InternalWorkerSessionStatus::Failed | InternalWorkerSessionStatus::Stopped
) {
if let Some(registry) = registry.upgrade() { if let Some(registry) = registry.upgrade() {
if let Err(error) = registry.reclaim_internal_scope(&child_name) { if let Err(error) = registry.reclaim_internal_scope(&child_name) {
tracing::warn!( tracing::warn!(
@@ -1008,28 +1011,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 +1082,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 +1143,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 +1174,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
); );
} }
@@ -1283,16 +1285,16 @@ extract_threshold = 4000
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
record.session.wait_until_idle().await, record.session.wait_until_idle().await,
InternalWorkerSessionStatus::Failed InternalWorkerSessionStatus::Stopped
); );
assert_eq!(calls.load(Ordering::SeqCst), 3); assert_eq!(calls.load(Ordering::SeqCst), 3);
assert!( assert!(
spawner_scope.snapshot().is_writable(&workspace_root), spawner_scope.snapshot().is_writable(&workspace_root),
"Failed terminal child must release its delegated Workdir session" "Stopped terminal child must release its delegated Workdir session"
); );
assert!( assert!(
!record.workdir_delegation.is_active(), !record.workdir_delegation.is_active(),
"failed child must revoke cloned scoped sessions" "stopped child must revoke cloned scoped sessions"
); );
assert!(registry.get_internal("reviewer-child").is_some()); assert!(registry.get_internal("reviewer-child").is_some());
File diff suppressed because it is too large Load Diff
+70 -7
View File
@@ -431,7 +431,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 +445,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 +539,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 +555,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 +587,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 +716,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 +752,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 +877,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")
),
_ => {} _ => {}
} }
} }
@@ -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(),
}) })
} }
} }
+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 {
+123 -25
View File
@@ -22,7 +22,7 @@ use crate::records::{
TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary, TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary,
TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView, TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView,
TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage, TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage,
summarize_body, truncate_body, validate_project_id, summarize_body, truncate_body,
}; };
use crate::store::{ use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
@@ -633,7 +633,15 @@ impl SqliteWorkspaceAuthority {
predicates.push(format!("o.updated_at<{value}")); predicates.push(format!("o.updated_at<{value}"));
} }
if let Some(value) = &query.linked_ticket_id { if let Some(value) = &query.linked_ticket_id {
let value = bind(SqlValue::Text(value.clone())); let resolved = self
.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
value,
)?
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))?;
let value = bind(SqlValue::Text(resolved));
predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})")); predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})"));
} }
let relevance_rank = if let Some(text) = let relevance_rank = if let Some(text) =
@@ -704,6 +712,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 +730,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 +842,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 +874,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 +902,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 +1134,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,
@@ -1194,6 +1245,10 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
.into_iter() .into_iter()
.map(|link| link.ticket_id) .map(|link| link.ticket_id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let linked_ticket_keys = linked_tickets
.iter()
.map(|ticket_id| self.resource_key(WorkspaceResourceKind::Ticket, ticket_id))
.collect::<Result<Vec<_>>>()?;
let body_md = record.body_md.clone(); let body_md = record.body_md.clone();
let objective = ObjectiveSummary { let objective = ObjectiveSummary {
resource_key: self resource_key: self
@@ -1210,6 +1265,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
items.push(objective_query_item( items.push(objective_query_item(
objective, objective,
linked_tickets, linked_tickets,
linked_ticket_keys,
query.query.as_deref(), query.query.as_deref(),
&body_md, &body_md,
)); ));
@@ -1296,9 +1352,19 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> { fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> {
validate_objective_title(&input.title)?; validate_objective_title(&input.title)?;
validate_objective_state(&input.state)?; validate_objective_state(&input.state)?;
for ticket_id in &input.linked_tickets { let linked_tickets = input
validate_project_id(ticket_id)?; .linked_tickets
} .iter()
.map(|ticket_reference| {
self.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
ticket_reference,
)?
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))
})
.collect::<Result<Vec<_>>>()?;
let now = now_rfc3339(); let now = now_rfc3339();
let objective_id = allocate_record_id( let objective_id = allocate_record_id(
unix_epoch_millis_now().map_err(|err| { unix_epoch_millis_now().map_err(|err| {
@@ -1324,8 +1390,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
updated_at: now.clone(), updated_at: now.clone(),
}; };
self.store.upsert_objective(&record)?; self.store.upsert_objective(&record)?;
let links = input let links = linked_tickets
.linked_tickets
.into_iter() .into_iter()
.map(|ticket_id| ObjectiveTicketLinkRecord { .map(|ticket_id| ObjectiveTicketLinkRecord {
workspace_id: self.workspace_id.clone(), workspace_id: self.workspace_id.clone(),
@@ -2240,12 +2305,18 @@ fn ticket_query_item(
.iter() .iter()
.map(|objective| objective.id.clone()) .map(|objective| objective.id.clone())
.collect(), .collect(),
linked_objective_keys: detail
.linked_objectives
.iter()
.map(|objective| objective.resource_key.clone())
.collect(),
relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(), relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(),
blocker_count: detail.relations.blockers.len(), blocker_count: detail.relations.blockers.len(),
unresolved_blocker_count: detail.relations.blockers.len(), unresolved_blocker_count: detail.relations.blockers.len(),
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes), unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
evidence: detail.evidence.clone(), evidence: detail.evidence.clone(),
merge_request: detail.merge_request.clone(), merge_request: detail.merge_request.clone(),
current_coder: detail.current_coder.clone(),
} }
} }
@@ -2356,6 +2427,7 @@ fn ticket_item_after_cursor(
fn objective_query_item( fn objective_query_item(
objective: ObjectiveSummary, objective: ObjectiveSummary,
linked_tickets: Vec<String>, linked_tickets: Vec<String>,
linked_ticket_keys: Vec<String>,
text: Option<&str>, text: Option<&str>,
body_md: &str, body_md: &str,
) -> ObjectiveQueryItem { ) -> ObjectiveQueryItem {
@@ -2383,6 +2455,7 @@ fn objective_query_item(
snippet, snippet,
linked_ticket_count: linked_tickets.len(), linked_ticket_count: linked_tickets.len(),
linked_tickets, linked_tickets,
linked_ticket_keys,
} }
} }
@@ -2927,7 +3000,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 +3111,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 +3141,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 +3232,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();
@@ -3245,16 +3338,21 @@ VALUES ('workspace-test', 'ticket', 4);
assert!(!objective.revision.is_empty()); assert!(!objective.revision.is_empty());
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2"); assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
assert_eq!(objective.linked_ticket_summaries[0].state, "ready"); assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
let objective_query = authority let objective_query = authority
.query_objectives(ObjectiveQueryRequest { .query_objectives(ObjectiveQueryRequest {
query: Some("Control plane".to_string()), query: Some("Control plane".to_string()),
linked_ticket_id: Some("00000000001J2".to_string()), linked_ticket_id: Some(linked_ticket_key.clone()),
limit: Some(1), limit: Some(1),
..ObjectiveQueryRequest::default() ..ObjectiveQueryRequest::default()
}) })
.unwrap(); .unwrap();
assert_eq!(objective_query.items.len(), 1); assert_eq!(objective_query.items.len(), 1);
assert_eq!(objective_query.items[0].linked_ticket_count, 1); assert_eq!(objective_query.items[0].linked_ticket_count, 1);
assert_eq!(
objective_query.items[0].linked_ticket_keys,
vec![linked_ticket_key]
);
assert_eq!(objective_query.page.limit, 1); assert_eq!(objective_query.page.limit, 1);
let body_query = authority let body_query = authority
.query_objectives(ObjectiveQueryRequest { .query_objectives(ObjectiveQueryRequest {
@@ -3347,7 +3445,7 @@ VALUES ('workspace-test', 'ticket', 3);
title: "Create Objective".to_string(), title: "Create Objective".to_string(),
body_md: "Alpha body".to_string(), body_md: "Alpha body".to_string(),
state: "active".to_string(), state: "active".to_string(),
linked_tickets: vec!["00000000001J2".to_string()], linked_tickets: vec!["T-1".to_string()],
}) })
.unwrap(); .unwrap();
assert_eq!(created.title, "Create Objective"); assert_eq!(created.title, "Create Objective");
@@ -3373,14 +3471,14 @@ VALUES ('workspace-test', 'ticket', 3);
assert_eq!(state.state, "paused"); assert_eq!(state.state, "paused");
assert_eq!( assert_eq!(
authority authority
.link_objective_ticket(&created.id, "00000000001J3") .link_objective_ticket(&created.id, "T-2")
.unwrap() .unwrap()
.linked_tickets, .linked_tickets,
vec!["00000000001J2", "00000000001J3"] vec!["00000000001J2", "00000000001J3"]
); );
assert_eq!( assert_eq!(
authority authority
.unlink_objective_ticket(&created.id, "00000000001J2") .unlink_objective_ticket(&created.id, "T-1")
.unwrap() .unwrap()
.linked_tickets, .linked_tickets,
vec!["00000000001J3"] vec!["00000000001J3"]
+204 -554
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,
assert_eq!( ..
resolved.server.embedded_runtime_store_root, } = &resolved.server.auth;
PathBuf::from("/tmp/yoi-runtime") assert_eq!(rp_id, "yoi.example");
); assert_eq!(origin, "https://yoi.example");
assert_eq!(public_base_url, "https://yoi.example");
} }
#[test] #[test]
fn data_root_derives_runtime_store_path_only() { fn browser_public_url_rejects_non_origin_urls() {
let dir = tempfile::tempdir().unwrap(); for value in [
let config = WorkspaceBackendConfigFile::parse_str( "https://example.test/path",
r#" "https://example.test?query=true",
[data] "file:///tmp/web",
root = ".local-data" ] {
"#, let host_config = ServerHostConfigFile {
"test", browser: ServerBrowserConfig {
) public_url: value.to_string(),
.unwrap(); },
let resolved = config.resolve(dir.path(), identity()).unwrap(); };
let result = ResolvedWorkspaceBackendConfig::local_dev(
assert!(resolved.database_path.ends_with("server.db")); tempfile::tempdir().unwrap().path(),
assert_eq!( identity(),
resolved.server.embedded_runtime_store_root, &host_config,
dir.path().join(".local-data/embedded-runtime") &BackendRuntimesConfigFile::default(),
); );
let error = match result {
Ok(_) => panic!("expected {value} to be rejected"),
Err(error) => error,
};
assert!(
error.to_string().contains("browser.public_url"),
"unexpected error for {value}: {error}"
);
}
} }
#[test] #[test]
fn copies_local_config_without_overwriting() { fn server_host_config_loads_only_from_the_explicit_host_path() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap(); let path = ServerHostConfigFile::path_for_config_dir(dir.path());
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( fs::write(
WorkspaceBackendConfigFile::path_for_workspace(dir.path()), &path,
"[server]\nlisten = \"127.0.0.1:9999\"\n", "[browser]\npublic_url = \"https://deploy.example.test\"\n",
) )
.unwrap(); .unwrap();
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(diff.differs); let loaded = ServerHostConfigFile::load_from_path(&path).unwrap();
assert!(diff.text.contains("+++ workspace local")); assert_eq!(loaded.browser.public_url, "https://deploy.example.test");
assert!(diff.text.contains("127.0.0.1:9999")); assert_eq!(path, dir.path().join("server.toml"));
} }
#[test] #[test]
fn resolves_repository_uri_relative_to_workspace_root() { fn explicit_missing_server_host_config_fails_closed() {
let dir = tempfile::tempdir().unwrap(); let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err();
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,
};
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"),
+96 -142
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, os: "test".to_string(),
can_list_workers: true, arch: "test".to_string(),
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(),
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()
);
}
} }
+6 -7
View File
@@ -1,12 +1,9 @@
use project_record::validate_record_id;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use workspace_api::{ pub use workspace_api::{
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary, ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary,
ObjectiveSummary, QueryPage, ObjectiveSummary, QueryPage,
}; };
use crate::{Error, Result};
const SUMMARY_BODY_LIMIT: usize = 240; const SUMMARY_BODY_LIMIT: usize = 240;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -296,6 +293,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>,
} }
@@ -386,12 +384,16 @@ pub struct TicketQueryItem {
pub snippet: Option<String>, pub snippet: Option<String>,
pub matching_event: Option<TicketEvidenceEvent>, pub matching_event: Option<TicketEvidenceEvent>,
pub linked_objective_ids: Vec<String>, pub linked_objective_ids: Vec<String>,
#[ts(skip)]
pub linked_objective_keys: Vec<String>,
pub relation_count: usize, pub relation_count: usize,
pub blocker_count: usize, pub blocker_count: usize,
pub unresolved_blocker_count: usize, pub unresolved_blocker_count: usize,
pub unresolved_review_count: usize, pub unresolved_review_count: usize,
pub evidence: TicketEvidenceSummary, pub evidence: TicketEvidenceSummary,
pub merge_request: Option<TicketMergeRequestSummary>, pub merge_request: Option<TicketMergeRequestSummary>,
#[ts(skip)]
pub current_coder: Option<TicketAssignmentSummary>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -434,6 +436,7 @@ pub struct ObjectiveQueryItem {
pub snippet: Option<String>, pub snippet: Option<String>,
pub linked_ticket_count: usize, pub linked_ticket_count: usize,
pub linked_tickets: Vec<String>, pub linked_tickets: Vec<String>,
pub linked_ticket_keys: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -520,10 +523,6 @@ mod typescript_tests {
} }
} }
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
}
pub(crate) fn summarize_body(body: &str) -> String { pub(crate) fn summarize_body(body: &str) -> String {
let summary = body let summary = body
.lines() .lines()
+117 -14
View File
@@ -5,6 +5,7 @@ use std::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use workspace_api::{RepositoryObservedStatus, RepositorySource};
pub type RepositoryId = String; pub type RepositoryId = String;
pub type RepositorySelector = String; pub type RepositorySelector = String;
@@ -13,8 +14,12 @@ pub type RepositorySelector = String;
pub struct ConfiguredRepository { pub struct ConfiguredRepository {
pub id: RepositoryId, pub id: RepositoryId,
pub provider: String, pub provider: String,
pub uri: String, pub source: RepositorySource,
pub path: PathBuf, pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
pub observed_at: Option<String>,
pub path: Option<PathBuf>,
pub display_name: Option<String>, pub display_name: Option<String>,
pub default_selector: Option<RepositorySelector>, pub default_selector: Option<RepositorySelector>,
} }
@@ -25,6 +30,12 @@ pub struct RepositorySummary {
pub display_name: String, pub display_name: String,
pub kind: String, pub kind: String,
pub provider: String, pub provider: String,
pub source: RepositorySource,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub default_selector: Option<RepositorySelector>, pub default_selector: Option<RepositorySelector>,
pub record_authority: String, pub record_authority: String,
@@ -262,9 +273,17 @@ impl RepositoryRegistryReader {
descendant: &str, descendant: &str,
) -> Result<(), RepositoryLookupError> { ) -> Result<(), RepositoryLookupError> {
let repository = self.merge_repository(id)?; let repository = self.merge_repository(id)?;
let repository_path =
repository
.path
.as_ref()
.ok_or_else(|| RepositoryLookupError::ProviderFailure {
id: id.into(),
operation: "repository source is not materialized for local Git access".into(),
})?;
let status = Command::new("git") let status = Command::new("git")
.arg("-C") .arg("-C")
.arg(&repository.path) .arg(repository_path)
.args(["merge-base", "--is-ancestor", ancestor, descendant]) .args(["merge-base", "--is-ancestor", ancestor, descendant])
.status() .status()
.map_err(|_| RepositoryLookupError::ProviderFailure { .map_err(|_| RepositoryLookupError::ProviderFailure {
@@ -306,7 +325,24 @@ impl RepositoryRegistryReader {
.clone() .clone()
.unwrap_or_else(|| repository.id.clone()); .unwrap_or_else(|| repository.id.clone());
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
if repository.source.kind == workspace_api::RepositorySourceKind::Http {
diagnostics.push(RepositoryDiagnostic {
severity: "warning".to_string(),
code: "repository_source_insecure_http".to_string(),
message:
"HTTP Repository source is unencrypted; prefer HTTPS or SSH when available."
.to_string(),
});
}
let git = match repository.provider.as_str() { let git = match repository.provider.as_str() {
"git" if repository.path.is_none() => {
diagnostics.push(RepositoryDiagnostic {
severity: "info".to_string(),
code: "repository_source_unverified".to_string(),
message: "Remote Repository source is registered but is not materialized for server-local inspection.".to_string(),
});
None
}
"git" => match self.inspect_git(repository) { "git" => match self.inspect_git(repository) {
Ok(git) => Some(git), Ok(git) => Some(git),
Err(message) => { Err(message) => {
@@ -335,6 +371,11 @@ impl RepositoryRegistryReader {
display_name, display_name,
kind: repository.provider.clone(), kind: repository.provider.clone(),
provider: repository.provider.clone(), provider: repository.provider.clone(),
source: repository.source.clone(),
source_revision: repository.source_revision,
source_fingerprint: repository.source_fingerprint.clone(),
observed_status: repository.observed_status,
observed_at: repository.observed_at.clone(),
default_selector: repository.default_selector.clone(), default_selector: repository.default_selector.clone(),
record_authority: "workspace-control-plane".to_string(), record_authority: "workspace-control-plane".to_string(),
git, git,
@@ -346,12 +387,15 @@ impl RepositoryRegistryReader {
&self, &self,
repository: &ConfiguredRepository, repository: &ConfiguredRepository,
) -> Result<GitRepositorySummary, String> { ) -> Result<GitRepositorySummary, String> {
let head = git_stdout(&repository.path, ["rev-parse", "HEAD"])?; let path = repository.path.as_ref().ok_or_else(|| {
let branch = git_stdout(&repository.path, ["branch", "--show-current"]) "Repository source is not materialized for local Git inspection.".to_string()
})?;
let head = git_stdout(path, ["rev-parse", "HEAD"])?;
let branch = git_stdout(path, ["branch", "--show-current"])
.ok() .ok()
.and_then(|value| non_empty_string(value.trim())); .and_then(|value| non_empty_string(value.trim()));
let status = git_stdout(&repository.path, ["status", "--porcelain"])?; let status = git_stdout(path, ["status", "--porcelain"])?;
let remotes = git_stdout(&repository.path, ["remote", "-v"]) let remotes = git_stdout(path, ["remote", "-v"])
.map(|raw| parse_remotes(&raw)) .map(|raw| parse_remotes(&raw))
.unwrap_or_default(); .unwrap_or_default();
Ok(GitRepositorySummary { Ok(GitRepositorySummary {
@@ -369,8 +413,11 @@ impl RepositoryRegistryReader {
limit: usize, limit: usize,
) -> Result<Vec<GitCommitSummary>, String> { ) -> Result<Vec<GitCommitSummary>, String> {
let limit_arg = format!("-{limit}"); let limit_arg = format!("-{limit}");
let path = repository.path.as_ref().ok_or_else(|| {
"Repository source is not materialized for local Git log access.".to_string()
})?;
let output = git_stdout( let output = git_stdout(
&repository.path, path,
[ [
"log", "log",
"--date=iso-strict", "--date=iso-strict",
@@ -419,11 +466,16 @@ fn merge_git_stdout(
operation: &str, operation: &str,
args: &[&str], args: &[&str],
) -> Result<String, RepositoryLookupError> { ) -> Result<String, RepositoryLookupError> {
git_stdout(&repository.path, args.iter().copied()).map_err(|_| { let path = repository
RepositoryLookupError::ProviderFailure { .path
.as_ref()
.ok_or_else(|| RepositoryLookupError::ProviderFailure {
id: repository.id.clone(), id: repository.id.clone(),
operation: operation.into(), operation: "repository source is not materialized for local Git access".into(),
} })?;
git_stdout(path, args.iter().copied()).map_err(|_| RepositoryLookupError::ProviderFailure {
id: repository.id.clone(),
operation: operation.into(),
}) })
} }
@@ -593,6 +645,47 @@ mod tests {
assert_eq!(projection.diagnostics[0].code, "repository_config_empty"); assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
} }
#[test]
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
let source = RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "git@example.test:org/repository.git".to_string(),
};
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
id: "remote".into(),
display_name: Some("Remote".into()),
provider: "git".into(),
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
source,
source_revision: 1,
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
path: None,
default_selector: Some("main".into()),
}]);
let projection = reader.list();
let summary = &projection.items[0];
assert_eq!(
summary.source.kind,
workspace_api::RepositorySourceKind::Ssh
);
assert_eq!(
summary.observed_status,
RepositoryObservedStatus::Unverified
);
assert!(summary.git.is_none());
assert_eq!(summary.diagnostics[0].code, "repository_source_unverified");
let repository = reader.merge_repository("remote").unwrap();
let error = merge_git_stdout(&repository, "inspect", &["rev-parse", "HEAD"]).unwrap_err();
assert!(matches!(
error,
RepositoryLookupError::ProviderFailure { operation, .. }
if operation.contains("not materialized")
));
}
#[test] #[test]
fn merge_evidence_is_resolved_by_repository_identity() { fn merge_evidence_is_resolved_by_repository_identity() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -675,12 +768,22 @@ mod tests {
.success() .success()
); );
let source_descriptor = RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: path.display().to_string(),
};
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository { let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
id: "main".into(), id: "main".into(),
display_name: Some("Main".into()), display_name: Some("Main".into()),
provider: "git".into(), provider: "git".into(),
path: path.to_path_buf(), source_fingerprint: crate::repository_source::repository_source_fingerprint(
uri: path.display().to_string(), &source_descriptor,
),
source: source_descriptor,
source_revision: 1,
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
path: Some(path.to_path_buf()),
default_selector: Some("main".into()), default_selector: Some("main".into()),
}]); }]);
let target = reader.observe_merge_target("main", Some("main")).unwrap(); let target = reader.observe_merge_target("main", Some("main")).unwrap();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,230 @@
use std::path::Path;
use sha2::{Digest, Sha256};
use url::Url;
use workspace_api::{RepositorySource, RepositorySourceKind};
use crate::{Error, Result};
const MAX_REPOSITORY_SOURCE_BYTES: usize = 4096;
/// Parse and canonicalize a user-authored Git source without accessing the
/// filesystem or network.
pub fn parse_repository_source(value: &str) -> Result<RepositorySource> {
let value = value.trim();
if value.is_empty() || value.len() > MAX_REPOSITORY_SOURCE_BYTES {
return Err(Error::InvalidInput(format!(
"initial repository source must be between 1 and {MAX_REPOSITORY_SOURCE_BYTES} bytes"
)));
}
if value.chars().any(char::is_control) {
return Err(Error::InvalidInput(
"initial repository source must not contain control characters".to_string(),
));
}
if Path::new(value).is_absolute() {
return Ok(RepositorySource {
kind: RepositorySourceKind::LocalPath,
uri: value.to_string(),
});
}
if is_scp_like_ssh(value) {
validate_scp_like_ssh(value)?;
return Ok(RepositorySource {
kind: RepositorySourceKind::Ssh,
uri: value.to_string(),
});
}
let parsed = Url::parse(value).map_err(|_| {
Error::InvalidInput(
"initial repository source must be an absolute local path or a supported Git URI"
.to_string(),
)
})?;
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err(Error::InvalidInput(
"initial repository source must not contain query parameters or fragments".to_string(),
));
}
if parsed.password().is_some() {
return Err(Error::InvalidInput(
"initial repository source must not embed a password or token".to_string(),
));
}
let kind = match parsed.scheme() {
"file" => {
if !parsed.username().is_empty() {
return Err(Error::InvalidInput(
"file repository URI must not contain user information".to_string(),
));
}
if parsed.host_str().is_some_and(|host| host != "localhost") {
return Err(Error::InvalidInput(
"file repository URI host must be empty or localhost".to_string(),
));
}
parsed.to_file_path().map_err(|_| {
Error::InvalidInput("file repository URI must contain an absolute path".to_string())
})?;
RepositorySourceKind::File
}
"ssh" => {
require_remote_host_and_path(&parsed)?;
RepositorySourceKind::Ssh
}
"http" | "https" => {
if !parsed.username().is_empty() {
return Err(Error::InvalidInput(
"HTTP repository URI must not contain user information".to_string(),
));
}
require_remote_host_and_path(&parsed)?;
if parsed.scheme() == "http" {
RepositorySourceKind::Http
} else {
RepositorySourceKind::Https
}
}
scheme => {
return Err(Error::InvalidInput(format!(
"unsupported initial repository source scheme `{scheme}`"
)));
}
};
Ok(RepositorySource {
kind,
uri: parsed.to_string(),
})
}
/// Classify persisted pre-source-contract rows without guessing a usable remote
/// when the legacy value is malformed. No filesystem or network access occurs.
pub fn classify_legacy_repository_source(value: &str) -> RepositorySource {
parse_repository_source(value).unwrap_or_else(|_| RepositorySource {
kind: RepositorySourceKind::Invalid,
uri: value.trim().to_string(),
})
}
pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
let payload = serde_json::to_vec(source).expect("Repository source serializes");
let mut hasher = Sha256::new();
hasher.update(b"yoi.repository-source.v1\0");
hasher.update(payload);
let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
}
format!("sha256:{encoded}")
}
fn require_remote_host_and_path(parsed: &Url) -> Result<()> {
if parsed.host_str().is_none() || parsed.path().is_empty() || parsed.path() == "/" {
return Err(Error::InvalidInput(
"remote repository URI must contain a host and repository path".to_string(),
));
}
Ok(())
}
fn is_scp_like_ssh(value: &str) -> bool {
!value.contains("://")
&& value
.split_once(':')
.is_some_and(|(identity, _)| identity.contains('@'))
}
fn validate_scp_like_ssh(value: &str) -> Result<()> {
let (identity, path) = value.split_once(':').ok_or_else(|| {
Error::InvalidInput("scp-like SSH source must contain `host:path`".to_string())
})?;
let (username, host) = identity.split_once('@').ok_or_else(|| {
Error::InvalidInput("scp-like SSH source must contain `user@host:path`".to_string())
})?;
if username.is_empty()
|| host.is_empty()
|| path.is_empty()
|| username.contains('@')
|| username.contains(':')
|| host.contains('@')
|| path.starts_with('-')
|| value.contains('?')
|| value.contains('#')
|| value.chars().any(char::is_whitespace)
{
return Err(Error::InvalidInput(
"scp-like SSH source must use `user@host:path` without credentials or parameters"
.to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_local_file_ssh_http_and_https_sources_without_io() {
let cases = [
("/runtime/repos/project", RepositorySourceKind::LocalPath),
("file:///runtime/repos/project", RepositorySourceKind::File),
(
"ssh://git@example.test/org/project.git",
RepositorySourceKind::Ssh,
),
(
"git@example.test:org/project.git",
RepositorySourceKind::Ssh,
),
(
"http://git.test/org/project.git",
RepositorySourceKind::Http,
),
(
"https://git.test/org/project.git",
RepositorySourceKind::Https,
),
];
for (source, expected_kind) in cases {
assert_eq!(parse_repository_source(source).unwrap().kind, expected_kind);
}
}
#[test]
fn rejects_relative_unsupported_and_credential_bearing_sources() {
for source in [
"relative/project",
"ftp://git.test/project.git",
"https://user@git.test/project.git",
"https://git.test/project.git?token=secret",
"ssh://git:secret@git.test/project.git",
"git@example.test:",
"git:secret@example.test:org/project.git",
"https://git.test/project.git\nother",
] {
assert!(
parse_repository_source(source).is_err(),
"accepted {source:?}"
);
}
}
#[test]
fn fingerprint_uses_canonical_source_identity() {
let first = parse_repository_source(" https://EXAMPLE.test/a/../project.git ").unwrap();
let second = parse_repository_source("https://example.test/project.git").unwrap();
assert_eq!(first, second);
assert_eq!(
repository_source_fingerprint(&first),
repository_source_fingerprint(&second)
);
}
}
+199 -33
View File
@@ -9,7 +9,8 @@ use worker_runtime::resource::{
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest, BackendResourceClient, BackendResourceError, BackendResourceFetchRequest,
BackendResourceFetchResponse, BackendResourceHandle, BackendResourceKind, BackendResourceFetchResponse, BackendResourceHandle, BackendResourceKind,
BackendResourceOperation, DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, BackendResourceOperation, DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES,
PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE, ResourceRedactionPolicy, DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE,
REPOSITORY_SSH_ACCESS_CONTENT_TYPE, RepositorySshAccessSecret, ResourceRedactionPolicy,
}; };
#[derive(Clone, Default)] #[derive(Clone, Default)]
@@ -29,7 +30,34 @@ struct StoredResource {
runtime_id: Option<String>, runtime_id: Option<String>,
worker: Option<RuntimeWorkerRef>, worker: Option<RuntimeWorkerRef>,
handle: BackendResourceHandle, handle: BackendResourceHandle,
archive: ProfileSourceArchive, bytes: Vec<u8>,
archive: Option<ProfileSourceArchive>,
one_shot: bool,
}
impl StoredResource {
fn byte_len(&self) -> usize {
self.archive
.as_ref()
.map(|archive| archive.content.len())
.unwrap_or_else(|| self.bytes.len())
}
fn take_bytes(&mut self) -> Vec<u8> {
self.archive
.as_mut()
.map(|archive| std::mem::take(&mut archive.content))
.unwrap_or_else(|| std::mem::take(&mut self.bytes))
}
}
impl Drop for StoredResource {
fn drop(&mut self) {
self.bytes.fill(0);
if let Some(archive) = self.archive.as_mut() {
archive.content.fill(0);
}
}
} }
impl BackendResourceBroker { impl BackendResourceBroker {
@@ -73,7 +101,9 @@ impl BackendResourceBroker {
runtime_id, runtime_id,
worker, worker,
handle: handle.clone(), handle: handle.clone(),
archive, bytes: Vec::new(),
archive: Some(archive),
one_shot: false,
}; };
if let Ok(mut resources) = self.resources.lock() { if let Ok(mut resources) = self.resources.lock() {
resources.insert(nonce, stored); resources.insert(nonce, stored);
@@ -81,6 +111,84 @@ impl BackendResourceBroker {
handle handle
} }
pub fn issue_repository_ssh_access_handle(
&self,
workspace_id: impl Into<String>,
runtime_id: &str,
resource_id: impl Into<String>,
revision: impl Into<String>,
expires_at_unix_seconds: i64,
secret: RepositorySshAccessSecret,
) -> Result<BackendResourceHandle, BackendResourceError> {
let bytes =
serde_json::to_vec(&secret).map_err(|error| BackendResourceError::InvalidResponse {
message: error.to_string(),
})?;
if bytes.len() as u64 > DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES {
return Err(BackendResourceError::Oversized {
max_bytes: DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
actual_bytes: bytes.len() as u64,
});
}
let workspace_id = workspace_id.into();
let resource_id = resource_id.into();
let revision = revision.into();
let nonce = Uuid::now_v7().to_string();
let handle = BackendResourceHandle {
kind: BackendResourceKind::RepositorySshAccess,
workspace_id,
scope_id: Some("repository-ssh-access".to_string()),
runtime_id: Some(runtime_id.to_string()),
worker_id: None,
resource_id,
digest: format!("opaque:{nonce}"),
operation: BackendResourceOperation::FetchOnce,
expires_at_unix_seconds,
nonce: nonce.clone(),
revision,
generation: None,
max_bytes: DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id: format!("repository-ssh-access-{nonce}"),
profile_source_graph: None,
};
let stored = StoredResource {
runtime_id: Some(runtime_id.to_string()),
worker: None,
handle: handle.clone(),
bytes,
archive: None,
one_shot: true,
};
let resource_key = nonce.clone();
self.resources
.lock()
.map_err(|_| BackendResourceError::Transport {
message: "resource broker lock poisoned".to_string(),
})?
.insert(resource_key.clone(), stored);
if expires_at_unix_seconds != i64::MAX {
let resources = self.resources.clone();
std::thread::spawn(move || {
let now = Utc::now().timestamp();
if expires_at_unix_seconds > now {
std::thread::sleep(std::time::Duration::from_secs(
(expires_at_unix_seconds - now) as u64,
));
}
if let Ok(mut resources) = resources.lock()
&& resources
.get(&resource_key)
.is_some_and(|stored| stored.handle.nonce == resource_key)
{
resources.remove(&resource_key);
}
});
}
Ok(handle)
}
pub fn profile_source_archive( pub fn profile_source_archive(
&self, &self,
digest: &str, digest: &str,
@@ -90,20 +198,21 @@ impl BackendResourceBroker {
.ok()? .ok()?
.values() .values()
.find(|resource| resource.handle.digest == digest) .find(|resource| resource.handle.digest == digest)
.map(|resource| resource.archive.clone()) .and_then(|resource| resource.archive.clone())
} }
pub fn fetch_profile_source_archive( pub fn fetch_resource(
&self, &self,
request: BackendResourceFetchRequest, request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> { ) -> Result<BackendResourceFetchResponse, BackendResourceError> {
verify_handle_shape(&request.handle)?; verify_handle_shape(&request.handle)?;
let stored = self let mut resources = self
.resources .resources
.lock() .lock()
.map_err(|_| BackendResourceError::Transport { .map_err(|_| BackendResourceError::Transport {
message: "resource broker lock poisoned".to_string(), message: "resource broker lock poisoned".to_string(),
})? })?;
let mut stored = resources
.get(&request.handle.nonce) .get(&request.handle.nonce)
.cloned() .cloned()
.ok_or(BackendResourceError::MissingResource)?; .ok_or(BackendResourceError::MissingResource)?;
@@ -111,7 +220,7 @@ impl BackendResourceBroker {
if stored.handle.expires_at_unix_seconds < Utc::now().timestamp() { if stored.handle.expires_at_unix_seconds < Utc::now().timestamp() {
return Err(BackendResourceError::Expired); return Err(BackendResourceError::Expired);
} }
let actual_bytes = stored.archive.content.len() as u64; let actual_bytes = stored.byte_len() as u64;
if actual_bytes > stored.handle.max_bytes { if actual_bytes > stored.handle.max_bytes {
return Err(BackendResourceError::Oversized { return Err(BackendResourceError::Oversized {
max_bytes: stored.handle.max_bytes, max_bytes: stored.handle.max_bytes,
@@ -139,12 +248,15 @@ impl BackendResourceBroker {
}); });
} }
} }
if stored.one_shot {
resources.remove(&request.handle.nonce);
}
Ok(BackendResourceFetchResponse { Ok(BackendResourceFetchResponse {
kind: BackendResourceKind::ProfileSourceArchive, kind: stored.handle.kind.clone(),
resource_id: stored.archive.reference.id, resource_id: stored.handle.resource_id.clone(),
digest: stored.archive.reference.digest, digest: stored.handle.digest.clone(),
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), content_type: stored.handle.content_type.clone(),
bytes: stored.archive.content, bytes: stored.take_bytes(),
audit_correlation_id: request.audit_correlation_id, audit_correlation_id: request.audit_correlation_id,
}) })
} }
@@ -156,24 +268,38 @@ impl BackendResourceClient for BackendResourceBroker {
&self, &self,
request: BackendResourceFetchRequest, request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> { ) -> Result<BackendResourceFetchResponse, BackendResourceError> {
self.fetch_profile_source_archive(request) self.fetch_resource(request)
} }
} }
fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendResourceError> { fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendResourceError> {
if handle.kind != BackendResourceKind::ProfileSourceArchive { match handle.kind {
return Err(BackendResourceError::UnsupportedKind); BackendResourceKind::ProfileSourceArchive => {
} if handle.operation != BackendResourceOperation::FetchArchive {
if handle.operation != BackendResourceOperation::FetchArchive { return Err(BackendResourceError::Unauthorized {
return Err(BackendResourceError::Unauthorized { message: "resource handle operation is not fetch_archive".to_string(),
message: "resource handle operation is not fetch_archive".to_string(), });
}); }
} if handle.content_type != PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE {
if handle.content_type != PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE { return Err(BackendResourceError::ContentTypeMismatch {
return Err(BackendResourceError::ContentTypeMismatch { expected: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
expected: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), actual: handle.content_type.clone(),
actual: handle.content_type.clone(), });
}); }
}
BackendResourceKind::RepositorySshAccess => {
if handle.operation != BackendResourceOperation::FetchOnce {
return Err(BackendResourceError::Unauthorized {
message: "resource handle operation is not fetch_once".to_string(),
});
}
if handle.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE {
return Err(BackendResourceError::ContentTypeMismatch {
expected: REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
actual: handle.content_type.clone(),
});
}
}
} }
Ok(()) Ok(())
} }
@@ -249,7 +375,7 @@ mod tests {
archive(), archive(),
); );
let response = broker let response = broker
.fetch_profile_source_archive(BackendResourceFetchRequest { .fetch_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,
@@ -260,6 +386,46 @@ mod tests {
assert_eq!(response.content_type, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE); assert_eq!(response.content_type, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE);
} }
#[test]
fn repository_ssh_access_resource_is_runtime_bound_and_one_shot() {
let broker = BackendResourceBroker::default();
let handle = broker
.issue_repository_ssh_access_handle(
"workspace-test",
"runtime-test",
"repository-access-test",
"1",
i64::MAX,
RepositorySshAccessSecret {
private_key: "private-key-bytes".to_string(),
known_hosts_entry: "known-hosts-entry".to_string(),
},
)
.unwrap();
let unauthorized = broker
.fetch_resource(request(handle.clone(), "runtime-other", None))
.unwrap_err();
assert!(matches!(
unauthorized,
BackendResourceError::Unauthorized { .. }
));
let response = broker
.fetch_resource(request(handle.clone(), "runtime-test", None))
.unwrap();
assert_eq!(response.kind, BackendResourceKind::RepositorySshAccess);
assert_eq!(response.content_type, REPOSITORY_SSH_ACCESS_CONTENT_TYPE);
let debug = format!("{response:?}");
assert!(!debug.contains("private-key-bytes"));
assert!(debug.contains("REDACTED"));
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap();
assert_eq!(secret.private_key, "private-key-bytes");
assert!(matches!(
broker.fetch_resource(request(handle, "runtime-test", None)),
Err(BackendResourceError::MissingResource)
));
}
#[test] #[test]
fn broker_rejects_runtime_mismatch() { fn broker_rejects_runtime_mismatch() {
let broker = BackendResourceBroker::default(); let broker = BackendResourceBroker::default();
@@ -270,7 +436,7 @@ mod tests {
archive(), archive(),
); );
let err = broker let err = broker
.fetch_profile_source_archive(request(handle, "runtime-b", None)) .fetch_resource(request(handle, "runtime-b", None))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. })); assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
} }
@@ -287,7 +453,7 @@ mod tests {
archive(), archive(),
); );
let err = broker let err = broker
.fetch_profile_source_archive(request(handle, runtime_id, Some(&worker_b.worker_id))) .fetch_resource(request(handle, runtime_id, Some(&worker_b.worker_id)))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. })); assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
} }
@@ -312,7 +478,7 @@ mod tests {
let mut extended = handle; let mut extended = handle;
extended.expires_at_unix_seconds = 4_102_444_800; extended.expires_at_unix_seconds = 4_102_444_800;
let err = broker let err = broker
.fetch_profile_source_archive(request(extended, &runtime_id, None)) .fetch_resource(request(extended, &runtime_id, None))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Expired)); assert!(matches!(err, BackendResourceError::Expired));
} }
@@ -328,7 +494,7 @@ mod tests {
); );
handle.scope_id = Some("tampered-scope".to_string()); handle.scope_id = Some("tampered-scope".to_string());
let err = broker let err = broker
.fetch_profile_source_archive(request(handle, &runtime_id, None)) .fetch_resource(request(handle, &runtime_id, None))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. })); assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
} }
@@ -345,7 +511,7 @@ mod tests {
); );
handle.max_bytes = DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1024; handle.max_bytes = DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1024;
let err = broker let err = broker
.fetch_profile_source_archive(request(handle, &runtime_id, None)) .fetch_resource(request(handle, &runtime_id, None))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Oversized { .. })); assert!(matches!(err, BackendResourceError::Oversized { .. }));
} }
@@ -0,0 +1,229 @@
use config_source::ConfigSchemaContribution;
use serde::Deserialize;
use crate::config_source::{
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
};
use crate::{Error, Result};
const RUNTIME_SCHEMA_SOURCE: &str = r#"{
runtime = {
default_runtime_id = String default "";
};
}"#;
#[derive(Debug, Default)]
pub struct RuntimeConfigSchemaProvider;
impl WorkspaceConfigSchemaProvider for RuntimeConfigSchemaProvider {
fn contribution(&self) -> Result<ConfigSchemaContribution> {
ConfigSchemaContribution::new("builtin:runtime", "runtime", "1", RUNTIME_SCHEMA_SOURCE)
.map_err(|error| Error::Config(error.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeConfigProjection {
pub config_revision: u64,
pub projection_digest: String,
pub default_runtime_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct VirtualRuntimeConfig {
runtime: VirtualRuntimeSection,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct VirtualRuntimeSection {
default_runtime_id: String,
}
pub fn project_runtime_from_workspace_config(
workspace_id: &str,
state: &WorkspaceConfigState,
) -> Result<RuntimeConfigProjection> {
let has_runtime_schema = state
.contract
.schema_bundle
.contributions
.iter()
.any(|entry| entry.provider_id == "builtin:runtime");
if !has_runtime_schema {
return Ok(RuntimeConfigProjection {
config_revision: state.snapshot.revision,
projection_digest: state.projection_digest.clone(),
default_runtime_id: None,
});
}
let evaluation = evaluate_workspace_config_state(state, state.contract.schema_bundle.clone())?;
if evaluation.projection_digest != state.projection_digest {
return Err(Error::RegistryInconsistency(format!(
"Runtime projection digest mismatch for Workspace {workspace_id}"
)));
}
let projected = evaluation.projections.first().ok_or_else(|| {
Error::RegistryInconsistency("Workspace config has no active projection".to_string())
})?;
let config: VirtualRuntimeConfig = serde_json::from_value(projected.data_json.clone())
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
let default_runtime_id = normalize_runtime_id(&config.runtime.default_runtime_id)?;
Ok(RuntimeConfigProjection {
config_revision: state.snapshot.revision,
projection_digest: evaluation.projection_digest,
default_runtime_id,
})
}
fn normalize_runtime_id(value: &str) -> Result<Option<String>> {
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
if value.chars().any(char::is_control) {
return Err(Error::InvalidRuntimeIdentifier {
kind: "runtime_id".to_string(),
value: "[redacted invalid value]".to_string(),
});
}
Ok(Some(value.to_string()))
}
#[cfg(test)]
mod tests {
use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath};
use super::*;
fn state(source: &str) -> WorkspaceConfigState {
let bundle =
config_source::WorkspaceConfigSchemaBundle::compose([RuntimeConfigSchemaProvider
.contribution()
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
7,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
source,
)
.unwrap()],
)
.unwrap();
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![VirtualPath::parse("main.dcdl").unwrap()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
);
let projection_digest = config_source::SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract)
.unwrap()
.projection_digest;
WorkspaceConfigState {
snapshot,
contract,
projection_digest,
}
}
#[test]
fn runtime_projection_reads_default_and_preserves_revision_evidence() {
let projection = project_runtime_from_workspace_config(
"workspace",
&state(
r#"{ runtime = { default_runtime_id = "arcadia"; }; } as WorkspaceConfigSchema"#,
),
)
.unwrap();
assert_eq!(projection.default_runtime_id.as_deref(), Some("arcadia"));
assert_eq!(projection.config_revision, 7);
assert!(!projection.projection_digest.is_empty());
}
#[test]
fn runtime_projection_treats_missing_default_as_unconfigured() {
let projection = project_runtime_from_workspace_config(
"workspace",
&state("{} as WorkspaceConfigSchema"),
)
.unwrap();
assert_eq!(projection.default_runtime_id, None);
}
#[test]
fn runtime_projection_treats_pre_runtime_schema_bundle_as_unconfigured() {
let bundle =
config_source::WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:legacy",
"legacy",
"1",
"{ legacy = { enabled = Bool default false; }; }",
)
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
6,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ legacy = { enabled = true; }; } as WorkspaceConfigSchema",
)
.unwrap()],
)
.unwrap();
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![VirtualPath::parse("main.dcdl").unwrap()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
);
let projection_digest = config_source::SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract)
.unwrap()
.projection_digest;
let state = WorkspaceConfigState {
snapshot,
contract,
projection_digest: projection_digest.clone(),
};
let projection = project_runtime_from_workspace_config("workspace", &state).unwrap();
assert_eq!(projection.default_runtime_id, None);
assert_eq!(projection.config_revision, 6);
assert_eq!(projection.projection_digest, projection_digest);
}
#[test]
fn runtime_schema_rejects_non_string_default() {
let bundle =
config_source::WorkspaceConfigSchemaBundle::compose([RuntimeConfigSchemaProvider
.contribution()
.unwrap()])
.unwrap();
let snapshot = ConfigTreeSnapshot::from_entries(
1,
[ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ runtime = { default_runtime_id = 42; }; } as WorkspaceConfigSchema",
)
.unwrap()],
)
.unwrap();
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![VirtualPath::parse("main.dcdl").unwrap()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
);
assert!(
config_source::SnapshotEnvironment::new(snapshot)
.evaluate_contract(&contract)
.is_err()
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,426 @@
use rusqlite::{OptionalExtension, params};
use sha2::{Digest, Sha256};
use crate::store::WorkdirCreateOperationRecord;
use crate::{Error, Result, SqliteWorkspaceStore};
pub fn selector_for_retry(
explicit_selector: Option<&str>,
persisted_selector: Option<&str>,
current_default_selector: Option<&str>,
) -> Option<String> {
explicit_selector
.or(persisted_selector)
.or(current_default_selector)
.map(str::to_string)
}
pub fn request_fingerprint(
repository_id: &str,
selector: Option<&str>,
requested_runtime_id: Option<&str>,
repository_source_fingerprint: &str,
repository_source_revision: u64,
) -> String {
let mut hasher = Sha256::new();
for value in [
Some(repository_id),
selector,
requested_runtime_id,
Some(repository_source_fingerprint),
] {
match value {
Some(value) => {
hasher.update([1]);
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value.as_bytes());
}
None => hasher.update([0]),
}
}
hasher.update(repository_source_revision.to_be_bytes());
let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
}
format!("sha256:{encoded}")
}
impl SqliteWorkspaceStore {
pub fn reserve_workdir_create_operation(
&self,
record: &WorkdirCreateOperationRecord,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let tx = conn.transaction()?;
tx.execute(
r#"INSERT OR IGNORE INTO workdir_create_operations (
workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, working_directory_id, state, failure,
created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)"#,
params![
record.workspace_id,
record.operation_id,
record.request_fingerprint,
record.repository_id,
record.selector,
record.requested_runtime_id,
record.resolved_runtime_id,
record.config_revision as i64,
record.config_projection_digest,
record.source_kind,
record.source_uri,
record.source_revision.map(|revision| revision as i64),
record.source_fingerprint,
record.working_directory_id,
record.state,
record.failure,
record.created_at,
record.updated_at,
],
)?;
let persisted =
read_workdir_create_operation(&tx, &record.workspace_id, &record.operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{}` was not persisted",
record.operation_id
))
})?;
if persisted.request_fingerprint != record.request_fingerprint {
return Err(Error::InvalidInput(format!(
"Workdir create operation `{}` was reused with different input",
record.operation_id
)));
}
tx.commit()?;
Ok(persisted)
})
}
pub fn bind_workdir_create_repository_access(
&self,
workspace_id: &str,
operation_id: &str,
request_fingerprint: &str,
credential_id: &str,
credential_revision: u64,
host_trust_id: &str,
host_trust_revision: u64,
repository_access_mode: &str,
cache_generation: u64,
now: &str,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let operation = read_workdir_create_operation(conn, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared before Repository access binding"
))
})?;
if operation.request_fingerprint != request_fingerprint {
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` was reused with different input"
)));
}
if let Some(existing) = operation.credential_id.as_deref() {
if existing != credential_id
|| operation.credential_revision != Some(credential_revision)
|| operation.host_trust_id.as_deref() != Some(host_trust_id)
|| operation.host_trust_revision != Some(host_trust_revision)
|| operation.repository_access_mode.as_deref()
!= Some(repository_access_mode)
|| operation.cache_generation != cache_generation
{
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` Repository access evidence changed"
)));
}
return Ok(operation);
}
conn.execute(
r#"UPDATE workdir_create_operations
SET credential_id = ?4, credential_revision = ?5,
host_trust_id = ?6, host_trust_revision = ?7,
repository_access_mode = ?8, cache_generation = ?9,
updated_at = ?10
WHERE workspace_id = ?1 AND operation_id = ?2
AND request_fingerprint = ?3 AND credential_id IS NULL"#,
params![
workspace_id,
operation_id,
request_fingerprint,
credential_id,
i64::try_from(credential_revision).map_err(|_| Error::InvalidInput(
"credential revision is out of range".to_string()
))?,
host_trust_id,
i64::try_from(host_trust_revision).map_err(|_| Error::InvalidInput(
"host-trust revision is out of range".to_string()
))?,
repository_access_mode,
i64::try_from(cache_generation).map_err(|_| Error::InvalidInput(
"cache generation is out of range".to_string()
))?,
now,
],
)?;
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after Repository access binding"
))
})
})
}
pub fn finish_workdir_create_operation(
&self,
workspace_id: &str,
operation_id: &str,
request_fingerprint: &str,
succeeded: bool,
failure: Option<&str>,
updated_at: &str,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let changed = conn.execute(
r#"UPDATE workdir_create_operations
SET state = ?1, failure = ?2, updated_at = ?3
WHERE workspace_id = ?4 AND operation_id = ?5
AND request_fingerprint = ?6"#,
params![
if succeeded { "succeeded" } else { "failed" },
failure,
updated_at,
workspace_id,
operation_id,
request_fingerprint,
],
)?;
if changed != 1 {
return Err(Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` could not be finalized"
)));
}
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared"
))
})
})
}
pub fn load_workdir_create_operation(
&self,
workspace_id: &str,
operation_id: &str,
) -> Result<Option<WorkdirCreateOperationRecord>> {
self.with_conn(|conn| read_workdir_create_operation(conn, workspace_id, operation_id))
}
}
fn read_workdir_create_operation(
conn: &rusqlite::Connection,
workspace_id: &str,
operation_id: &str,
) -> Result<Option<WorkdirCreateOperationRecord>> {
conn.query_row(
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, credential_id, credential_revision,
host_trust_id, host_trust_revision, repository_access_mode,
cache_generation, working_directory_id, state, failure,
created_at, updated_at
FROM workdir_create_operations
WHERE workspace_id = ?1 AND operation_id = ?2"#,
params![workspace_id, operation_id],
|row| {
Ok(WorkdirCreateOperationRecord {
workspace_id: row.get(0)?,
operation_id: row.get(1)?,
request_fingerprint: row.get(2)?,
repository_id: row.get(3)?,
selector: row.get(4)?,
requested_runtime_id: row.get(5)?,
resolved_runtime_id: row.get(6)?,
config_revision: row.get::<_, i64>(7)? as u64,
config_projection_digest: row.get(8)?,
source_kind: row.get(9)?,
source_uri: row.get(10)?,
source_revision: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
source_fingerprint: row.get(12)?,
credential_id: row.get(13)?,
credential_revision: row.get::<_, Option<i64>>(14)?.map(|value| value as u64),
host_trust_id: row.get(15)?,
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
repository_access_mode: row.get(17)?,
cache_generation: row.get::<_, i64>(18)? as u64,
working_directory_id: row.get(19)?,
state: row.get(20)?,
failure: row.get(21)?,
created_at: row.get(22)?,
updated_at: row.get(23)?,
})
},
)
.optional()
.map_err(Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{ControlPlaneStore, RepositoryRecord, WorkspaceRecord};
#[test]
fn retry_selector_keeps_persisted_default_but_honors_explicit_input() {
assert_eq!(
selector_for_retry(None, Some("develop"), Some("main")),
Some("develop".to_string())
);
assert_eq!(
selector_for_retry(Some("release"), Some("develop"), Some("main")),
Some("release".to_string())
);
assert_eq!(
selector_for_retry(None, None, Some("main")),
Some("main".to_string())
);
}
#[test]
fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace".to_string(),
owner_account_id: None,
display_name: "Workspace".to_string(),
state: "active".to_string(),
created_at: "2026-08-24T00:00:00Z".to_string(),
updated_at: "2026-08-24T00:00:00Z".to_string(),
}))
.unwrap();
store
.upsert_repository(&RepositoryRecord {
workspace_id: "workspace".to_string(),
repository_id: "main".to_string(),
name: "main".to_string(),
kind: "git".to_string(),
provider: Some("git".to_string()),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: "/tmp/main".to_string(),
},
default_ref: Some("develop".to_string()),
source_revision: 1,
source_fingerprint: "sha256:test".to_string(),
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: "2026-08-24T00:00:00Z".to_string(),
updated_at: "2026-08-24T00:00:00Z".to_string(),
})
.unwrap();
let record = WorkdirCreateOperationRecord {
workspace_id: "workspace".to_string(),
operation_id: "call-1".to_string(),
request_fingerprint: request_fingerprint(
"main",
Some("develop"),
None,
"sha256:test",
1,
),
repository_id: "main".to_string(),
selector: Some("develop".to_string()),
requested_runtime_id: None,
resolved_runtime_id: "arcadia".to_string(),
config_revision: 7,
config_projection_digest: "sha256:projection".to_string(),
source_kind: Some("local_path".to_string()),
source_uri: Some("/tmp/repo".to_string()),
source_revision: Some(1),
source_fingerprint: Some("sha256:source".to_string()),
credential_id: None,
credential_revision: None,
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
cache_generation: 0,
working_directory_id: "wd-1".to_string(),
state: "pending".to_string(),
failure: None,
created_at: "2026-08-24T00:00:00Z".to_string(),
updated_at: "2026-08-24T00:00:00Z".to_string(),
};
assert_eq!(
store.reserve_workdir_create_operation(&record).unwrap(),
record
);
let bound = store
.bind_workdir_create_repository_access(
"workspace",
"call-1",
&record.request_fingerprint,
"credential-1",
3,
"trust-1",
5,
"read_only",
2,
"2026-08-24T00:00:01Z",
)
.unwrap();
assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
assert_eq!(bound.credential_revision, Some(3));
assert_eq!(bound.host_trust_revision, Some(5));
assert_eq!(bound.cache_generation, 2);
assert!(
store
.bind_workdir_create_repository_access(
"workspace",
"call-1",
&record.request_fingerprint,
"credential-1",
4,
"trust-1",
5,
"read_only",
2,
"2026-08-24T00:00:02Z",
)
.is_err()
);
let mut changed_resolution = record.clone();
changed_resolution.resolved_runtime_id = "other".to_string();
changed_resolution.config_revision = 8;
changed_resolution.source_uri = Some("ssh://git@other.test/repo.git".to_string());
changed_resolution.source_revision = Some(9);
let replayed = store
.reserve_workdir_create_operation(&changed_resolution)
.unwrap();
assert_eq!(replayed, bound);
assert_eq!(replayed.source_uri.as_deref(), Some("/tmp/repo"));
assert_eq!(
store
.load_workdir_create_operation("workspace", "call-1")
.unwrap(),
Some(bound.clone())
);
let mut changed_input = record.clone();
changed_input.request_fingerprint =
request_fingerprint("main", Some("main"), None, "sha256:test", 1);
assert!(
store
.reserve_workdir_create_operation(&changed_input)
.unwrap_err()
.to_string()
.contains("reused with different input")
);
}
}
+122 -8
View File
@@ -3,14 +3,121 @@ use std::time::{SystemTime, UNIX_EPOCH};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use worker_runtime::auth::{ use worker_runtime::auth::{
WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, RuntimeRequestSourceExpectation, WorkerMutationActorKind, WorkerMutationOperation,
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims, WorkerMutationSourceClaims, WorkerMutationSourceExpectation,
verify_worker_mutation_source_proof, decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
verify_runtime_request_source, verify_worker_mutation_source_proof,
}; };
use worker_runtime::worker_source::InProcessWorkerMutationProof; use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::hosts::RemoteRuntimeConfig; use crate::hosts::RemoteRuntimeConfig;
use crate::server::WorkspaceApi; use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedRuntimeRequestSource {
pub runtime_id: String,
pub worker_id: Option<String>,
}
pub async fn verify_runtime_request_source_proof(
api: &WorkspaceApi,
proof: &str,
workspace_id: &str,
permission: &str,
method: &str,
path: &str,
body_digest: &str,
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
verify_runtime_request_source_proof_with_store(
api.store.as_ref(),
&api.config,
proof,
workspace_id,
permission,
method,
path,
body_digest,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn verify_runtime_request_source_proof_with_store(
store: &dyn ControlPlaneStore,
config: &ServerConfig,
proof: &str,
workspace_id: &str,
permission: &str,
method: &str,
path: &str,
body_digest: &str,
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
let unverified = decode_runtime_request_source_claims(proof)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, workspace_id)?;
let trusted = store
.get_trusted_runtime(&unverified.iss)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
.filter(|record| record.revoked_at.is_none())
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
let trusted_for_workspace = trusted.workspace_id.as_deref() == Some(workspace_id)
|| (unverified.iss == crate::hosts::EMBEDDED_RUNTIME_ID && trusted.workspace_id.is_none());
if !trusted_for_workspace {
return Err(WorkerMutationSourceProofError::WrongWorkspace);
}
let expected = RuntimeRequestSourceExpectation {
identity_id: &unverified.iss,
audience: audience.as_ref(),
workspace_id,
worker_id: unverified.worker_id.as_deref(),
permission,
method,
path,
body_digest,
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
};
let claims = verify_runtime_request_source(proof, &trusted.public_key, &expected)
.map_err(map_auth_error)?;
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
.ok_or(WorkerMutationSourceProofError::Expired)?
.to_rfc3339();
if !store
.consume_worker_mutation_source_jti(
&claims.iss,
&claims.jti,
expires_at,
now_seconds,
&consumed_at,
)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
{
return Err(WorkerMutationSourceProofError::Replay);
}
if let Some(worker_id) = claims.worker_id.as_deref() {
let worker = worker_runtime::identity::RuntimeWorkerRef {
runtime_id: claims.iss.clone(),
worker_id: worker_id.to_owned(),
};
let member = store
.get_worker_registry(workspace_id, &worker)
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
let reserved = store
.has_active_worker_create_reservation(workspace_id, &worker)
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
if member.is_none() && !reserved {
return Err(WorkerMutationSourceProofError::WorkerCatalogMembership);
}
}
Ok(VerifiedRuntimeRequestSource {
runtime_id: claims.iss,
worker_id: claims.worker_id,
})
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum PresentedWorkerMutationSourceProof<'a> { pub enum PresentedWorkerMutationSourceProof<'a> {
@@ -97,16 +204,19 @@ async fn verify_worker_remove_source_with(
PresentedWorkerMutationSourceProof::Remote(token) => { PresentedWorkerMutationSourceProof::Remote(token) => {
let unverified = decode_worker_mutation_source_claims(token) let unverified = decode_worker_mutation_source_claims(token)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?; .map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss)?; let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?;
let trusted = store let trusted = store
.get_trusted_runtime(&unverified.iss) .get_trusted_runtime(&unverified.iss)
.await .await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
.filter(|record| record.revoked_at.is_none()) .filter(|record| record.revoked_at.is_none())
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
if trusted.workspace_id.as_deref() != Some(config.workspace_id.as_str()) {
return Err(WorkerMutationSourceProofError::WrongWorkspace);
}
let expected = WorkerMutationSourceExpectation { let expected = WorkerMutationSourceExpectation {
runtime_id: &unverified.iss, runtime_id: &unverified.iss,
audience, audience: audience.as_ref(),
workspace_id: &config.workspace_id, workspace_id: &config.workspace_id,
worker_id: None, worker_id: None,
actor_kind: WorkerMutationActorKind::Worker, actor_kind: WorkerMutationActorKind::Worker,
@@ -247,13 +357,17 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
fn remote_audience<'a>( fn remote_audience<'a>(
config: &'a crate::server::ServerConfig, config: &'a crate::server::ServerConfig,
runtime_id: &str, runtime_id: &str,
) -> Result<&'a str, WorkerMutationSourceProofError> { workspace_id: &str,
) -> Result<std::borrow::Cow<'a, str>, WorkerMutationSourceProofError> {
if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID {
return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}")));
}
config config
.remote_runtime_sources .remote_runtime_sources
.iter() .iter()
.find(|runtime| runtime.runtime_id == runtime_id) .find(|runtime| runtime.runtime_id == runtime_id)
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref()) .and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref())
.map(|auth| auth.server_id.as_str()) .map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str()))
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust) .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)
} }
+104 -124
View File
@@ -1,4 +1,3 @@
use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
@@ -6,6 +5,9 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
use workspace_api::{RepositoryObservedStatus, RepositorySource};
use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
use crate::store::{ use crate::store::{
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord, ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
}; };
@@ -52,6 +54,10 @@ impl WorkspaceCatalogService {
Self { store } Self { store }
} }
pub fn is_empty(&self) -> Result<bool> {
Ok(self.store.list_workspaces()?.is_empty())
}
pub fn list( pub fn list(
&self, &self,
owner_account_id: Option<&str>, owner_account_id: Option<&str>,
@@ -74,33 +80,16 @@ impl WorkspaceCatalogService {
pub fn create( pub fn create(
&self, &self,
request: WorkspaceCreateRequest, request: WorkspaceCreateRequest,
owner_account_id: Option<String>, owner_account_id: String,
) -> Result<WorkspaceCreateResponse> { ) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, owner_account_id, None, false) self.create_internal(request, owner_account_id, None)
}
pub fn create_first_ownerless(
&self,
request: WorkspaceCreateRequest,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, None, None, true)
}
pub fn create_with_workspace_id(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
requested_workspace_id: Option<String>,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, owner_account_id, requested_workspace_id, false)
} }
fn create_internal( fn create_internal(
&self, &self,
request: WorkspaceCreateRequest, request: WorkspaceCreateRequest,
owner_account_id: Option<String>, owner_account_id: String,
requested_workspace_id: Option<String>, requested_workspace_id: Option<String>,
require_empty_catalog: bool,
) -> Result<WorkspaceCreateResponse> { ) -> Result<WorkspaceCreateResponse> {
let operation_key = normalize_required( let operation_key = normalize_required(
"operation_key", "operation_key",
@@ -109,8 +98,8 @@ impl WorkspaceCatalogService {
)?; )?;
let display_name = let display_name =
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?; normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
let repository_path = validate_repository_uri(&request.repository.uri)?; let repository_source = validate_repository_source(&request.repository.uri)?;
let repository_uri = repository_path.to_string_lossy().into_owned(); let repository_uri = repository_source.uri.clone();
let repository_name = request let repository_name = request
.repository .repository
.display_name .display_name
@@ -140,7 +129,7 @@ impl WorkspaceCatalogService {
let fingerprint = workspace_create_fingerprint( let fingerprint = workspace_create_fingerprint(
requested_workspace_id.as_deref(), requested_workspace_id.as_deref(),
&display_name, &display_name,
owner_account_id.as_deref(), Some(&owner_account_id),
&repository_uri, &repository_uri,
&repository_name, &repository_name,
&default_ref, &default_ref,
@@ -151,10 +140,10 @@ impl WorkspaceCatalogService {
.create_workspace_bootstrap(&WorkspaceBootstrapRecord { .create_workspace_bootstrap(&WorkspaceBootstrapRecord {
operation_key, operation_key,
request_fingerprint: fingerprint.clone(), request_fingerprint: fingerprint.clone(),
require_empty_catalog, require_empty_catalog: false,
workspace: WorkspaceRecord { workspace: WorkspaceRecord {
workspace_id: workspace_id.clone(), workspace_id: workspace_id.clone(),
owner_account_id, owner_account_id: Some(owner_account_id),
display_name, display_name,
state: "active".to_string(), state: "active".to_string(),
created_at: now.clone(), created_at: now.clone(),
@@ -166,10 +155,12 @@ impl WorkspaceCatalogService {
name: repository_name, name: repository_name,
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
uri: repository_uri, source: repository_source.clone(),
default_ref: Some(default_ref), default_ref: Some(default_ref),
auth_ref_kind: None, source_revision: 1,
auth_ref_key: None, source_fingerprint: repository_source_fingerprint(&repository_source),
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}, },
@@ -194,35 +185,8 @@ fn normalize_required(field: &str, value: String, max_bytes: usize) -> Result<St
Ok(value.to_string()) Ok(value.to_string())
} }
fn validate_repository_uri(uri: &str) -> Result<PathBuf> { fn validate_repository_source(uri: &str) -> Result<RepositorySource> {
let uri = uri.trim(); parse_repository_source(uri)
if uri.is_empty() || uri.contains("://") {
return Err(Error::InvalidInput(
"initial repository uri must be an absolute server-local path".to_string(),
));
}
let path = Path::new(uri);
if !path.is_absolute() {
return Err(Error::InvalidInput(
"initial repository uri must be an absolute server-local path".to_string(),
));
}
let path = path.canonicalize().map_err(|error| {
Error::InvalidInput(format!("initial repository path is unavailable: {error}"))
})?;
if !path.is_dir() {
return Err(Error::InvalidInput(
"initial repository path must be a directory".to_string(),
));
}
let normal_git = path.join(".git").exists();
let bare_git = path.join("HEAD").is_file() && path.join("objects").is_dir();
if !normal_git && !bare_git {
return Err(Error::InvalidInput(
"initial repository path is not a Git repository".to_string(),
));
}
Ok(path)
} }
fn workspace_create_fingerprint( fn workspace_create_fingerprint(
@@ -258,7 +222,8 @@ fn workspace_create_fingerprint(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::store::SqliteWorkspaceStore; use crate::store::{AccountRecord, SqliteWorkspaceStore};
use workspace_api::RepositorySourceKind;
fn git_repository() -> tempfile::TempDir { fn git_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -266,6 +231,22 @@ mod tests {
dir dir
} }
fn owner_account(store: &SqliteWorkspaceStore) -> String {
let account_id = Uuid::now_v7().to_string();
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
store
.upsert_account(&AccountRecord {
account_id: account_id.clone(),
kind: "user".to_string(),
handle: format!("owner-{}", &account_id[..8]),
display_name: "Workspace Owner".to_string(),
created_at: now.clone(),
updated_at: now,
})
.unwrap();
account_id
}
#[tokio::test] #[tokio::test]
async fn create_is_atomic_and_exact_retries_converge() { async fn create_is_atomic_and_exact_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
@@ -281,8 +262,11 @@ mod tests {
}, },
}; };
let created = service.create(request.clone(), None).unwrap(); let owner_account_id = owner_account(store.as_ref());
let replayed = service.create(request, None).unwrap(); let created = service
.create(request.clone(), owner_account_id.clone())
.unwrap();
let replayed = service.create(request, owner_account_id).unwrap();
assert!(!created.replayed); assert!(!created.replayed);
assert!(replayed.replayed); assert!(replayed.replayed);
@@ -306,64 +290,10 @@ mod tests {
); );
} }
#[test]
fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store.clone());
let repository_a = git_repository();
let repository_b = git_repository();
let requests = [
WorkspaceCreateRequest {
operation_key: "bootstrap-a".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository_a.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
WorkspaceCreateRequest {
operation_key: "bootstrap-b".to_string(),
display_name: "Workspace B".to_string(),
repository: InitialRepositoryIntent {
uri: repository_b.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
];
let barrier = Arc::new(std::sync::Barrier::new(2));
let results = std::thread::scope(|scope| {
requests
.into_iter()
.map(|request| {
let service = service.clone();
let barrier = barrier.clone();
scope.spawn(move || {
barrier.wait();
service.create_first_ownerless(request)
})
})
.collect::<Vec<_>>()
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
let error = results
.into_iter()
.find_map(Result::err)
.unwrap()
.to_string();
assert!(error.contains("catalog is empty"), "{error}");
}
#[tokio::test] #[tokio::test]
async fn idempotency_key_reuse_with_different_payload_is_rejected() { async fn idempotency_key_reuse_with_different_payload_is_rejected() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(store);
let repository = git_repository(); let repository = git_repository();
let mut request = WorkspaceCreateRequest { let mut request = WorkspaceCreateRequest {
@@ -375,20 +305,70 @@ mod tests {
default_ref: None, default_ref: None,
}, },
}; };
service.create(request.clone(), None).unwrap(); service
.create(request.clone(), owner_account_id.clone())
.unwrap();
request.display_name = "Workspace B".to_string(); request.display_name = "Workspace B".to_string();
let error = service.create(request, None).unwrap_err().to_string(); let error = service
.create(request, owner_account_id)
.unwrap_err()
.to_string();
assert!(error.contains("different input"), "{error}"); assert!(error.contains("different input"), "{error}");
} }
#[test] #[test]
fn repository_intent_rejects_remote_and_non_git_paths() { fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
let remote = validate_repository_uri("https://example.test/repo.git").unwrap_err(); let remote = validate_repository_source("https://example.test/repo.git").unwrap();
assert!(remote.to_string().contains("server-local path")); assert_eq!(remote.kind, RepositorySourceKind::Https);
let dir = tempfile::tempdir().unwrap(); let local = validate_repository_source("/runtime-only/missing/repository").unwrap();
let non_git = validate_repository_uri(&dir.path().display().to_string()).unwrap_err(); assert_eq!(local.kind, RepositorySourceKind::LocalPath);
assert!(non_git.to_string().contains("not a Git repository")); assert_eq!(local.uri, "/runtime-only/missing/repository");
let file = validate_repository_source("file:///runtime-only/missing/repository").unwrap();
assert_eq!(file.kind, RepositorySourceKind::File);
assert!(validate_repository_source("relative/repository").is_err());
}
#[test]
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store.clone());
let result = service
.create(
WorkspaceCreateRequest {
operation_key: "remote-create".to_string(),
display_name: "Remote Workspace".to_string(),
repository: InitialRepositoryIntent {
uri: "ssh://git@example.test/org/repository.git".to_string(),
display_name: Some("Remote Repository".to_string()),
default_ref: Some("main".to_string()),
},
},
owner_account_id,
)
.unwrap();
let persisted = store
.get_repository(
&result.workspace.workspace_id,
&result.repository.repository_id,
)
.unwrap()
.unwrap();
assert_eq!(persisted.source.kind, RepositorySourceKind::Ssh);
assert_eq!(persisted.source_revision, 1);
assert!(persisted.source_fingerprint.starts_with("sha256:"));
assert_eq!(
persisted.observed_status,
RepositoryObservedStatus::Unverified
);
let json = serde_json::to_value(&persisted).unwrap();
assert!(json.get("source").is_some());
assert!(json.get("auth_ref_kind").is_none());
assert!(json.get("auth_ref_key").is_none());
} }
} }
+2 -2
View File
@@ -671,7 +671,7 @@ fn state(
StateTarget::InProgress => TicketWorkflowState::InProgress, StateTarget::InProgress => TicketWorkflowState::InProgress,
StateTarget::Done => { StateTarget::Done => {
return Err(TicketCliError::new( return Err(TicketCliError::new(
"done is guarded by MergeRequestComplete with an approved immutable revision and operation_id", "done is guarded by CompleteMergeRequest with an approved exact source ref and operation_id",
)); ));
} }
StateTarget::Closed => { StateTarget::Closed => {
@@ -1366,7 +1366,7 @@ mod tests {
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"])) let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
.and_then(|cli| run_in_workspace(cli, temp.path())) .and_then(|cli| run_in_workspace(cli, temp.path()))
.unwrap_err(); .unwrap_err();
assert!(done_error.to_string().contains("MergeRequestComplete")); assert!(done_error.to_string().contains("CompleteMergeRequest"));
let closed = run( let closed = run(
&temp, &temp,
+3 -1
View File
@@ -264,6 +264,8 @@ in
"serve" "serve"
"--listen" "--listen"
"0.0.0.0:8787" "0.0.0.0:8787"
"--config"
"/server-config/server.toml"
]; ];
Env = [ Env = [
"PATH=/bin" "PATH=/bin"
@@ -274,7 +276,7 @@ in
}; };
Volumes = { Volumes = {
"/server-data" = { }; "/server-data" = { };
"/workspace" = { }; "/server-config" = { };
}; };
WorkingDir = "/server-data"; WorkingDir = "/server-data";
}; };
+9 -2
View File
@@ -58,12 +58,19 @@ The Compose files live at:
```text ```text
compose.yaml compose.yaml
docker/workspace/.yoi/workspace.toml
docker/workspace/.yoi/workspace-backend.local.toml
``` ```
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime. The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime.
The operator-owned `/etc/yoi/server.toml` is mounted read-only at `/server-config/server.toml`. Its `browser.public_url` is the single browser-facing setting used for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks:
```toml
[browser]
public_url = "https://yoi.example.com"
```
Create this host file before starting Compose and set it to the exact Nginx-facing origin. It is not an API/backend URL and must not include a path, query, or fragment. It is deployment topology outside the source and Workspace repositories, not Workspace DB state or repository-local configuration.
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly. Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.
## Worker launch path ## Worker launch path
+1 -5
View File
@@ -153,11 +153,7 @@ For repository builds:
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787 cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
``` ```
If the Server DB has no workspace record yet, initialize it first: An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration.
```bash
yoi-server init --workspace <WORKSPACE_ROOT>
```
## Smoke checks ## Smoke checks
+5 -5
View File
@@ -37,9 +37,9 @@ Workers with the Ticket and operation-specific Merge Request built-in features c
- `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata. - `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata.
- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence. - `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence.
- `TicketComment` - `TicketComment`
- Coder: `MergeRequestShow`, `MergeRequestOpen` - Coder: `ShowMergeRequest`, `OpenMergeRequest`
- Reviewer: `MergeRequestShow`, `MergeRequestReview` — available only inside the attested direct-child Reviewer request; grant and subject-ref capability material are not model input. - Reviewer: `ShowMergeRequest`, `ReviewMergeRequest` — available only inside the attested direct-child Reviewer request; grant and subject-ref capability material are not model input.
- Orchestrator: `MergeRequestShow`, `MergeRequestReadinessCheck`, `MergeRequestComplete` - Orchestrator: `ShowMergeRequest`, `CheckMergeRequestReadiness`, `CompleteMergeRequest`
- `TicketClose` - `TicketClose`
- `TicketRelationRecord` - `TicketRelationRecord`
@@ -242,9 +242,9 @@ Implementation normally happens in a child git worktree created by the Orchestra
### 5. Review ### 5. Review
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with read-only scope and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval. The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with write scope, so it can use the Workdir command tools required for inspection and validation, and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval.
The Reviewer records the structured result with `MergeRequestReview`. Request changes requires a new immutable revision and a fresh child attempt. The Orchestrator uses `MergeRequestReadinessCheck` and then `MergeRequestComplete` for guarded integration with operation-id dedupe/CAS semantics; Flow transitions are not completion authority. The Reviewer records the structured result with `ReviewMergeRequest`. Request changes advances the existing Merge Request source selector with a normal non-force push and requires a fresh child attempt for that exact new source ref; do not create a replacement Merge Request, add-revision operation, or fresh integration branch. Target-only movement preserves approval for an unchanged source and requires refreshed integration evidence. The Orchestrator uses `CheckMergeRequestReadiness` and then `CompleteMergeRequest` for guarded integration with operation-id dedupe/CAS semantics; Flow transitions are not completion authority.
Blockers must be fixed or explicitly escalated before merge-ready submission. Blockers must be fixed or explicitly escalated before merge-ready submission.
@@ -2,9 +2,9 @@
## Observed ## Observed
While implementing Ticket `00001KZRNHB35`, the assigned Coder had a clean committed Workdir and all immutable base/head/changed-path evidence needed by `MergeRequestOpen`. While implementing Ticket `00001KZRNHB35`, the assigned Coder had a clean committed Workdir and all immutable base/head/changed-path evidence needed by `OpenMergeRequest`.
`MergeRequestOpen` rejected candidate repository ids with: `OpenMergeRequest` rejected candidate repository ids with:
```text ```text
invalid input: Merge Request repository must match the authoritative Ticket target invalid input: Merge Request repository must match the authoritative Ticket target
@@ -14,14 +14,14 @@ The typed `TicketShow` result available to the Coder rendered only the Ticket id
## Impact ## Impact
A Coder can finish and validate implementation but cannot open the required immutable MR revision or start independent review. The failure is safe, but it strands otherwise review-ready work and provides no actionable expected target. A Coder can finish and validate implementation but cannot open the required selector-based Merge Request or start independent review. The failure is safe, but it strands otherwise review-ready work and provides no actionable expected target.
## Suggested improvement ## Suggested improvement
At least one trusted read surface in the assigned-Coder flow should return the immutable Ticket target needed by `MergeRequestOpen`: At least one trusted read surface in the assigned-Coder flow should return the immutable Ticket target needed by `OpenMergeRequest`:
- include `repository_id` and `ref_selector` in `TicketShow`'s bounded authoritative projection; or - include `repository_id` and `ref_selector` in `TicketShow`'s bounded authoritative projection; or
- have `MergeRequestOpen` derive repository identity from the authoritative Ticket target and remove it from model input; or - have `OpenMergeRequest` derive repository identity from the authoritative Ticket target and remove it from model input; or
- return a bounded structured mismatch diagnostic containing the authoritative repository id when the caller is already authorized to read that Ticket. - return a bounded structured mismatch diagnostic containing the authoritative repository id when the caller is already authorized to read that Ticket.
Deriving the repository in `MergeRequestOpen` is preferable because it removes duplicated model-controlled identity and avoids target drift between Ticket read and MR creation. Deriving the repository in `OpenMergeRequest` is preferable because it removes duplicated model-controlled identity and avoids target drift between Ticket read and MR creation.
+10 -10
View File
@@ -5,51 +5,51 @@
states = { states = {
implement = { implement = {
instructions = "Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the published source selector resolves to the exact local HEAD. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open or update the Ticket Merge Request with immutable `selector_from` / `selector_to` revision evidence. Do not request review from a dirty Workdir or an unpublished source ref. A Flow transition is never Ticket completion authority."; instructions = "Inspect the assigned Workdir Git state before editing. Reuse a suitable restored `work/<ticket-id>-<slug>` branch, or create a collision-safe work branch from detached HEAD; never overwrite an existing branch. For this assigned Ticket Workdir, you are explicitly authorized to create or switch the local work branch and to use `git add` and `git commit`. Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record concrete evidence in coherent commits. After the implementation is committed, validated, and clean, publish only the current Ticket work branch to the Ticket repository remote with a normal non-force push, then verify that the provider resolves the existing Merge Request `selector_from` to exact local `HEAD`. Do not push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Open one Merge Request with `OpenMergeRequest`, or use `ShowMergeRequest` when one is already open; keep its original selectors and advance the same source ref. Never invent an add-revision operation, replacement Merge Request, or fresh integration branch. Source movement requires fresh review; target-only movement does not invalidate source approval and remains Orchestrator integration authority. Do not request review from a dirty Workdir or unpublished source ref. A Flow transition is never Ticket completion authority.";
transitions = { transitions = {
review = { review = {
target = "review"; target = "review";
condition = "The requested implementation is present on the Ticket work branch, all intended changes are committed, the Workdir is clean, the relevant validation has completed, the current Ticket work branch has been published with a normal non-force push, the configured repository provider resolves that published source ref to the exact current HEAD, and the linked Merge Request current revision records that same subject for independent review."; condition = "The requested implementation is present on the Ticket work branch, all intended changes are committed, the Workdir is clean, the relevant validation has completed, the existing Merge Request source ref has been published with a normal non-force push, and the configured repository provider resolves that source ref to exact current HEAD for independent review.";
}; };
}; };
}; };
review = { review = {
instructions = "Use the current Ticket Merge Request as review authority. Confirm its immutable source selector resolves to the exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, read-only scope, and a structured review handoff bound to the current immutable Merge Request revision. The trusted spawn layer records `ReviewRequested`; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit MergeRequestReview; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition."; instructions = "Use the current Ticket Merge Request as review authority. Call `ShowMergeRequest` and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope for Workdir inspection and command validation, and only the Ticket id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition.";
transitions = { transitions = {
approved = { approved = {
target = "complete"; target = "complete";
condition = "The authoritative Merge Request current revision has a structured approve result from its registered direct-child builtin:reviewer attempt, with no later unresolved request_changes finding. The Flow transition itself does not complete the Ticket."; condition = "The authoritative Merge Request has a structured approve result for its exact current source ref from its registered direct-child builtin:reviewer attempt, with no later unresolved request_changes finding. The Flow transition itself does not complete the Ticket.";
}; };
changes_requested = { changes_requested = {
target = "fix"; target = "fix";
condition = "The latest independent Reviewer attempt for the current implementation requested one or more concrete changes that remain unresolved."; condition = "The latest independent Reviewer attempt for the exact current source ref requested one or more concrete changes that remain unresolved.";
}; };
}; };
}; };
fix = { fix = {
instructions = "Resolve every open Reviewer finding on the same Ticket work branch, rerun the validation affected by the fixes, commit the corrected implementation as a new revision, and preserve concrete evidence. Publish only the updated Ticket work branch with a normal non-force push, verify that the configured repository provider resolves the published source ref to the exact new HEAD, and update the linked Merge Request so its current revision records that same subject. Request review from a fresh read-only Reviewer child so the trusted spawn layer captures the new immutable subject. Do not rewrite the previously reviewed commit, claim approval from the prior request_changes review, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected committed revision is published and ready for a new independent review."; instructions = "Resolve every open Reviewer finding on the same Ticket work branch and existing Merge Request source selector, rerun validation affected by the fixes, and commit the corrected implementation. Publish only that updated source ref with a normal non-force push and verify that the provider resolves it to exact new HEAD. Source movement invalidates the prior verdict, so request a fresh Reviewer child after publication. Do not open a replacement Merge Request, invent an add-revision operation, create a fresh integration branch, rewrite previously reviewed commits, reuse the prior request_changes result as approval, push the target branch, push tags or unrelated refs, force-push, merge, delete branches, or discard pre-existing changes. Request a Flow transition only after the corrected source ref is published and ready for fresh independent review.";
transitions = { transitions = {
review = { review = {
target = "review"; target = "review";
condition = "Every finding from the latest request_changes review has been addressed with relevant validation evidence, the corrected implementation is committed and clean, the updated Ticket work branch has been published with a normal non-force push, the configured repository provider resolves that published source ref to the exact new HEAD, and the linked Merge Request current revision records that same subject for a fresh independent Reviewer attempt."; condition = "Every finding from the latest request_changes review has been addressed with relevant validation evidence, the corrected implementation is committed and clean, the existing Merge Request source ref has been updated with a normal non-force push, and the configured repository provider resolves that source ref to exact new HEAD for a fresh independent Reviewer attempt.";
}; };
}; };
}; };
complete = { complete = {
instructions = "Verify the authoritative approval still matches the exact current Merge Request subject, keep the reviewed source ref immutable, leave concise implementation and validation evidence on the Ticket when useful, then hand off to the Orchestrator. Do not call MergeRequestComplete, update the target selector, or treat the Flow terminal state as Ticket completion authority. After durable handoff evidence exists, request a Flow transition."; instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, keep that reviewed source ref immutable, leave concise implementation and validation evidence on the Ticket when useful, then hand off to the Orchestrator. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After durable handoff evidence exists, request a Flow transition.";
transitions = { transitions = {
completed = { completed = {
target = "done"; target = "done";
condition = "The exact approved Merge Request revision and implementation evidence have been durably handed off to the Orchestrator. A Flow state or prose report alone is never Ticket completion authority."; condition = "The exact approved Merge Request source ref and implementation evidence have been durably handed off to the Orchestrator. A Flow state or prose report alone is never Ticket completion authority.";
}; };
}; };
}; };
done = { done = {
instructions = "The approved implementation has been handed off for Orchestrator-owned readiness and integration. Flow terminal state only reflects that handoff."; instructions = "The approved implementation has been handed off for Orchestrator-owned readiness, target integration, and `CompleteMergeRequest`. Flow terminal state only reflects that handoff.";
terminal = true; terminal = true;
}; };
}; };

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