61 Commits
Author SHA1 Message Date
Hare 96592b1a24 fix: project Worker delete persistence diagnostics 2026-09-17 04:16:33 +09:00
Hare d70dee77a9 fix: retain Worker identity after delete persistence failure 2026-09-17 04:13:45 +09:00
Hare 1402240ef0 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-612-persisted-worker-identity 2026-09-17 03:54:59 +09:00
Hare decd1311cb fix: recover interrupted Runtime store migrations 2026-09-17 03:47:26 +09:00
Hare c4622e9e3a refactor: separate Worker identity from execution state 2026-09-17 03:25:40 +09:00
Hare 360387c986 fix: classify file content conflicts as retryable 2026-09-17 02:54:37 +09:00
Hare bc0342b03e fix: discard legacy top-level run generation 2026-09-17 02:07:55 +09:00
Hare 10ebac142e refactor: make worker state snapshots authoritative 2026-09-17 02:02:51 +09:00
Hare d6bef5d1c7 fix: address lifecycle review findings 2026-09-17 00:36:08 +09:00
Hare 9d8e0114f5 fix: serialize runtime worker lifecycle 2026-09-17 00:14:33 +09:00
Hare 93070a3a93 fix(runtime): reject duplicate removal revisions 2026-09-16 21:04:30 +09:00
Hare d1b94625b1 fix(runtime): fence removed internal worker activity 2026-09-16 20:57:17 +09:00
Hare fbbea1bf91 fix: scope compaction suppression to one run 2026-09-16 07:27:21 +09:00
Hare a0f8ee51a4 fix: guard automatic compaction retries 2026-09-16 07:16:31 +09:00
Hare ffc1933f06 fix: separate compaction metric correlation 2026-09-16 06:34:15 +09:00
Hare b6960878a6 fix: align compaction metric schema 2026-09-16 06:24:45 +09:00
Hare fef3b6f4a0 fix: preserve compaction metric precision and CAS category 2026-09-16 06:13:32 +09:00
Hare c0a73c12ec feat: record compaction lifecycle metrics 2026-09-16 06:01:04 +09:00
Hare f40714efc2 feat: add explicit session metrics export 2026-09-16 06:00:57 +09:00
Hare 07ed908ae6 test: cover compaction restart boundaries 2026-09-16 05:14:16 +09:00
Hare cfe772ea7d fix: fence compaction commit and status 2026-09-16 04:58:38 +09:00
Hare c0fe20e8a2 fix: serialize compaction pointer commits 2026-09-16 04:41:27 +09:00
Hare df22526a0d fix: render compaction on worker status 2026-09-16 04:25:37 +09:00
Hare 1d06d4b08a fix: keep compaction lifecycle runtime-only 2026-09-16 04:19:02 +09:00
Hare 4e7a314a00 fix: make compaction activation atomic 2026-09-16 03:44:48 +09:00
Hare 016dbd7cb1 fix: preserve worker run generations across restore 2026-09-15 01:17:30 +09:00
Hare 7210d3c202 fix: reject failed worker restores before attach 2026-09-15 00:47:12 +09:00
Hare 572204b49a fix: resolve CLI connections before async runtime 2026-09-15 00:28:25 +09:00
Hare 86dd67a24c fix: skip stopped worker protocol subscriptions 2026-09-14 23:31:14 +09:00
Hare beeba1fdfc fix: migrate legacy worker snapshots 2026-09-14 22:28:01 +09:00
Hare 41b7b289d0 fix: serialize Workdir lease admission with writes 2026-09-14 20:32:21 +09:00
Hare 24237249d1 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 20:05:34 +09:00
Hare e448073b98 fix: fence resolved Workdir lease aliases 2026-09-14 20:05:27 +09:00
Hare c08152d069 fix: share workspace creation wire contract 2026-09-14 19:45:18 +09:00
Hare 3995561220 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-468-remove-local-workspace-authority 2026-09-14 19:26:44 +09:00
Hare aea51caeb4 fix: resolve workspaces through backend repositories 2026-09-14 19:24:44 +09:00
Hare 3eca380bd8 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 19:12:17 +09:00
Hare 8a3e06bc81 feat: add selective Workdir symlink policies 2026-09-14 19:09:12 +09:00
Hare c4274c42cb fix: align plugin help with offline commands 2026-09-14 18:52:00 +09:00
Hare a61ad15767 refactor: remove ambient plugin authority 2026-09-14 18:40:42 +09:00
Hare 7f1e374fd7 fix: remove repository-local workspace selection 2026-09-14 18:31:04 +09:00
Hare e3f5445a02 fix: move workspace metadata authority to server database 2026-09-14 18:31:04 +09:00
Hare d2cb50d081 fix: fence runtime removal races and retries 2026-09-13 02:54:01 +09:00
Hare 6c609808c9 fix: use authoritative runtime removal result 2026-09-13 02:30:24 +09:00
Hare 2d4c7b383a feat: add guarded runtime removal operation 2026-09-13 02:30:15 +09:00
Hare c21ed7dff2 fix: preserve credential candidates across Workdir retries 2026-09-13 01:24:26 +09:00
Hare 448e392a0e fix: persist Workdir credential candidate snapshots 2026-09-13 01:14:39 +09:00
Hare d97c40d6af fix: reject plain HTTP repository sources 2026-09-13 01:12:01 +09:00
Hare 2d512b6be6 fix: remove stale HTTP repository access branch 2026-09-13 01:04:46 +09:00
Hare f061a95b48 fix: validate repository URI before source kind 2026-09-13 00:45:43 +09:00
Hare eefdef1bef fix: lock verified runtime public keys 2026-09-13 00:32:44 +09:00
Hare 7675f81999 fix: reject plain HTTP repository sources 2026-09-13 00:29:16 +09:00
Hare 8fb592071f fix: fence stale repository SSH probes 2026-09-13 00:27:32 +09:00
Hare 2528312142 fix: lock verified Runtime public keys in settings 2026-09-13 00:23:25 +09:00
Hare 08d7965ea8 fix: centralize repository access expiry 2026-09-13 00:20:25 +09:00
Hare e0badad91f fix: invalidate stale repository SSH probes 2026-09-13 00:17:12 +09:00
Hare f0a91ce2d8 fix: centralize repository access expiry 2026-09-13 00:11:03 +09:00
Hare 24cab83f48 test: cover repository credential rotate visibility 2026-09-13 00:05:14 +09:00
Hare 33a2b5d702 fix: unify workspace request routing 2026-09-12 23:49:13 +09:00
Hare 7f807004ad Update AGENTS.md 2026-09-12 22:47:35 +09:00
Hare 5564425488 fix: retry retained workdir removal 2026-09-12 13:30:25 +09:00
136 changed files with 15209 additions and 18762 deletions
+7 -9
View File
@@ -1,21 +1,19 @@
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。 すでにシステムのドッグフーディングに成功しており、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceの実装を進めている。
## このシステムに置ける設計要旨 ## このシステムに置ける設計要旨
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。 - プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。 - 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
### LLM コンテキストの加工原則 ### LLM コンテキストの加工原則
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止** LLM に投げる context はappend-onlyが基本であり、またその永続化形式からAPIコールの形式を純粋に再現可能である必要が有る
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等) 一時的にメッセージを差し込む等の、揮発性の有るコンテキストの改変や、過去のメッセージを改ざんすることは基本的に禁止されている
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。 これを行うと、 LLM はそのコンテキストに基づいて生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。 過去のコンテキストの圧縮は、キャッシュ破壊とトークン消費のトレードオフであり、必要であれば行っている。
しかし、キャッシュを破壊するタイミングと頻度は正確にコントロールされる必要があり、実際のセッションデータの解析に基づいて慎重に設計されるべきである。
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
--- ---
Generated
+11 -654
View File
@@ -2,15 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "addr2line"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -177,12 +168,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.1" version = "1.9.1"
@@ -476,9 +461,6 @@ name = "bumpalo"
version = "3.20.2" version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
dependencies = [
"allocator-api2",
]
[[package]] [[package]]
name = "bytecount" name = "bytecount"
@@ -662,15 +644,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -765,15 +738,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpp_demangle"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -792,148 +756,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cranelift-assembler-x64"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bc293b86236abcc45f2f72e2d18e2bd636f2a08b75eb286bae31e71e1430c91"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b954c826eddaf1b001402cb8aecf1764c6f6d637ba69fb9e3311f1ebac965be6"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4053fa2575ef4a5c35d2708533df2200400ae979226cea9cc92a578b811bd4e7"
dependencies = [
"cranelift-entity",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-bitset"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d216663191014aa63e1d2cffd058e609eaf207646d40b739d88250f65b2c4f69"
dependencies = [
"serde",
"serde_derive",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a5e7e7aad6a425a51da1ad7ab9e5d280ea97eb7c7c4545fafb567915a75aadb"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-codegen-meta",
"cranelift-codegen-shared",
"cranelift-control",
"cranelift-entity",
"cranelift-isle",
"gimli",
"hashbrown 0.17.1",
"libm",
"log",
"pulley-interpreter",
"regalloc2",
"rustc-hash",
"serde",
"smallvec",
"target-lexicon",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen-meta"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c421d80a9a85f806cb02a2983b5b5368a335c319795b1f1b4b771a24479af5b0"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
"cranelift-srcgen",
"heck",
"pulley-interpreter",
]
[[package]]
name = "cranelift-codegen-shared"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78fdb83ab012d0ee6a44ced7ca8788a444f17cf821c62f95d6ef87c9f0262518"
[[package]]
name = "cranelift-control"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b75adc6eb7bb4ac6365106afb6cac4f12fe1ddfa02ddc9fd7015ca1469b471b"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668e56db75a54816cbdd7c7b7bfc558b08bf7b2cda9d0846491517e92f3b393b"
dependencies = [
"cranelift-bitset",
"serde",
"serde_derive",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-frontend"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c63892dc1cc3ae48680183fa66997f60ffe7f1e200c8d390f8ee66edff4aef5a"
dependencies = [
"cranelift-codegen",
"log",
"smallvec",
"target-lexicon",
]
[[package]]
name = "cranelift-isle"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94eaf429c32a12715429c7c6ddfdd43c170f4cdd7e97bfa507bd68a652091087"
[[package]]
name = "cranelift-native"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd77674904ae9be11c1e1efdba54788b59f3d6658d747b97534bfbba2909aacc"
dependencies = [
"cranelift-codegen",
"libc",
"target-lexicon",
]
[[package]]
name = "cranelift-srcgen"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba7c0ff5941842c36653da155580ce41e675c204a67ac1b4e1c478a9347bbb7"
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -1374,18 +1196,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -1788,18 +1598,6 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "gimli"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
"fnv",
"hashbrown 0.16.1",
"indexmap",
"stable_deref_trait",
]
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.3" version = "0.3.3"
@@ -1922,11 +1720,6 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "hashlink" name = "hashlink"
@@ -2614,15 +2407,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "manifest" name = "manifest"
version = "0.1.0" version = "0.1.0"
@@ -2713,15 +2497,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memfd"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227"
dependencies = [
"rustix 1.1.4",
]
[[package]] [[package]]
name = "memmap2" name = "memmap2"
version = "0.9.10" version = "0.9.10"
@@ -3003,18 +2778,6 @@ 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 = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"crc32fast",
"hashbrown 0.17.1",
"indexmap",
"memchr",
]
[[package]] [[package]]
name = "oid-registry" name = "oid-registry"
version = "0.7.1" version = "0.7.1"
@@ -3410,18 +3173,6 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"serde",
]
[[package]] [[package]]
name = "postscript" name = "postscript"
version = "0.14.1" version = "0.14.1"
@@ -3524,29 +3275,6 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "pulley-interpreter"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d9880c1985ccccaed3646b0ef793dc39a4b117403ed4afc6fa3ef6027c5200f"
dependencies = [
"cranelift-bitset",
"log",
"pulley-macros",
"wasmtime-internal-core",
]
[[package]]
name = "pulley-macros"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee249346855ad102580e474da5463f86f8a7d449e6d49e00fefb304e448e2983"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -3824,20 +3552,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "regalloc2"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186"
dependencies = [
"allocator-api2",
"bumpalo",
"hashbrown 0.17.1",
"log",
"rustc-hash",
"smallvec",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.3" version = "1.12.3"
@@ -3971,12 +3685,6 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -4216,10 +3924,6 @@ name = "semver"
version = "1.0.27" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde" name = "serde"
@@ -4393,6 +4097,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"session-store", "session-store",
"tempfile",
] ]
[[package]] [[package]]
@@ -4536,9 +4241,6 @@ name = "smallvec"
version = "1.15.1" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -4771,12 +4473,6 @@ dependencies = [
"xattr", "xattr",
] ]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]] [[package]]
name = "target-triple" name = "target-triple"
version = "1.0.0" version = "1.0.0"
@@ -5702,27 +5398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [ dependencies = [
"leb128fmt", "leb128fmt",
"wasmparser 0.244.0", "wasmparser",
]
[[package]]
name = "wasm-encoder"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6"
dependencies = [
"leb128fmt",
"wasmparser 0.248.0",
]
[[package]]
name = "wasm-encoder"
version = "0.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f"
dependencies = [
"leb128fmt",
"wasmparser 0.252.0",
] ]
[[package]] [[package]]
@@ -5733,8 +5409,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap", "indexmap",
"wasm-encoder 0.244.0", "wasm-encoder",
"wasmparser 0.244.0", "wasmparser",
] ]
[[package]] [[package]]
@@ -5762,286 +5438,6 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "wasmparser"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a"
dependencies = [
"bitflags 2.11.0",
"hashbrown 0.17.1",
"indexmap",
"semver",
"serde",
]
[[package]]
name = "wasmparser"
version = "0.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c"
dependencies = [
"bitflags 2.11.0",
"indexmap",
"semver",
]
[[package]]
name = "wasmprinter"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183"
dependencies = [
"anyhow",
"termcolor",
"wasmparser 0.248.0",
]
[[package]]
name = "wasmtime"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7ce9aa2c67f75fadcfdc6aa9097d03e7c39485dfe316f2ed6a7c0fd186c527"
dependencies = [
"addr2line",
"async-trait",
"bitflags 2.11.0",
"bumpalo",
"cc",
"cfg-if",
"encoding_rs",
"libc",
"log",
"mach2",
"memfd",
"object",
"once_cell",
"postcard",
"pulley-interpreter",
"rustix 1.1.4",
"semver",
"serde",
"serde_derive",
"smallvec",
"target-lexicon",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-component-macro",
"wasmtime-internal-component-util",
"wasmtime-internal-core",
"wasmtime-internal-cranelift",
"wasmtime-internal-fiber",
"wasmtime-internal-jit-debug",
"wasmtime-internal-jit-icache-coherence",
"wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
"wasmtime-internal-winch",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-environ"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fb157bd1fbf689ac89d570433a700db6f33bdfcb5ffc30e3f1c49e4c70de71"
dependencies = [
"anyhow",
"cpp_demangle",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-entity",
"gimli",
"hashbrown 0.17.1",
"indexmap",
"log",
"object",
"postcard",
"rustc-demangle",
"semver",
"serde",
"serde_derive",
"sha2 0.10.9",
"smallvec",
"target-lexicon",
"wasm-encoder 0.248.0",
"wasmparser 0.248.0",
"wasmprinter",
"wasmtime-internal-component-util",
"wasmtime-internal-core",
]
[[package]]
name = "wasmtime-internal-component-macro"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96c17f35fae2ab574667aba0c58fd56349a6f788ac42541a2e543116d5cfb91"
dependencies = [
"anyhow",
"proc-macro2",
"quote",
"syn 2.0.117",
"wasmtime-internal-component-util",
"wasmtime-internal-wit-bindgen",
"wit-parser 0.248.0",
]
[[package]]
name = "wasmtime-internal-component-util"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2eeb9b53222859e6f5dc73d2ccfb33254d672469cac11b693a71912e2f3817"
[[package]]
name = "wasmtime-internal-core"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1deaf6bc3430abd7497b00c64f06ca2b97ca0fe41af87836446ca30949965c"
dependencies = [
"hashbrown 0.17.1",
"libm",
"serde",
]
[[package]]
name = "wasmtime-internal-cranelift"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b845f83b5b04b11bc48329b53eb4fa8cf9f28a43c71ed8e1203f68ffa9806d1b"
dependencies = [
"cfg-if",
"cranelift-codegen",
"cranelift-control",
"cranelift-entity",
"cranelift-frontend",
"cranelift-native",
"gimli",
"itertools",
"log",
"object",
"pulley-interpreter",
"smallvec",
"target-lexicon",
"thiserror 2.0.18",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-core",
"wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
]
[[package]]
name = "wasmtime-internal-fiber"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10c8466f72965ae85c250f90aaa7992c089a2f8502009bd0d2c9e7d6409174a"
dependencies = [
"cc",
"cfg-if",
"libc",
"rustix 1.1.4",
"wasmtime-environ",
"wasmtime-internal-versioned-export-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-internal-jit-debug"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3adfecf5621b14d8f8871f4cb4ed9f844197b1ddefc702ef4c859552cd9551"
dependencies = [
"cc",
"wasmtime-internal-versioned-export-macros",
]
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d3c1e9fb618ec45c9b3477ea683cd37bee427273d7b13bba5c66a1caaf1dd6"
dependencies = [
"cfg-if",
"libc",
"wasmtime-internal-core",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-internal-unwinder"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa91132b81f1e172ec7e7c3c114ac34209ee6b3524b3a8d6943af99803f66c5"
dependencies = [
"cfg-if",
"cranelift-codegen",
"log",
"object",
"wasmtime-environ",
]
[[package]]
name = "wasmtime-internal-versioned-export-macros"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea811ffe23f597cc7708327ea25d9eb018dcf760ffe15ccb7d0b27ad635de61"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "wasmtime-internal-winch"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "828b66175c54a0d00b4c1c1c76658d8aa73aeb9fa3553575c5eee56d40f2eb18"
dependencies = [
"cranelift-codegen",
"gimli",
"log",
"object",
"target-lexicon",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-cranelift",
"winch-codegen",
]
[[package]]
name = "wasmtime-internal-wit-bindgen"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae00896ad9bef1b3ca6401ae9a841daa6f357dd91541b6baf87082946d1bde1"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"heck",
"indexmap",
"wit-parser 0.248.0",
]
[[package]]
name = "wast"
version = "252.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder 0.252.0",
]
[[package]]
name = "wat"
version = "1.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb"
dependencies = [
"wast",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.94" version = "0.3.94"
@@ -6266,25 +5662,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89c09acfdfa281b3340e1e94ef3cf6618d69eab975280f881e154c29f49419c1"
dependencies = [
"cranelift-assembler-x64",
"cranelift-codegen",
"gimli",
"regalloc2",
"smallvec",
"target-lexicon",
"thiserror 2.0.18",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-core",
"wasmtime-internal-cranelift",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -6548,7 +5925,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck", "heck",
"wit-parser 0.244.0", "wit-parser",
] ]
[[package]] [[package]]
@@ -6595,10 +5972,10 @@ dependencies = [
"serde", "serde",
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"wasm-encoder 0.244.0", "wasm-encoder",
"wasm-metadata", "wasm-metadata",
"wasmparser 0.244.0", "wasmparser",
"wit-parser 0.244.0", "wit-parser",
] ]
[[package]] [[package]]
@@ -6616,26 +5993,7 @@ dependencies = [
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"unicode-xid", "unicode-xid",
"wasmparser 0.244.0", "wasmparser",
]
[[package]]
name = "wit-parser"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856"
dependencies = [
"anyhow",
"hashbrown 0.17.1",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser 0.248.0",
] ]
[[package]] [[package]]
@@ -6698,11 +6056,8 @@ dependencies = [
"tracing", "tracing",
"tungstenite 0.28.0", "tungstenite 0.28.0",
"uuid", "uuid",
"wasmtime",
"wat",
"workdir", "workdir",
"workspace-api", "workspace-api",
"yoi-plugin-pdk",
] ]
[[package]] [[package]]
@@ -6715,6 +6070,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"decodal", "decodal",
"flow", "flow",
"fs4",
"futures", "futures",
"manifest", "manifest",
"protocol", "protocol",
@@ -6822,6 +6178,7 @@ dependencies = [
"tokio", "tokio",
"toml", "toml",
"tui", "tui",
"uuid",
"worker", "worker",
"workspace-api", "workspace-api",
] ]
+1
View File
@@ -16,6 +16,7 @@ pub use workspace_api::{
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions, WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate, WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption, WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
WorkerOperationState as BackendWorkerOperationState,
WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
+47 -20
View File
@@ -1,9 +1,9 @@
use crate::{BackendApiClient, BackendApiClientError}; use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method; use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use workspace_api::{ use workspace_api::{
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord, InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
WorkspaceSummary, WorkspaceSummary,
}; };
@@ -11,23 +11,8 @@ const DEFAULT_WORKSPACE_LIMIT: usize = 200;
pub type BackendWorkspace = WorkspaceSummary; pub type BackendWorkspace = WorkspaceSummary;
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse; pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord; pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRequest {
pub operation_key: String,
pub display_name: String,
pub repository: CreateBackendWorkspaceRepository,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRepository {
pub uri: String,
pub display_name: Option<String>,
pub default_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkspaceCatalogTarget { pub struct BackendWorkspaceCatalogTarget {
@@ -73,6 +58,48 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
} }
} }
pub fn list_backend_workspaces_blocking(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
}
pub fn list_backend_workspace_repositories_blocking(
target: &BackendWorkspaceCatalogTarget,
workspace_id: &str,
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
if workspace_id.is_empty()
|| workspace_id.len() > 200
|| !workspace_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(BackendWorkspaceClientError::InvalidTarget(
"Workspace id returned by Backend is invalid".to_string(),
));
}
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
.send()?;
client.check_status(response.status())?;
let response = response.json::<RepositoryListResponse>()?;
if response.workspace_id != workspace_id {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Repository catalog response does not match the requested Workspace".to_string(),
));
}
Ok(response.items)
}
pub async fn list_backend_workspaces( pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> { ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
@@ -149,8 +176,8 @@ mod tests {
operation_key: "workspace-create-1".to_string(), operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(), display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository { repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(), uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()), default_ref: Some("develop".to_string()),
}, },
}; };
+8 -5
View File
@@ -26,15 +26,18 @@ pub use backend_runtime::{
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption, BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
BackendWorkerLaunchTarget, BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
BackendWorkerSummary, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary,
connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options, BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
list_backend_stopped_workers, list_backend_workers, restore_backend_worker, get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
restore_backend_worker,
}; };
pub use backend_workspace::{ pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces, CreateBackendWorkspaceResponse, create_backend_workspace,
list_backend_workspace_repositories_blocking, list_backend_workspaces,
list_backend_workspaces_blocking,
}; };
pub use client::{Client, ClientError}; pub use client::{Client, ClientError};
pub use target::{ pub use target::{
+8 -1
View File
@@ -9,14 +9,21 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
operation_key: "workspace-create-1".to_string(), operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(), display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository { repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(), uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()), default_ref: Some("develop".to_string()),
}, },
}; };
assert_eq!(request.clone(), request); assert_eq!(request.clone(), request);
assert_eq!(request.operation_key, "workspace-create-1"); assert_eq!(request.operation_key, "workspace-create-1");
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["operation_key"], "workspace-create-1");
assert_eq!(json["repository"]["repository_key"], "main");
assert_eq!(json["repository"]["uri"], "/srv/repos/alpha");
assert!(json.get("operation_id").is_none());
assert!(json["repository"].get("display_name").is_none());
assert!(json["repository"].get("source").is_none());
} }
#[test] #[test]
+9 -3
View File
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use globset::Glob; use globset::Glob;
use ignore::WalkBuilder; use ignore::WalkBuilder;
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult}; use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, resolve_access_path};
/// Execute a bounded glob entirely inside the provider process. /// Execute a bounded glob entirely inside the provider process.
pub fn run_glob( pub fn run_glob(
@@ -15,7 +15,11 @@ pub fn run_glob(
if !root.is_absolute() { if !root.is_absolute() {
return Err(FsError::RelativePath(root.to_path_buf())); return Err(FsError::RelativePath(root.to_path_buf()));
} }
if !access.is_readable(base) { let base_resolved = resolve_access_path(base).map_err(|error| FsError::Io {
path: PathBuf::from(request.path.as_str()),
source: error,
})?;
if !access.is_readable_paths(base, &base_resolved) {
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str()))); return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
} }
let matcher = Glob::new(&request.pattern) let matcher = Glob::new(&request.pattern)
@@ -26,7 +30,9 @@ pub fn run_glob(
walker.hidden(false).follow_links(false); walker.hidden(false).follow_links(false);
for entry in walker.build().flatten() { for entry in walker.build().flatten() {
let path = entry.path(); let path = entry.path();
if !path.is_file() || !access.is_readable(path) { let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !path.is_file() || !readable {
continue; continue;
} }
let relative = path.strip_prefix(base).unwrap_or(path); let relative = path.strip_prefix(base).unwrap_or(path);
+14 -1
View File
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
use thiserror::Error; use thiserror::Error;
pub use glob::run_glob; pub use glob::run_glob;
pub use local::{run_edit, run_list, run_read, run_stat, run_write}; pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write};
pub use operation::*; pub use operation::*;
pub use search::run_grep; pub use search::run_grep;
@@ -22,6 +22,19 @@ pub use search::run_grep;
pub trait FsAccessPolicy: Send + Sync { pub trait FsAccessPolicy: Send + Sync {
fn is_readable(&self, path: &Path) -> bool; fn is_readable(&self, path: &Path) -> bool;
fn is_writable(&self, path: &Path) -> bool; fn is_writable(&self, path: &Path) -> bool;
/// Authorize both the Workdir-visible path and its provider-resolved
/// target. Implementations that do not distinguish symbolic-link identity
/// retain resolved-target semantics through the defaults.
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
let _ = logical;
self.is_readable(resolved)
}
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
let _ = logical;
self.is_writable(resolved)
}
} }
/// First symlink encountered while resolving a provider path. /// First symlink encountered while resolving a provider path.
+66 -29
View File
@@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -18,7 +19,8 @@ pub fn run_stat(
) -> Result<StatResult, FsError> { ) -> Result<StatResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
if !access.is_readable(&path) { let resolved = resolve_access_path(&path).map_err(|error| map_io(&logical, error))?;
if !access.is_readable_paths(&path, &resolved) {
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str()))); return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
} }
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?; let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
@@ -113,12 +115,8 @@ pub fn run_write(
if request.expected_hash.is_some() { if request.expected_hash.is_some() {
return Err(FsError::Conflict(logical.as_str().to_string())); return Err(FsError::Conflict(logical.as_str().to_string()));
} }
let parent = path.parent().ok_or_else(|| { let target = require_access(&path, &logical, access, true, true)?;
FsError::InvalidArgument(format!("{} has no parent", logical.as_str())) atomic_write(&target, &request.content, &logical)?;
})?;
let parent_logical = logical_parent(&logical);
require_access(parent, &parent_logical, access, true, true)?;
atomic_write(&path, &request.content, &logical)?;
} }
Ok(WriteResult { Ok(WriteResult {
bytes_written: request.content.len(), bytes_written: request.content.len(),
@@ -173,6 +171,7 @@ pub fn run_list(
) -> Result<ListResult, FsError> { ) -> Result<ListResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let logical_base = path.clone();
let path = require_access(&path, &logical, access, false, true)?; let path = require_access(&path, &logical, access, false, true)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?; let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if !metadata.is_dir() { if !metadata.is_dir() {
@@ -183,7 +182,15 @@ pub fn run_list(
for entry in read_dir { for entry in read_dir {
let entry = entry.map_err(|error| map_io(&logical, error))?; let entry = entry.map_err(|error| map_io(&logical, error))?;
let absolute = entry.path(); let absolute = entry.path();
if !access.is_readable(&absolute) { let relative_to_base = absolute.strip_prefix(&path).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its list base".to_string())
})?;
let logical_absolute = logical_base.join(relative_to_base);
let resolved = match resolve_access_path(&absolute) {
Ok(resolved) => resolved,
Err(_) => continue,
};
if !access.is_readable_paths(&logical_absolute, &resolved) {
continue; continue;
} }
let link_metadata = let link_metadata =
@@ -203,7 +210,7 @@ pub fn run_list(
} else { } else {
EntryKind::Other EntryKind::Other
}; };
let relative = absolute.strip_prefix(root).map_err(|_| { let relative = logical_absolute.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its root".to_string()) FsError::InvalidArgument("provider returned a path outside its root".to_string())
})?; })?;
entries.push(ListEntry { entries.push(ListEntry {
@@ -249,18 +256,22 @@ fn require_access(
write: bool, write: bool,
allow_symlink_directory: bool, allow_symlink_directory: bool,
) -> Result<PathBuf, FsError> { ) -> Result<PathBuf, FsError> {
if let Some(info) = direct_symlink(path) { let symlink = direct_symlink(path);
if !info.target_exists { if let Some(info) = symlink.as_ref()
return Err(FsError::BrokenSymlink { && !info.target_exists
path: PathBuf::from(logical.as_str()), {
link: PathBuf::from(logical.as_str()), return Err(FsError::BrokenSymlink {
target: PathBuf::from("<provider-internal target>"), path: PathBuf::from(logical.as_str()),
}); link: PathBuf::from(logical.as_str()),
} target: PathBuf::from("<provider-internal target>"),
});
}
let resolved = resolve_access_path(path).map_err(|error| map_io(logical, error))?;
if let Some(info) = symlink {
let allowed = if write { let allowed = if write {
access.is_writable(path) access.is_writable_paths(path, &resolved)
} else { } else {
access.is_readable(path) access.is_readable_paths(path, &resolved)
}; };
if !allowed { if !allowed {
return Err(FsError::SymlinkOutOfScope { return Err(FsError::SymlinkOutOfScope {
@@ -275,15 +286,15 @@ fn require_access(
target: PathBuf::from("<provider-internal target>"), target: PathBuf::from("<provider-internal target>"),
}); });
} }
return Ok(info.resolved_path); return Ok(resolved);
} }
let allowed = if write { let allowed = if write {
access.is_writable(path) access.is_writable_paths(path, &resolved)
} else { } else {
access.is_readable(path) access.is_readable_paths(path, &resolved)
}; };
if allowed { if allowed {
Ok(path.to_path_buf()) Ok(resolved)
} else if write { } else if write {
Err(FsError::ReadOnly(PathBuf::from(logical.as_str()))) Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
} else { } else {
@@ -291,12 +302,38 @@ fn require_access(
} }
} }
fn logical_parent(path: &FsPath) -> FsPath { /// Resolve every existing component of an absolute provider path while
let parent = Path::new(path.as_str()) /// retaining a missing final tail for create operations. Dangling symlinks are
.parent() /// rejected because no resolved authority identity can be established.
.unwrap_or_else(|| Path::new("")) pub fn resolve_access_path(path: &Path) -> std::io::Result<PathBuf> {
.to_string_lossy(); let mut cursor = path;
FsPath::new(parent).unwrap_or_else(|_| FsPath::root()) let mut missing = Vec::<OsString>::new();
loop {
match fs::canonicalize(cursor) {
Ok(mut resolved) => {
for component in missing.iter().rev() {
resolved.push(component);
}
return Ok(resolved);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if fs::symlink_metadata(cursor)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(error);
}
let name = cursor.file_name().ok_or(error)?;
missing.push(name.to_os_string());
cursor = cursor.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"path has no existing ancestor",
)
})?;
}
Err(error) => return Err(error),
}
}
} }
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> { fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
+23 -14
View File
@@ -10,7 +10,9 @@ use ignore::WalkBuilder;
use ignore::overrides::{Override, OverrideBuilder}; use ignore::overrides::{Override, OverrideBuilder};
use ignore::types::{Types, TypesBuilder}; use ignore::types::{Types, TypesBuilder};
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink}; use crate::{
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
};
struct ContentLine { struct ContentLine {
path: PathBuf, path: PathBuf,
@@ -220,14 +222,28 @@ pub fn run_grep(
return Err(FsError::RelativePath(base)); return Err(FsError::RelativePath(base));
} }
let symlink = direct_symlink(&base); let symlink = direct_symlink(&base);
if !access.is_readable(&base) { if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.resolved_path.clone(),
});
}
let resolved_base = resolve_access_path(&base).map_err(|error| FsError::io(&base, error))?;
if !access.is_readable_paths(&base, &resolved_base) {
return Err(if let Some(info) = symlink.as_ref() { return Err(if let Some(info) = symlink.as_ref() {
let link_parent_readable = info let link_parent_readable = info
.link_path .link_path
.parent() .parent()
.map(|parent| access.is_readable(parent)) .and_then(|parent| {
resolve_access_path(parent)
.ok()
.map(|resolved| access.is_readable_paths(parent, &resolved))
})
.unwrap_or(false); .unwrap_or(false);
if info.target_exists && link_parent_readable { if link_parent_readable {
FsError::SymlinkOutOfScope { FsError::SymlinkOutOfScope {
path: base.clone(), path: base.clone(),
target: info.resolved_path.clone(), target: info.resolved_path.clone(),
@@ -240,15 +256,6 @@ pub fn run_grep(
FsError::OutOfScope(base.clone()) FsError::OutOfScope(base.clone())
}); });
} }
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.target_path.clone(),
});
}
}
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() { let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()), std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
_ => FsError::io(&base, e), _ => FsError::io(&base, e),
@@ -321,7 +328,9 @@ pub fn run_grep(
continue; continue;
} }
let path = entry.path(); let path = entry.path();
if !access.is_readable(path) { let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !readable {
continue; continue;
} }
if scan_path( if scan_path(
+23 -26
View File
@@ -15,7 +15,6 @@ use serde::{Deserialize, Serialize};
use crate::defaults; use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{ use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
@@ -55,10 +54,6 @@ pub struct WorkerManifestConfig {
/// disabled after cascade merge. /// disabled after cascade merge.
#[serde(default)] #[serde(default)]
pub feature: FeatureConfigPartial, pub feature: FeatureConfigPartial,
/// Explicit plugin package enablement entries. Discovery/resolution is a
/// separate step and does not run during config merge.
#[serde(default)]
pub plugins: PluginConfig,
/// Explicit Model Context Protocol provider declarations. Config parsing /// Explicit Model Context Protocol provider declarations. Config parsing
/// never starts a local MCP subprocess. /// never starts a local MCP subprocess.
#[serde(default)] #[serde(default)]
@@ -74,6 +69,7 @@ pub struct WorkerManifestConfig {
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureConfigPartial { pub struct FeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub task: Option<FeatureFlagConfigPartial>, pub task: Option<FeatureFlagConfigPartial>,
@@ -101,8 +97,6 @@ pub struct FeatureConfigPartial {
pub merge_request: Option<MergeRequestFeatureConfigPartial>, pub merge_request: Option<MergeRequestFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
pub orchestration: Option<FeatureFlagConfigPartial>, pub orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
} }
impl FeatureConfigPartial { impl FeatureConfigPartial {
@@ -145,7 +139,6 @@ impl FeatureConfigPartial {
other.orchestration, other.orchestration,
FeatureFlagConfigPartial::merge, FeatureFlagConfigPartial::merge,
), ),
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
} }
} }
} }
@@ -370,10 +363,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.orchestration .orchestration
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
plugins: value
.plugins
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
} }
} }
} }
@@ -517,7 +506,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
ticket: Some(value.ticket.into()), ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()), merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()), orchestration: Some(value.orchestration.into()),
plugins: Some(value.plugins.into()),
} }
} }
} }
@@ -654,6 +642,20 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
"unknown field in manifest: memory (removed; configure feature.memory)", "unknown field in manifest: memory (removed; configure feature.memory)",
)); ));
} }
if value.get("plugins").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
));
}
if value
.get("feature")
.and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("plugins"))
{
return Err(toml::de::Error::custom(
"unknown field in manifest: feature.plugins (dynamic Plugins are not supported)",
));
}
if value if value
.get("feature") .get("feature")
.and_then(toml::Value::as_table) .and_then(toml::Value::as_table)
@@ -771,7 +773,6 @@ impl WorkerManifestConfig {
PermissionConfigPartial::merge, PermissionConfigPartial::merge,
), ),
feature: self.feature.merge(upper.feature), feature: self.feature.merge(upper.feature),
plugins: merge_plugin_config(self.plugins, upper.plugins),
mcp: merge_mcp_config(self.mcp, upper.mcp), mcp: merge_mcp_config(self.mcp, upper.mcp),
compaction: merge_option( compaction: merge_option(
self.compaction, self.compaction,
@@ -791,16 +792,6 @@ impl SkillsConfig {
} }
} }
fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
let upper_has_resolved_plan = upper.has_resolved_plan();
base.enabled.extend(upper.enabled);
if upper_has_resolved_plan {
base.resolved = upper.resolved;
base.diagnostics = upper.diagnostics;
}
base
}
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig { fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
base.stdio_servers.extend(upper.stdio_servers); base.stdio_servers.extend(upper.stdio_servers);
base base
@@ -1289,7 +1280,6 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
session, session,
permissions, permissions,
feature: FeatureConfig::from(cfg.feature), feature: FeatureConfig::from(cfg.feature),
plugins: cfg.plugins,
mcp: cfg.mcp, mcp: cfg.mcp,
compaction, compaction,
web: cfg.web, web: cfg.web,
@@ -1329,13 +1319,13 @@ mod tests {
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
delegation_scope: ScopeConfig::default(), delegation_scope: ScopeConfig::default(),
permissions: None, permissions: None,
feature: FeatureConfigPartial::default(), feature: FeatureConfigPartial::default(),
plugins: PluginConfig::default(),
mcp: McpConfig::default(), mcp: McpConfig::default(),
session: None, session: None,
compaction: None, compaction: None,
@@ -1575,6 +1565,7 @@ mod tests {
target: PathBuf::from("secrets"), target: PathBuf::from("secrets"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
let resolved = cfg.resolve_paths(Path::new("/workspace/proj")); let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj")); assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
@@ -1712,6 +1703,7 @@ mod tests {
target: abs("/a"), target: abs("/a"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -1723,11 +1715,13 @@ mod tests {
target: abs("/b"), target: abs("/b"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: vec![ScopeRule { deny: vec![ScopeRule {
target: abs("/a/secret"), target: abs("/a/secret"),
permission: Permission::Read, permission: Permission::Read,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}], }],
}, },
..Default::default() ..Default::default()
@@ -2091,6 +2085,7 @@ enabled = false
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -2193,6 +2188,7 @@ enabled = true
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -2269,6 +2265,7 @@ permission = "write"
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
+170 -40
View File
@@ -29,7 +29,7 @@ pub use profile::{
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value, WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
validate_profile_execution_target, validate_profile_execution_target,
}; };
pub use protocol::{Permission, ScopeRule}; pub use protocol::{Permission, ScopeRule, SymlinkPolicy};
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
@@ -69,10 +69,6 @@ pub struct WorkerManifest {
/// resolve disabled so Profile authors choose the exposed built-in surfaces. /// resolve disabled so Profile authors choose the exposed built-in surfaces.
#[serde(default)] #[serde(default)]
pub feature: FeatureConfig, pub feature: FeatureConfig,
/// Explicit plugin package enablement. Discovery remains read-only; only
/// source-qualified entries listed here may resolve to active plugin metadata.
#[serde(default)]
pub plugins: plugin::PluginConfig,
/// Explicit external Model Context Protocol provider configuration. This /// Explicit external Model Context Protocol provider configuration. This
/// is config data only: declaring a server never starts a subprocess or /// is config data only: declaring a server never starts a subprocess or
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate /// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
@@ -106,6 +102,7 @@ pub struct WorkerManifest {
/// sessions, secrets, or resolved host state. Tool registration still applies /// sessions, secrets, or resolved host state. Tool registration still applies
/// the normal scope, host-authority, backend, memory, and network checks. /// the normal scope, host-authority, backend, memory, and network checks.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FeatureConfig { pub struct FeatureConfig {
#[serde(default)] #[serde(default)]
pub task: FeatureFlagConfig, pub task: FeatureFlagConfig,
@@ -135,8 +132,6 @@ pub struct FeatureConfig {
pub merge_request: MergeRequestFeatureConfig, pub merge_request: MergeRequestFeatureConfig,
#[serde(default)] #[serde(default)]
pub orchestration: FeatureFlagConfig, pub orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
} }
impl Default for FeatureConfig { impl Default for FeatureConfig {
@@ -155,7 +150,6 @@ impl Default for FeatureConfig {
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(), merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(), orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
} }
} }
} }
@@ -941,9 +935,7 @@ impl Default for CompactionConfig {
impl WorkerManifest { impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool { pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some() self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
|| self.plugins.has_resolved_plan()
|| self.feature.memory.workspace_settings.is_some()
} }
/// Parse a manifest from a TOML string. /// Parse a manifest from a TOML string.
@@ -984,7 +976,8 @@ struct LegacyMemoryConfig {
consolidation_threshold_bytes: Option<u64>, consolidation_threshold_bytes: Option<u64>,
} }
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2; const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3;
const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
/// Serialize a resolved Worker Manifest for durable Worker-specific storage. /// Serialize a resolved Worker Manifest for durable Worker-specific storage.
pub fn write_persisted_worker_manifest_snapshot( pub fn write_persisted_worker_manifest_snapshot(
@@ -1014,7 +1007,9 @@ pub fn read_persisted_worker_manifest_snapshot(
"resolved Worker manifest snapshot schema_version must be an integer", "resolved Worker manifest snapshot schema_version must be an integer",
)) ))
})?; })?;
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION { if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
{
return Err(serde_json::Error::io(std::io::Error::new( return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData, std::io::ErrorKind::InvalidData,
format!("unsupported resolved Worker manifest snapshot schema version {version}"), format!("unsupported resolved Worker manifest snapshot schema version {version}"),
@@ -1026,7 +1021,7 @@ pub fn read_persisted_worker_manifest_snapshot(
"resolved Worker manifest snapshot contains unknown fields", "resolved Worker manifest snapshot contains unknown fields",
))); )));
} }
let manifest = object.get("manifest").cloned().ok_or_else(|| { let mut manifest = object.get("manifest").cloned().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new( serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData, std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot is missing manifest", "resolved Worker manifest snapshot is missing manifest",
@@ -1041,6 +1036,9 @@ pub fn read_persisted_worker_manifest_snapshot(
"current resolved Worker manifest contains removed top-level memory authority", "current resolved Worker manifest contains removed top-level memory authority",
))); )));
} }
if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
migrate_legacy_manifest_authority(&mut manifest)?;
}
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?); return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
} }
@@ -1063,6 +1061,49 @@ fn validate_persisted_worker_manifest(
Ok(manifest) Ok(manifest)
} }
fn migrate_legacy_manifest_authority(
manifest: &mut serde_json::Value,
) -> Result<(), serde_json::Error> {
let root = manifest.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest must be an object",
))
})?;
root.remove("plugins");
if let Some(feature) = root.get_mut("feature") {
let feature = feature.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest feature must be an object",
))
})?;
feature.remove("plugins");
feature.remove("ticket_orchestration");
if let Some(workers) = feature.remove("workers") {
feature
.entry("sub_worker".to_string())
.or_insert_with(|| workers.clone());
feature.entry("worker".to_string()).or_insert(workers);
}
if let Some(ticket) = feature
.get_mut("ticket")
.and_then(serde_json::Value::as_object_mut)
&& let Some(access) = ticket.remove("access")
&& ticket
.get("enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
&& access.as_str() == Some("lifecycle")
{
ticket.insert("authoring".to_string(), serde_json::Value::Bool(true));
ticket.insert("thread".to_string(), serde_json::Value::Bool(true));
ticket.insert("workflow".to_string(), serde_json::Value::Bool(true));
}
}
Ok(())
}
fn migrate_legacy_resolved_manifest_snapshot( fn migrate_legacy_resolved_manifest_snapshot(
mut snapshot: serde_json::Value, mut snapshot: serde_json::Value,
) -> Result<WorkerManifest, serde_json::Error> { ) -> Result<WorkerManifest, serde_json::Error> {
@@ -1088,7 +1129,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
.remove("memory") .remove("memory")
.unwrap_or_else(|| serde_json::json!({})), .unwrap_or_else(|| serde_json::json!({})),
)?; )?;
let enabled = legacy_feature_memory.enabled; let requested_enabled = legacy_feature_memory.enabled;
let staging_tools = legacy_feature_memory.staging; let staging_tools = legacy_feature_memory.staging;
let legacy_memory: LegacyMemoryConfig = let legacy_memory: LegacyMemoryConfig =
@@ -1111,9 +1152,14 @@ fn migrate_legacy_resolved_manifest_snapshot(
))); )));
} }
}; };
if !enabled { if !requested_enabled {
workspace_settings = None; workspace_settings = None;
} }
// Legacy standalone manifests could enable process-local Memory without a
// Workspace-owned settings snapshot. That authority no longer exists, so
// migration safely disables Memory instead of treating the whole Worker
// snapshot as corrupt.
let enabled = requested_enabled && workspace_settings.is_some();
let extraction_enabled = legacy_memory.extract_threshold.is_some(); let extraction_enabled = legacy_memory.extract_threshold.is_some();
if legacy_memory.consolidation_model.is_some() { if legacy_memory.consolidation_model.is_some() {
return Err(serde_json::Error::io(std::io::Error::new( return Err(serde_json::Error::io(std::io::Error::new(
@@ -1159,6 +1205,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
.insert("workspace_settings".to_string(), workspace_settings); .insert("workspace_settings".to_string(), workspace_settings);
} }
feature.insert("memory".to_string(), resolved); feature.insert("memory".to_string(), resolved);
migrate_legacy_manifest_authority(&mut snapshot)?;
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?) validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
} }
@@ -1322,33 +1369,61 @@ model_id = "claude-sonnet-4-20250514"
} }
#[test] #[test]
fn parse_plugin_enablement_config() { fn dynamic_plugin_manifest_config_is_rejected() {
let toml = format!( let toml = format!(
"{MINIMAL_REQUIRED}\n\ "{MINIMAL_REQUIRED}\n\
[[plugins.enabled]]\n\ [[plugins.enabled]]\n\
id = \"project:example\"\n\ id = \"project:example\"\n"
version = \"0.1.0\"\n\
digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\
surfaces = [\"hook\"]\n\n\
[plugins.enabled.config]\n\
greeting = \"hello\"\n"
); );
let manifest = WorkerManifest::from_toml(&toml).unwrap(); let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert_eq!(manifest.plugins.enabled.len(), 1); assert!(
let enabled = &manifest.plugins.enabled[0]; error
assert_eq!(enabled.id, "project:example"); .to_string()
assert_eq!( .contains("dynamic Plugins are not supported"),
enabled.version.as_ref().map(|version| version.0.as_str()), "unexpected error: {error}"
Some("0.1.0")
); );
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]); }
assert_eq!(
enabled #[test]
.config fn persisted_manifest_with_dynamic_plugin_plan_is_rejected() {
.as_ref() let base =
.and_then(|value| value.get("greeting")) serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
.and_then(|value| value.as_str()),
Some("hello") let mut top_level = base.clone();
top_level.as_object_mut().unwrap().insert(
"plugins".to_string(),
serde_json::json!({
"resolved": [{
"package_path": "/tmp/ambient.yoi-plugin"
}]
}),
);
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
let mut nested = base;
nested
.get_mut("feature")
.unwrap()
.as_object_mut()
.unwrap()
.insert(
"plugins".to_string(),
serde_json::json!({ "enabled": true }),
);
let error = serde_json::from_value::<WorkerManifest>(nested).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
}
#[test]
fn dynamic_plugin_feature_flag_is_rejected() {
let toml = format!("{MINIMAL_REQUIRED}\n[feature.plugins]\nenabled = true\n");
let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!(
error
.to_string()
.contains("dynamic Plugins are not supported"),
"unexpected error: {error}"
); );
} }
@@ -1581,7 +1656,7 @@ model_id = "claude-sonnet-4-20250514"
"Français" "Français"
); );
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap(); let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
assert_eq!(current["schema_version"], 2); assert_eq!(current["schema_version"], 3);
assert!(current["manifest"].get("memory").is_none()); assert!(current["manifest"].get("memory").is_none());
let mut disabled = let mut disabled =
@@ -1597,6 +1672,61 @@ model_id = "claude-sonnet-4-20250514"
assert!(disabled.feature.memory.workspace_settings.is_none()); assert!(disabled.feature.memory.workspace_settings.is_none());
} }
#[test]
fn persisted_manifest_adapter_drops_removed_plugin_authority() {
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let mut versioned = write_persisted_worker_manifest_snapshot(&manifest).unwrap();
versioned["schema_version"] = serde_json::json!(2);
versioned["manifest"]["feature"]["plugins"] = serde_json::json!({ "enabled": true });
versioned["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("sub_worker");
versioned["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("worker");
versioned["manifest"]["feature"]["workers"] = serde_json::json!({ "enabled": true });
versioned["manifest"]["feature"]["ticket"] =
serde_json::json!({ "enabled": true, "access": "lifecycle" });
versioned["manifest"]["feature"]["ticket_orchestration"] =
serde_json::json!({ "enabled": false });
versioned["manifest"]["plugins"] = serde_json::json!({
"enabled": ["legacy-plugin"],
"config": { "legacy-plugin": { "legacy": true } }
});
let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap();
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
assert_eq!(current["schema_version"], 3);
assert!(current["manifest"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("workers").is_none());
assert_eq!(
current["manifest"]["feature"]["sub_worker"]["enabled"],
true
);
assert_eq!(current["manifest"]["feature"]["worker"]["enabled"], true);
assert_eq!(current["manifest"]["feature"]["ticket"]["authoring"], true);
assert_eq!(current["manifest"]["feature"]["ticket"]["thread"], true);
assert_eq!(current["manifest"]["feature"]["ticket"]["workflow"], true);
let mut legacy = serde_json::to_value(manifest).unwrap();
legacy.as_object_mut().unwrap().remove("memory");
legacy["feature"]["memory"] = serde_json::json!({
"enabled": true,
"staging": false
});
legacy["feature"]["plugins"] = serde_json::json!({ "enabled": false });
legacy["plugins"] = serde_json::json!({ "enabled": [] });
let legacy = read_persisted_worker_manifest_snapshot(legacy).unwrap();
let current = write_persisted_worker_manifest_snapshot(&legacy).unwrap();
assert_eq!(
current["manifest"]["feature"]["memory"]["profile"]["enabled"],
false
);
}
#[test] #[test]
fn persisted_manifest_adapter_rejects_mixed_or_future_authority() { fn persisted_manifest_adapter_rejects_mixed_or_future_authority() {
let manifest = let manifest =
@@ -1639,7 +1769,7 @@ model_id = "claude-sonnet-4-20250514"
assert!( assert!(
read_persisted_worker_manifest_snapshot(serde_json::json!({ read_persisted_worker_manifest_snapshot(serde_json::json!({
"schema_version": 3, "schema_version": 4,
"manifest": manifest, "manifest": manifest,
})) }))
.is_err() .is_err()
File diff suppressed because it is too large Load Diff
+47 -11
View File
@@ -18,7 +18,6 @@ use crate::config::{
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial, CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
}; };
use crate::model::{AuthRef, ModelManifest}; use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{ use crate::{
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig, EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
@@ -148,7 +147,6 @@ pub enum WorkspaceAuthorityRequirement {
MergeRequest, MergeRequest,
Objective, Objective,
Orchestration, Orchestration,
Plugins,
Ticket, Ticket,
Worker, Worker,
} }
@@ -162,7 +160,6 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
Self::MergeRequest => formatter.write_str("feature.merge_request"), Self::MergeRequest => formatter.write_str("feature.merge_request"),
Self::Objective => formatter.write_str("feature.objective"), Self::Objective => formatter.write_str("feature.objective"),
Self::Orchestration => formatter.write_str("feature.orchestration"), Self::Orchestration => formatter.write_str("feature.orchestration"),
Self::Plugins => formatter.write_str("feature.plugins or plugin packages"),
Self::Ticket => formatter.write_str("feature.ticket"), Self::Ticket => formatter.write_str("feature.ticket"),
Self::Worker => formatter.write_str("feature.worker"), Self::Worker => formatter.write_str("feature.worker"),
} }
@@ -202,9 +199,6 @@ pub fn validate_profile_execution_target(
if feature.orchestration.enabled { if feature.orchestration.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Orchestration); requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
} }
if feature.plugins.enabled || !manifest.plugins.is_empty() {
requirements.insert(WorkspaceAuthorityRequirement::Plugins);
}
if feature.ticket.enabled if feature.ticket.enabled
|| feature.ticket.authoring || feature.ticket.authoring
|| feature.ticket.thread || feature.ticket.thread
@@ -638,7 +632,6 @@ fn resolve_profile_value(
session: profile.session, session: profile.session,
permissions: profile.permissions, permissions: profile.permissions,
feature: profile.feature, feature: profile.feature,
plugins: profile.plugins,
mcp: profile.mcp, mcp: profile.mcp,
compaction, compaction,
web: profile.web, web: profile.web,
@@ -684,8 +677,6 @@ struct ProfileConfig {
#[serde(default)] #[serde(default)]
feature: FeatureConfigPartial, feature: FeatureConfigPartial,
#[serde(default)] #[serde(default)]
plugins: PluginConfig,
#[serde(default)]
mcp: McpConfig, mcp: McpConfig,
#[serde(default)] #[serde(default)]
compaction: Option<serde_json::Value>, compaction: Option<serde_json::Value>,
@@ -970,6 +961,7 @@ fn profile_scope_intent_to_config(
target: workspace_base.join(path), target: workspace_base.join(path),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
} }
Ok(ScopeConfig { Ok(ScopeConfig {
@@ -977,6 +969,7 @@ fn profile_scope_intent_to_config(
target: workspace_base.to_path_buf(), target: workspace_base.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny, deny,
}) })
@@ -1267,6 +1260,51 @@ mod tests {
} }
} }
#[test]
fn ambient_plugin_directories_do_not_affect_builtin_profile_resolution() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace/nested");
std::fs::create_dir_all(&workspace).unwrap();
for root in [tmp.path(), tmp.path().join("workspace").as_path()] {
let package = root.join(".yoi/plugins/broken.yoi-plugin");
std::fs::create_dir_all(package.parent().unwrap()).unwrap();
std::fs::write(package, b"malformed ambient package").unwrap();
}
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve_for_target(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
ProfileResolveOptions::with_worker_name("standalone-worker"),
ProfileExecutionTarget::Standalone,
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "standalone-worker");
}
#[test]
fn profile_rejects_dynamic_plugin_configuration() {
let tmp = TempDir::new().unwrap();
for body in [
"[feature.plugins]\nenabled = true\n",
"[[plugins.enabled]]\nid = \"explicit:example\"\n",
] {
let profile = write_profile(tmp.path(), "plugin.toml", body);
let error = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(profile),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
}
#[test] #[test]
fn builtin_default_resolves_as_a_standalone_local_capability_profile() { fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -1305,8 +1343,6 @@ mod tests {
assert!(!resolved.manifest.feature.flow.enabled); assert!(!resolved.manifest.feature.flow.enabled);
assert!(!resolved.manifest.feature.worker.enabled); assert!(!resolved.manifest.feature.worker.enabled);
assert!(!resolved.manifest.feature.manage_workdir.enabled); assert!(!resolved.manifest.feature.manage_workdir.enabled);
assert!(!resolved.manifest.feature.plugins.enabled);
assert!(resolved.manifest.plugins.is_empty());
} }
#[test] #[test]
+239 -48
View File
@@ -3,16 +3,17 @@
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every //! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
//! rule `target` must already be an absolute path — per-layer path //! rule `target` must already be an absolute path — per-layer path
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`]. //! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
//! All rule `target` paths inside the [`Scope`] are normalized lexically so //! All rule targets retain both their lexically normalized logical identity and
//! access authority follows the path presented through the Workdir, not a //! their provider-resolved identity. Allow rules select one identity explicitly;
//! symbolic-link target outside that logical tree. //! deny rules always inspect both so aliases cannot bypass a restriction.
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use arc_swap::{ArcSwap, Guard}; use arc_swap::{ArcSwap, Guard};
use crate::{Permission, ScopeConfig, ScopeRule}; use crate::{Permission, ScopeConfig, ScopeRule, SymlinkPolicy};
/// Parsed, pwd-resolved set of allow/deny rules for a Worker. /// Parsed, pwd-resolved set of allow/deny rules for a Worker.
/// ///
@@ -26,10 +27,13 @@ pub struct Scope {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedRule { struct ResolvedRule {
/// Absolute, lexically normalized target directory/file. /// Absolute, lexically normalized target as presented through the Workdir.
target: PathBuf, logical_target: PathBuf,
/// Absolute target after provider-side symbolic-link resolution.
resolved_target: PathBuf,
permission: Permission, permission: Permission,
recursive: bool, recursive: bool,
symlink_policy: SymlinkPolicy,
} }
/// Parsed filesystem authority this Worker may pass to spawned children. /// Parsed filesystem authority this Worker may pass to spawned children.
@@ -98,18 +102,46 @@ fn permission_denies_requested(denied: Permission, requested: Permission) -> boo
fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool { fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
permission_covers(available.permission, requested.permission) permission_covers(available.permission, requested.permission)
&& rule_path_set_contains(available, requested) && available.symlink_policy >= requested.symlink_policy
&& rule_path_set_contains(
available,
requested,
match available.symlink_policy {
SymlinkPolicy::Resolved => RuleIdentity::Resolved,
SymlinkPolicy::Logical => RuleIdentity::Logical,
},
)
} }
fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool { fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool {
permission_denies_requested(deny.permission, requested.permission) permission_denies_requested(deny.permission, requested.permission)
&& rule_path_sets_overlap(deny, requested) && (rule_path_sets_overlap(deny, requested, RuleIdentity::Logical)
|| rule_path_sets_overlap(deny, requested, RuleIdentity::Resolved))
} }
fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) -> bool { #[derive(Clone, Copy)]
enum RuleIdentity {
Logical,
Resolved,
}
fn rule_target(rule: &ResolvedRule, identity: RuleIdentity) -> &Path {
match identity {
RuleIdentity::Logical => &rule.logical_target,
RuleIdentity::Resolved => &rule.resolved_target,
}
}
fn rule_path_set_contains(
available: &ResolvedRule,
requested: &ResolvedRule,
identity: RuleIdentity,
) -> bool {
let available_target = rule_target(available, identity);
let requested_target = rule_target(requested, identity);
match (available.recursive, requested.recursive) { match (available.recursive, requested.recursive) {
// A recursive grant contains every possible requested path below its target. // A recursive grant contains every possible requested path below its target.
(true, _) => requested.target.starts_with(&available.target), (true, _) => requested_target.starts_with(available_target),
// A non-recursive grant contains only the target and its direct children; // A non-recursive grant contains only the target and its direct children;
// a recursive request always includes descendants beyond that finite-depth // a recursive request always includes descendants beyond that finite-depth
// set. // set.
@@ -117,36 +149,42 @@ fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) ->
// Two non-recursive rules have the same finite-depth set only when their // Two non-recursive rules have the same finite-depth set only when their
// target is identical. A request rooted at a direct child would also grant // target is identical. A request rooted at a direct child would also grant
// that child's children, which are grandchildren of `available.target`. // that child's children, which are grandchildren of `available.target`.
(false, false) => requested.target == available.target, (false, false) => requested_target == available_target,
} }
} }
fn rule_path_sets_overlap(left: &ResolvedRule, right: &ResolvedRule) -> bool { fn rule_path_sets_overlap(
left: &ResolvedRule,
right: &ResolvedRule,
identity: RuleIdentity,
) -> bool {
let left_target = rule_target(left, identity);
let right_target = rule_target(right, identity);
match (left.recursive, right.recursive) { match (left.recursive, right.recursive) {
(true, true) => { (true, true) => {
left.target.starts_with(&right.target) || right.target.starts_with(&left.target) left_target.starts_with(right_target) || right_target.starts_with(left_target)
} }
(true, false) => recursive_and_non_recursive_sets_overlap(left, right), (true, false) => recursive_and_non_recursive_sets_overlap(left_target, right_target),
(false, true) => recursive_and_non_recursive_sets_overlap(right, left), (false, true) => recursive_and_non_recursive_sets_overlap(right_target, left_target),
(false, false) => { (false, false) => {
left.target == right.target left_target == right_target
|| direct_child(&left.target, &right.target) || direct_child(left_target, right_target)
|| direct_child(&right.target, &left.target) || direct_child(right_target, left_target)
} }
} }
} }
fn recursive_and_non_recursive_sets_overlap( fn recursive_and_non_recursive_sets_overlap(
recursive: &ResolvedRule, recursive_target: &Path,
non_recursive: &ResolvedRule, non_recursive_target: &Path,
) -> bool { ) -> bool {
// The non-recursive set is `{target} + direct children`. It overlaps a // The non-recursive set is `{target} + direct children`. It overlaps a
// recursive subtree when either the non-recursive target is inside that // recursive subtree when either the non-recursive target is inside that
// subtree, or the recursive subtree begins at the non-recursive target or // subtree, or the recursive subtree begins at the non-recursive target or
// one of its direct children. // one of its direct children.
non_recursive.target.starts_with(&recursive.target) non_recursive_target.starts_with(recursive_target)
|| recursive.target == non_recursive.target || recursive_target == non_recursive_target
|| direct_child(&recursive.target, &non_recursive.target) || direct_child(recursive_target, non_recursive_target)
} }
fn direct_child(child: &Path, parent: &Path) -> bool { fn direct_child(child: &Path, parent: &Path) -> bool {
@@ -201,7 +239,8 @@ impl Scope {
} }
/// Convenience constructor for tests and simple setups: a single /// Convenience constructor for tests and simple setups: a single
/// recursive `allow(Write)` rule rooted at the lexical path `root`. /// recursive `allow(Write)` rule rooted at `root` with the default
/// resolved-target symlink policy.
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> { pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
let root = normalize_path(root.as_ref()).ok_or_else(|| { let root = normalize_path(root.as_ref()).ok_or_else(|| {
std::io::Error::new( std::io::Error::new(
@@ -209,19 +248,26 @@ impl Scope {
"scope root must be an absolute path without root traversal", "scope root must be an absolute path without root traversal",
) )
})?; })?;
let resolved_root = resolve_path(&root)?;
Ok(Self { Ok(Self {
allow: vec![ResolvedRule { allow: vec![ResolvedRule {
target: root, logical_target: root,
resolved_target: resolved_root,
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}], }],
deny: Vec::new(), deny: Vec::new(),
}) })
} }
/// Return one rule's lexically normalized target without resolving symlinks. /// Return one rule target in the identity selected by its symlink policy.
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> { pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
Ok(resolve_rule(rule)?.target) let rule = resolve_rule(rule)?;
Ok(match rule.symlink_policy {
SymlinkPolicy::Resolved => rule.resolved_target,
SymlinkPolicy::Logical => rule.logical_target,
})
} }
/// Return whether this effective scope fully contains a requested rule. /// Return whether this effective scope fully contains a requested rule.
@@ -248,10 +294,23 @@ impl Scope {
/// Returns `None` when `path` is outside every allow rule, or when /// Returns `None` when `path` is outside every allow rule, or when
/// deny rules have knocked it below `Read`. /// deny rules have knocked it below `Read`.
pub fn permission_at(&self, path: &Path) -> Option<Permission> { pub fn permission_at(&self, path: &Path) -> Option<Permission> {
let resolved = normalize_path(path)?; let logical = normalize_path(path)?;
let resolved = resolve_path(&logical).ok()?;
self.permission_at_paths(&logical, &resolved)
}
/// Effective permission for a path whose logical and provider-resolved
/// identities were obtained inside the filesystem provider boundary.
pub fn permission_at_paths(&self, logical: &Path, resolved: &Path) -> Option<Permission> {
let logical = normalize_path(logical)?;
let resolved = normalize_path(resolved)?;
let mut effective: Option<Permission> = None; let mut effective: Option<Permission> = None;
for rule in &self.allow { for rule in &self.allow {
if rule.matches(&resolved) { let candidate = match rule.symlink_policy {
SymlinkPolicy::Resolved => &resolved,
SymlinkPolicy::Logical => &logical,
};
if rule.matches(candidate, rule.symlink_policy) {
effective = match effective { effective = match effective {
None => Some(rule.permission), None => Some(rule.permission),
Some(cur) => Some(cur.max(rule.permission)), Some(cur) => Some(cur.max(rule.permission)),
@@ -260,11 +319,13 @@ impl Scope {
} }
let mut effective = effective?; let mut effective = effective?;
// Deny: min(min_deny) dictates the cap. Effective level is capped // Deny rules always inspect both identities. This prevents a logical
// strictly below that value, so deny(read) wipes access entirely. // alias or a second symlink to the same target from bypassing a deny.
let mut min_deny: Option<Permission> = None; let mut min_deny: Option<Permission> = None;
for rule in &self.deny { for rule in &self.deny {
if rule.matches(&resolved) { if rule.matches(&logical, SymlinkPolicy::Logical)
|| rule.matches(&resolved, SymlinkPolicy::Resolved)
{
min_deny = match min_deny { min_deny = match min_deny {
None => Some(rule.permission), None => Some(rule.permission),
Some(cur) => Some(cur.min(rule.permission)), Some(cur) => Some(cur.min(rule.permission)),
@@ -297,7 +358,7 @@ impl Scope {
/// rule, preserving declaration order. Does not account for deny /// rule, preserving declaration order. Does not account for deny
/// rules, which only cap effective permission at query time. /// rules, which only cap effective permission at query time.
pub fn readable_paths(&self) -> impl Iterator<Item = &Path> { pub fn readable_paths(&self) -> impl Iterator<Item = &Path> {
self.allow.iter().map(|r| r.target.as_path()) self.allow.iter().map(|r| r.logical_target.as_path())
} }
/// Allow rules with their targets resolved to absolute paths. /// Allow rules with their targets resolved to absolute paths.
@@ -309,9 +370,10 @@ impl Scope {
self.allow self.allow
.iter() .iter()
.map(|r| ScopeRule { .map(|r| ScopeRule {
target: r.target.clone(), target: r.logical_target.clone(),
permission: r.permission, permission: r.permission,
recursive: r.recursive, recursive: r.recursive,
symlink_policy: r.symlink_policy,
}) })
.collect() .collect()
} }
@@ -326,9 +388,10 @@ impl Scope {
self.deny self.deny
.iter() .iter()
.map(|r| ScopeRule { .map(|r| ScopeRule {
target: r.target.clone(), target: r.logical_target.clone(),
permission: r.permission, permission: r.permission,
recursive: r.recursive, recursive: r.recursive,
symlink_policy: r.symlink_policy,
}) })
.collect() .collect()
} }
@@ -339,7 +402,7 @@ impl Scope {
self.allow self.allow
.iter() .iter()
.filter(|r| r.permission == Permission::Write) .filter(|r| r.permission == Permission::Write)
.map(|r| r.target.as_path()) .map(|r| r.logical_target.as_path())
} }
/// Build a new [`Scope`] equal to `self` with `extra_allow` appended /// Build a new [`Scope`] equal to `self` with `extra_allow` appended
@@ -416,7 +479,10 @@ impl Scope {
pub fn summary(&self) -> String { pub fn summary(&self) -> String {
fn push_rule(out: &mut String, rule: &ResolvedRule) { fn push_rule(out: &mut String, rule: &ResolvedRule) {
out.push_str(" - "); out.push_str(" - ");
out.push_str(&rule.target.display().to_string()); out.push_str(&rule.logical_target.display().to_string());
if rule.symlink_policy == SymlinkPolicy::Logical {
out.push_str(" [logical-symlinks]");
}
if !rule.recursive { if !rule.recursive {
out.push_str(" [non-recursive]"); out.push_str(" [non-recursive]");
} }
@@ -514,11 +580,15 @@ impl SharedScope {
} }
impl ResolvedRule { impl ResolvedRule {
fn matches(&self, path: &Path) -> bool { fn matches(&self, path: &Path, identity: SymlinkPolicy) -> bool {
let target = match identity {
SymlinkPolicy::Resolved => &self.resolved_target,
SymlinkPolicy::Logical => &self.logical_target,
};
if self.recursive { if self.recursive {
path.starts_with(&self.target) path.starts_with(target)
} else { } else {
path == self.target || path.parent() == Some(self.target.as_path()) path == target || path.parent() == Some(target.as_path())
} }
} }
} }
@@ -527,17 +597,63 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
if !rule.target.is_absolute() { if !rule.target.is_absolute() {
return Err(ScopeError::RelativeTarget(rule.target.clone())); return Err(ScopeError::RelativeTarget(rule.target.clone()));
} }
let target = normalize_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget { let logical_target = normalize_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
path: rule.target.clone(), path: rule.target.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"), source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
})?; })?;
let resolved_target =
resolve_path(&logical_target).map_err(|source| ScopeError::ResolveTarget {
path: rule.target.clone(),
source,
})?;
Ok(ResolvedRule { Ok(ResolvedRule {
target, logical_target,
resolved_target,
permission: rule.permission, permission: rule.permission,
recursive: rule.recursive, recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
}) })
} }
/// Resolve every existing path component while retaining a missing final tail.
/// A dangling symlink is rejected rather than treated as an ordinary missing
/// component because its resolved authority cannot be established.
fn resolve_path(path: &Path) -> std::io::Result<PathBuf> {
let mut cursor = path;
let mut missing = Vec::<OsString>::new();
loop {
match std::fs::canonicalize(cursor) {
Ok(mut resolved) => {
for component in missing.iter().rev() {
resolved.push(component);
}
return normalize_path(&resolved).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"resolved target is not an absolute normalized path",
)
});
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(cursor)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(error);
}
let name = cursor.file_name().ok_or(error)?;
missing.push(name.to_os_string());
cursor = cursor.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"scope target has no existing ancestor",
)
})?;
}
Err(error) => return Err(error),
}
}
}
/// Normalize an absolute path for lexical scope comparison without consulting /// Normalize an absolute path for lexical scope comparison without consulting
/// filesystem metadata or resolving symbolic links. /// filesystem metadata or resolving symbolic links.
fn normalize_path(path: &Path) -> Option<PathBuf> { fn normalize_path(path: &Path) -> Option<PathBuf> {
@@ -571,6 +687,7 @@ mod tests {
target: target.to_path_buf(), target: target.to_path_buf(),
permission, permission,
recursive, recursive,
symlink_policy: Default::default(),
} }
} }
@@ -685,6 +802,7 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}; };
@@ -784,6 +902,7 @@ mod tests {
target: PathBuf::from("relative/path"), target: PathBuf::from("relative/path"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}; };
@@ -801,19 +920,84 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn scope_authorizes_symlink_paths_lexically_without_authorizing_targets() { fn scope_defaults_to_resolved_symlink_authority_and_logical_is_explicit() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap(); let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("outside.txt"), "visible through link").unwrap(); std::fs::write(outside.path().join("outside.txt"), "visible through link").unwrap();
symlink(outside.path(), dir.path().join("external")).unwrap(); symlink(outside.path(), dir.path().join("external")).unwrap();
let scope = Scope::writable(dir.path()).unwrap();
assert!(scope.is_readable(&dir.path().join("external/outside.txt"))); let resolved = Scope::writable(dir.path()).unwrap();
assert!(scope.is_writable(&dir.path().join("external/new.txt"))); assert!(!resolved.is_readable(&dir.path().join("external/outside.txt")));
assert!(!scope.is_readable(&outside.path().join("outside.txt"))); assert!(!resolved.is_writable(&dir.path().join("external/new.txt")));
assert!(!scope.is_writable(&outside.path().join("new.txt")));
let logical = Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: Vec::new(),
})
.unwrap();
assert!(logical.is_readable(&dir.path().join("external/outside.txt")));
assert!(logical.is_writable(&dir.path().join("external/new.txt")));
assert!(!logical.is_readable(&outside.path().join("outside.txt")));
assert!(!logical.is_writable(&outside.path().join("new.txt")));
}
#[cfg(unix)]
#[test]
fn deny_rules_match_both_logical_alias_and_resolved_target() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let secret = root.path().join("secret");
std::fs::create_dir(&secret).unwrap();
std::fs::write(secret.join("key"), "hidden").unwrap();
symlink(&secret, root.path().join("alias")).unwrap();
let scope = Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: vec![ScopeRule {
target: secret,
permission: Permission::Read,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
})
.unwrap();
assert!(!scope.is_readable(&root.path().join("alias/key")));
}
#[test]
fn delegation_symlink_policy_is_monotonically_attenuated() {
let root = TempDir::new().unwrap();
let mut parent_rule = allow_rule(root.path(), Permission::Write);
parent_rule.symlink_policy = SymlinkPolicy::Logical;
let logical_parent = DelegationScope::from_config(&ScopeConfig {
allow: vec![parent_rule],
deny: Vec::new(),
})
.unwrap();
let resolved_child = allow_rule(&root.path().join("child"), Permission::Read);
assert!(logical_parent.allows_rule(&resolved_child).unwrap());
let resolved_parent = DelegationScope::from_config(&ScopeConfig {
allow: vec![allow_rule(root.path(), Permission::Write)],
deny: Vec::new(),
})
.unwrap();
let mut logical_child = resolved_child;
logical_child.symlink_policy = SymlinkPolicy::Logical;
assert!(!resolved_parent.allows_rule(&logical_child).unwrap());
} }
#[test] #[test]
@@ -862,11 +1046,13 @@ mod tests {
target: docs.clone(), target: docs.clone(),
permission: Permission::Read, permission: Permission::Read,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
@@ -925,6 +1111,7 @@ mod tests {
target: extra.path().to_path_buf(), target: extra.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
.unwrap(); .unwrap();
assert!(extended.is_readable(&extra.path().join("x"))); assert!(extended.is_readable(&extra.path().join("x")));
@@ -942,6 +1129,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
.unwrap(); .unwrap();
let f = sub.join("a.txt"); let f = sub.join("a.txt");
@@ -961,6 +1149,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}; };
let base = Scope::writable(dir.path()) let base = Scope::writable(dir.path())
.unwrap() .unwrap()
@@ -1014,6 +1203,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1032,6 +1222,7 @@ mod tests {
target: extra.path().to_path_buf(), target: extra.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
+1
View File
@@ -23,6 +23,7 @@ fn deny_write(target: &Path) -> ScopeRule {
target: target.to_path_buf(), target: target.to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
+142 -137
View File
@@ -85,24 +85,19 @@ impl AuthenticatedInputSource {
} }
} }
/// Immutable identity and revision fence for one state-changing Worker command. /// Caller-owned identity for one state-changing Worker command.
///
/// A controller accepts command ids in strictly increasing order. Exact retries
/// of an accepted id must retain the same command kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkerCommandEnvelope { pub struct WorkerCommandEnvelope {
/// Caller-owned sequence. A controller accepts command ids in strictly
/// increasing order for one execution generation.
pub command_id: u64, pub command_id: u64,
pub expected_execution_generation: u64,
pub expected_worker_state_revision: u64,
} }
impl WorkerCommandEnvelope { impl WorkerCommandEnvelope {
pub fn for_snapshot(command_id: u64, snapshot: &WorkerStateSnapshot) -> Self { pub fn new(command_id: u64) -> Self {
Self { Self { command_id }
command_id,
expected_execution_generation: snapshot.execution_generation,
expected_worker_state_revision: snapshot.revision,
}
} }
} }
@@ -122,8 +117,6 @@ pub enum WorkerCommandKind {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerCommandDisposition { pub enum WorkerCommandDisposition {
Accepted, Accepted,
StaleExecutionGeneration,
StaleWorkerStateRevision,
StaleCommandId, StaleCommandId,
Conflict, Conflict,
InvalidState, InvalidState,
@@ -175,18 +168,14 @@ pub enum WorkerMaintenanceState {
#[derive(Debug, Clone, 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))]
pub struct WorkerStateSnapshot { pub struct WorkerStateSnapshot {
pub execution_generation: u64, /// Highest lifecycle command id observed by this controller instance.
pub revision: u64,
/// Highest lifecycle command id observed by this controller generation.
pub last_command_id: u64, pub last_command_id: u64,
pub state: WorkerState, pub state: WorkerState,
} }
impl WorkerStateSnapshot { impl WorkerStateSnapshot {
pub fn initial(execution_generation: u64) -> Self { pub fn initial() -> Self {
Self { Self {
execution_generation,
revision: 0,
last_command_id: 0, last_command_id: 0,
state: WorkerState::Idle, state: WorkerState::Idle,
} }
@@ -204,53 +193,6 @@ impl WorkerStateSnapshot {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkerStateSnapshotApply {
Applied,
Duplicate,
Stale,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkerStateSnapshotConflict {
pub execution_generation: u64,
pub revision: u64,
}
impl std::fmt::Display for WorkerStateSnapshotConflict {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"conflicting worker state snapshots at generation {} revision {}",
self.execution_generation, self.revision
)
}
}
impl std::error::Error for WorkerStateSnapshotConflict {}
pub fn apply_worker_state_snapshot(
current: &mut WorkerStateSnapshot,
incoming: &WorkerStateSnapshot,
) -> Result<WorkerStateSnapshotApply, WorkerStateSnapshotConflict> {
use std::cmp::Ordering;
let ordering = (incoming.execution_generation, incoming.revision)
.cmp(&(current.execution_generation, current.revision));
match ordering {
Ordering::Greater => {
*current = incoming.clone();
Ok(WorkerStateSnapshotApply::Applied)
}
Ordering::Less => Ok(WorkerStateSnapshotApply::Stale),
Ordering::Equal if incoming == current => Ok(WorkerStateSnapshotApply::Duplicate),
Ordering::Equal => Err(WorkerStateSnapshotConflict {
execution_generation: incoming.execution_generation,
revision: incoming.revision,
}),
}
}
impl From<WorkerStatus> for WorkerStateSnapshot { impl From<WorkerStatus> for WorkerStateSnapshot {
fn from(status: WorkerStatus) -> Self { fn from(status: WorkerStatus) -> Self {
let state = match status { let state = match status {
@@ -261,8 +203,6 @@ impl From<WorkerStatus> for WorkerStateSnapshot {
WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)), WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)),
}; };
Self { Self {
execution_generation: 1,
revision: 0,
last_command_id: 0, last_command_id: 0,
state, state,
} }
@@ -1228,8 +1168,12 @@ 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, or bound the run to its /// Runtime-only compaction progress. `None` clears the current status.
/// observable Internal Worker. Revisions upsert one stable lifecycle item. /// This never enters Session history and carries no operation or Segment identity.
CompactionProgress {
compaction: Option<InFlightCompaction>,
},
/// Legacy compaction lifecycle event retained for wire read compatibility.
CompactStart { CompactStart {
lifecycle: CompactionLifecycle, lifecycle: CompactionLifecycle,
}, },
@@ -1416,6 +1360,32 @@ pub enum CommandEvent {
}, },
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompactionPhase {
Preparing,
Summarizing,
Committing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompactionTrigger {
Manual,
PreRun,
RequestThreshold,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct InFlightCompaction {
pub phase: CompactionPhase,
pub started_at_ms: u64,
pub trigger: CompactionTrigger,
}
/// Unfinished model output and active command state included in /// Unfinished model output and active command state included in
/// `Event::Snapshot` for clients that attach while work is still streaming. /// `Event::Snapshot` for clients that attach while work is still streaming.
/// ///
@@ -1430,11 +1400,17 @@ pub struct InFlightSnapshot {
pub blocks: Vec<InFlightBlock>, pub blocks: Vec<InFlightBlock>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<CommandSnapshot>, pub commands: Vec<CommandSnapshot>,
/// The currently running compaction, if any.
///
/// This is lifecycle progress only. Candidate history and the staged
/// Segment remain private until the Segment is activated atomically.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction: Option<InFlightCompaction>,
} }
impl InFlightSnapshot { impl InFlightSnapshot {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.blocks.is_empty() && self.commands.is_empty() self.blocks.is_empty() && self.commands.is_empty() && self.compaction.is_none()
} }
} }
@@ -1599,12 +1575,30 @@ pub struct ScopeRule {
/// direct children. Defaults to `true`. /// direct children. Defaults to `true`.
#[serde(default = "default_recursive")] #[serde(default = "default_recursive")]
pub recursive: bool, pub recursive: bool,
/// Which path identity an allow rule uses when symbolic links are
/// encountered. Deny rules always inspect both identities.
#[serde(default)]
pub symlink_policy: SymlinkPolicy,
} }
fn default_recursive() -> bool { fn default_recursive() -> bool {
true true
} }
/// Symbolic-link identity used by one filesystem allow rule.
///
/// `Resolved` is the least authority and the default: access is matched
/// against the provider-resolved target. `Logical` intentionally grants the
/// path as presented through the Workdir, even when it aliases another target.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum SymlinkPolicy {
#[default]
Resolved,
Logical,
}
/// Permission lattice used by [`ScopeRule`]. /// Permission lattice used by [`ScopeRule`].
/// ///
/// The derived `Ord` instance follows declaration order, so /// The derived `Ord` instance follows declaration order, so
@@ -1624,55 +1618,43 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn worker_state_snapshot_apply_is_monotonic_and_detects_conflicts() { fn scope_rule_defaults_to_resolved_symlink_policy() {
let mut current = WorkerStateSnapshot::initial(4); let rule: ScopeRule = serde_json::from_value(serde_json::json!({
let mut newer = current.clone(); "target": "/workspace",
newer.revision = 1; "permission": "read"
newer.state = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)); }))
.unwrap();
assert!(rule.recursive);
assert_eq!(rule.symlink_policy, SymlinkPolicy::Resolved);
assert_eq!( let logical: ScopeRule = serde_json::from_value(serde_json::json!({
apply_worker_state_snapshot(&mut current, &newer), "target": "/workspace",
Ok(WorkerStateSnapshotApply::Applied) "permission": "read",
); "symlink_policy": "logical"
assert_eq!( }))
apply_worker_state_snapshot(&mut current, &newer), .unwrap();
Ok(WorkerStateSnapshotApply::Duplicate) assert_eq!(logical.symlink_policy, SymlinkPolicy::Logical);
); }
let stale_revision = WorkerStateSnapshot::initial(4); #[test]
assert_eq!( fn worker_state_snapshot_wire_shape_has_one_authoritative_state() {
apply_worker_state_snapshot(&mut current, &stale_revision), let snapshot = WorkerStateSnapshot {
Ok(WorkerStateSnapshotApply::Stale) last_command_id: 7,
); state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
let stale_generation = WorkerStateSnapshot {
execution_generation: 3,
revision: u64::MAX,
..newer.clone()
}; };
let value = serde_json::to_value(&snapshot).unwrap();
assert_eq!( assert_eq!(
apply_worker_state_snapshot(&mut current, &stale_generation), value,
Ok(WorkerStateSnapshotApply::Stale) serde_json::json!({
); "last_command_id": 7,
"state": {
let conflicting = WorkerStateSnapshot { "kind": "busy",
state: WorkerState::Idle, "state": { "kind": "run", "state": "running" }
..newer.clone() }
};
assert_eq!(
apply_worker_state_snapshot(&mut current, &conflicting),
Err(WorkerStateSnapshotConflict {
execution_generation: 4,
revision: 1,
}) })
); );
assert_eq!(current, newer); assert!(value.get("execution_generation").is_none());
assert!(value.get("revision").is_none());
let next_generation = WorkerStateSnapshot::initial(5);
assert_eq!(
apply_worker_state_snapshot(&mut current, &next_generation),
Ok(WorkerStateSnapshotApply::Applied)
);
assert_eq!(current, next_generation);
} }
#[test] #[test]
@@ -1869,28 +1851,21 @@ mod tests {
} }
#[test] #[test]
fn lifecycle_methods_roundtrip_with_fences() { fn lifecycle_methods_roundtrip_with_command_identity() {
for method in [ for method in [
Method::Pause { Method::Pause {
command: WorkerCommandEnvelope { command: WorkerCommandEnvelope { command_id: 11 },
command_id: 11,
expected_execution_generation: 4,
expected_worker_state_revision: 8,
},
}, },
Method::Compact { Method::Compact {
command: WorkerCommandEnvelope { command: WorkerCommandEnvelope { command_id: 12 },
command_id: 12,
expected_execution_generation: 4,
expected_worker_state_revision: 9,
},
}, },
] { ] {
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
assert!(!json.contains("expected_execution_generation"));
assert!(!json.contains("expected_worker_state_revision"));
let decoded: Method = serde_json::from_str(&json).unwrap(); let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Method::Pause { command } | Method::Compact { command } => { Method::Pause { command } | Method::Compact { command } => {
assert_eq!(command.expected_execution_generation, 4);
assert!(command.command_id >= 11); assert!(command.command_id >= 11);
} }
other => panic!("unexpected lifecycle method: {other:?}"), other => panic!("unexpected lifecycle method: {other:?}"),
@@ -2187,7 +2162,7 @@ mod tests {
#[test] #[test]
fn event_snapshot_in_flight_roundtrip_and_default() { fn event_snapshot_in_flight_roundtrip_and_default() {
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"execution_generation":1,"revision":1,"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#; let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap(); let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded { match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()), Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
@@ -2243,6 +2218,11 @@ mod tests {
stderr: CommandStreamSlice::default(), stderr: CommandStreamSlice::default(),
exit_code: None, exit_code: None,
}], }],
compaction: Some(InFlightCompaction {
phase: CompactionPhase::Summarizing,
started_at_ms: 99,
trigger: CompactionTrigger::Manual,
}),
}, },
internal_workers: Vec::new(), internal_workers: Vec::new(),
}; };
@@ -2254,9 +2234,35 @@ mod tests {
parsed["data"]["in_flight"]["blocks"][2]["state"], parsed["data"]["in_flight"]["blocks"][2]["state"],
"streaming_args" "streaming_args"
); );
assert_eq!(
parsed["data"]["in_flight"]["compaction"]["phase"],
"summarizing"
);
assert_eq!(
parsed["data"]["in_flight"]["compaction"]["trigger"],
"manual"
);
assert!(
parsed["data"]["in_flight"]["compaction"]
.as_object()
.is_some_and(|value| {
!value.contains_key("state")
&& !value.contains_key("compaction_id")
&& !value.contains_key("internal_worker")
&& !value.contains_key("summary")
&& !value.contains_key("new_segment_id")
}),
"in-flight compaction progress must not expose terminal or staged state"
);
match serde_json::from_str::<Event>(&json).unwrap() { match serde_json::from_str::<Event>(&json).unwrap() {
Event::Snapshot { in_flight, .. } => assert_eq!(in_flight.blocks.len(), 3), Event::Snapshot { in_flight, .. } => {
assert_eq!(in_flight.blocks.len(), 3);
assert_eq!(
in_flight.compaction.unwrap().phase,
CompactionPhase::Summarizing
);
}
other => panic!("expected Snapshot, got {other:?}"), other => panic!("expected Snapshot, got {other:?}"),
} }
} }
@@ -2300,8 +2306,6 @@ mod tests {
fn event_worker_state_format() { fn event_worker_state_format() {
let event = Event::WorkerState { let event = Event::WorkerState {
snapshot: WorkerStateSnapshot { snapshot: WorkerStateSnapshot {
execution_generation: 7,
revision: 3,
last_command_id: 9, last_command_id: 9,
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)), state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
}, },
@@ -2309,8 +2313,12 @@ mod tests {
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"], "worker_state"); assert_eq!(parsed["event"], "worker_state");
assert_eq!(parsed["data"]["snapshot"]["execution_generation"], 7); assert!(
assert_eq!(parsed["data"]["snapshot"]["revision"], 3); parsed["data"]["snapshot"]
.get("execution_generation")
.is_none()
);
assert!(parsed["data"]["snapshot"].get("revision").is_none());
assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy"); assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy");
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
@@ -2318,10 +2326,8 @@ mod tests {
decoded, decoded,
Event::WorkerState { Event::WorkerState {
snapshot: WorkerStateSnapshot { snapshot: WorkerStateSnapshot {
execution_generation: 7, last_command_id: 9,
revision: 3,
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)), state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
..
} }
} }
)); ));
@@ -2462,6 +2468,7 @@ mod tests {
target: "/tmp/work".into(), target: "/tmp/work".into(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}); });
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
@@ -2780,8 +2787,6 @@ mod tests {
"tools": [] "tools": []
}, },
"state": { "state": {
"execution_generation": 1,
"revision": 0,
"last_command_id": 0, "last_command_id": 0,
"state": { "kind": "idle" } "state": { "kind": "idle" }
} }
+16 -12
View File
@@ -4,18 +4,18 @@ use ts_rs::{Config, TS};
use crate::{ use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream, Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry, CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot, CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot, InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType, InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, SessionContentPart, PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
SessionEntryProvenance, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry, ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, ToolResultDisposition, SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState, SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope, UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerStateSnapshot, WorkerStatus, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
subscription::{ subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame, EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest, SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -64,6 +64,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<ToolResultDisposition>(&cfg, &mut output); push_decl::<ToolResultDisposition>(&cfg, &mut output);
push_decl::<ErrorCode>(&cfg, &mut output); push_decl::<ErrorCode>(&cfg, &mut output);
push_decl::<Permission>(&cfg, &mut output); push_decl::<Permission>(&cfg, &mut output);
push_decl::<SymlinkPolicy>(&cfg, &mut output);
push_decl::<InFlightToolCallState>(&cfg, &mut output); push_decl::<InFlightToolCallState>(&cfg, &mut output);
push_decl::<CommandStatus>(&cfg, &mut output); push_decl::<CommandStatus>(&cfg, &mut output);
push_decl::<CommandStream>(&cfg, &mut output); push_decl::<CommandStream>(&cfg, &mut output);
@@ -72,6 +73,8 @@ pub fn generated_protocol_types() -> String {
push_decl::<CommandEvent>(&cfg, &mut output); push_decl::<CommandEvent>(&cfg, &mut output);
push_decl::<CompactionLifecycleState>(&cfg, &mut output); push_decl::<CompactionLifecycleState>(&cfg, &mut output);
push_decl::<CompactionLifecycle>(&cfg, &mut output); push_decl::<CompactionLifecycle>(&cfg, &mut output);
push_decl::<CompactionPhase>(&cfg, &mut output);
push_decl::<CompactionTrigger>(&cfg, &mut output);
push_decl::<UploadedFileAvailability>(&cfg, &mut output); push_decl::<UploadedFileAvailability>(&cfg, &mut output);
push_decl::<UploadedFileRef>(&cfg, &mut output); push_decl::<UploadedFileRef>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output); push_decl::<ScopeRule>(&cfg, &mut output);
@@ -80,6 +83,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<RewindTarget>(&cfg, &mut output); push_decl::<RewindTarget>(&cfg, &mut output);
push_decl::<RewindSummary>(&cfg, &mut output); push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output); push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightCompaction>(&cfg, &mut output);
push_decl::<InFlightSnapshot>(&cfg, &mut output); push_decl::<InFlightSnapshot>(&cfg, &mut output);
push_decl::<SessionEntryProvenance>(&cfg, &mut output); push_decl::<SessionEntryProvenance>(&cfg, &mut output);
push_decl::<SessionMessageRole>(&cfg, &mut output); push_decl::<SessionMessageRole>(&cfg, &mut output);
+3
View File
@@ -8,3 +8,6 @@ license.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
session-store = { workspace = true } session-store = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+32 -22
View File
@@ -1,29 +1,39 @@
# session-metrics # session-metrics
## Role Session 単位の append-only な観測値を既存 session-log に記録し、明示的な
metrics 読取 / JSONL export 経路で取り出すための小さなヘルパークレートです。
`session-metrics` records usage and memory/session metrics that are useful for diagnostics and maintenance. - 保存先は `session-store``LogEntry::Extension`
- extension domain は `metrics`
- metric は `name / ts / dimensions / value / correlation_id` の最小 envelope
- `record_metric` で指定した Session / Segment に append する
- `read_segment_metrics` は 1 Segment、`read_session_metrics` は Session 内の全
Segment を読み、各 metric に `segment_id``compacted_from` を付ける
- `export_metrics_jsonl` はその located metric を newline-delimited JSON にする
- 通常の Session snapshot / Worker list / Worker detail は Extension を公開しない
## Boundaries compaction は `compact.start` を source Segment、`compact.finish`
`compact.post_request` を結果 Segment に記録する。同じ `correlation_id`
`SegmentStart.compacted_from` により、Segment をまたぐ attempt と次の通常 LLM
request を結合できる。
Owns: ```rust,ignore
use session_metrics::{
Metric, export_metrics_jsonl, read_session_metrics, record_metric,
};
- metric record types and persistence helpers let metric = Metric::now("compact.start")
- explicit memory usage/read/reference observations where applicable .with_value(12_345.0)
- lightweight diagnostic data that should not become model context by itself .with_dimension("trigger", "pre_run")
.with_correlation_id("018f6f8a-9822-7b11-8b35-706f30313700");
record_metric(
&store,
location.session_id,
location.segment_id,
&metric,
)?;
Does not own: let records = read_session_metrics(&store, location.session_id)?;
let jsonl = export_metrics_jsonl(&records)?;
- prompt context packing (`agen`) # Ok::<(), Box<dyn std::error::Error>>(())
- generated memory contents (`memory`) ```
- provider billing semantics (`provider`)
- UI status rendering (`tui`)
## Design notes
Metrics are observations. They may guide compaction, memory effectiveness analysis, or UX, but they are not authoritative conversation history and should not smuggle hidden state into model input.
## See also
- [`../../docs/design/memory-knowledge.md`](../../docs/design/memory-knowledge.md)
- [`../../docs/design/compaction.md`](../../docs/design/compaction.md)
+260 -3
View File
@@ -12,13 +12,16 @@
//! 観測できない値は `None` で明示する //! 観測できない値は `None` で明示する
//! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に //! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に
//! 書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手で join //! 書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手で join
//! - 集計 / 可視化 API はこのクレートには無い。session-log を読めば取り出せる、 //! - 集計 / 可視化には [`read_session_metrics`] / [`read_segment_metrics`] /
//! までが到達点 //! [`export_metrics_jsonl`] の明示的な metrics 専用経路を使う。通常の
//! Session snapshot は `Extension` を公開しない。
use std::collections::BTreeMap; use std::collections::BTreeMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::{SegmentId, SessionId, Store, StoreError, save_extension, segment_log}; use session_store::{
LogEntry, SegmentId, SegmentOrigin, SessionId, Store, StoreError, save_extension, segment_log,
};
/// Domain tag used in `LogEntry::Extension` for all metrics records. /// Domain tag used in `LogEntry::Extension` for all metrics records.
pub const DOMAIN: &str = "metrics"; pub const DOMAIN: &str = "metrics";
@@ -97,6 +100,165 @@ pub fn metrics_from_extensions(extensions: &[(String, serde_json::Value)]) -> Ve
.collect() .collect()
} }
/// A metric together with its durable Session/Segment origin.
///
/// `compacted_from` is copied from the Segment start record so readers can
/// reconstruct compaction lineage without inferring relationships from metric
/// names or timestamps.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LocatedMetric {
pub session_id: SessionId,
pub segment_id: SegmentId,
#[serde(skip_serializing_if = "Option::is_none")]
pub compacted_from: Option<SegmentOrigin>,
pub log_index: usize,
pub metric: Metric,
}
#[derive(Debug)]
pub enum SessionMetricsError {
Store(StoreError),
MissingSegmentStart {
segment_id: SegmentId,
},
SessionMismatch {
requested: SessionId,
observed: SessionId,
segment_id: SegmentId,
},
Encode(serde_json::Error),
}
impl std::fmt::Display for SessionMetricsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Store(error) => write!(formatter, "session metrics store error: {error}"),
Self::MissingSegmentStart { segment_id } => {
write!(formatter, "segment {segment_id} has no start record")
}
Self::SessionMismatch {
requested,
observed,
segment_id,
} => write!(
formatter,
"segment {segment_id} belongs to session {observed}, not {requested}"
),
Self::Encode(error) => write!(formatter, "session metrics encode error: {error}"),
}
}
}
impl std::error::Error for SessionMetricsError {}
impl From<StoreError> for SessionMetricsError {
fn from(error: StoreError) -> Self {
Self::Store(error)
}
}
impl From<serde_json::Error> for SessionMetricsError {
fn from(error: serde_json::Error) -> Self {
Self::Encode(error)
}
}
/// Read metrics from one exact Segment.
///
/// This is an explicit metrics-only surface. It validates the Segment's
/// durable start record and retains the log position of each metric.
pub fn read_segment_metrics(
store: &dyn Store,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
let entries = store.read_all(session_id, segment_id)?;
let (observed_session_id, compacted_from) = entries
.iter()
.find_map(|entry| match entry {
LogEntry::AnnotatedSegmentStart {
session_id,
compacted_from,
..
} => Some((*session_id, compacted_from.clone())),
_ => None,
})
.ok_or(SessionMetricsError::MissingSegmentStart { segment_id })?;
if observed_session_id != session_id {
return Err(SessionMetricsError::SessionMismatch {
requested: session_id,
observed: observed_session_id,
segment_id,
});
}
Ok(entries
.iter()
.enumerate()
.filter_map(|(log_index, entry)| match entry {
LogEntry::Extension {
domain, payload, ..
} if domain == DOMAIN => {
serde_json::from_value::<Metric>(payload.clone())
.ok()
.map(|metric| LocatedMetric {
session_id,
segment_id,
compacted_from: compacted_from.clone(),
log_index,
metric,
})
}
_ => None,
})
.collect())
}
/// Read every metric for a Session across all of its Segments.
pub fn read_session_metrics(
store: &dyn Store,
session_id: SessionId,
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
let mut metrics = Vec::new();
for segment_id in store.list_segments(session_id)? {
metrics.extend(read_segment_metrics(store, session_id, segment_id)?);
}
metrics.sort_by(|left, right| {
(
left.metric.ts,
metric_phase_order(&left.metric.name),
left.segment_id,
left.log_index,
)
.cmp(&(
right.metric.ts,
metric_phase_order(&right.metric.name),
right.segment_id,
right.log_index,
))
});
Ok(metrics)
}
/// Serialize located metrics as newline-delimited JSON for an explicit export.
pub fn export_metrics_jsonl(metrics: &[LocatedMetric]) -> Result<String, SessionMetricsError> {
let mut output = String::new();
for metric in metrics {
output.push_str(&serde_json::to_string(metric)?);
output.push('\n');
}
Ok(output)
}
fn metric_phase_order(name: &str) -> u8 {
match name {
"compact.start" => 0,
"compact.finish" => 2,
"compact.post_request" => 3,
_ => 1,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -151,6 +313,101 @@ mod tests {
assert_eq!(metrics[1].name, "b"); assert_eq!(metrics[1].name, "b");
} }
#[test]
fn explicit_reader_and_export_preserve_compaction_lineage() {
use session_store::FsStore;
let temp = tempfile::tempdir().unwrap();
let store = FsStore::new(temp.path()).unwrap();
let session_id = SessionId::parse_str("018f6f8a-9822-7b11-8b35-706f30313701").unwrap();
let source_segment_id =
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313702").unwrap();
let result_segment_id =
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313703").unwrap();
let correlation_id = "018f6f8a-9822-7b11-8b35-706f30313700";
store
.create_segment(
session_id,
source_segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: Default::default(),
history: Vec::new(),
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
let mut start = Metric::now("compact.start").with_correlation_id(correlation_id);
start.ts = 10;
record_metric(&store, session_id, source_segment_id, &start).unwrap();
let origin = SegmentOrigin {
segment_id: source_segment_id,
at_turn_index: 0,
};
store
.create_segment(
session_id,
result_segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 2,
session_id,
system_prompt: None,
config: Default::default(),
history: Vec::new(),
forked_from: None,
compacted_from: Some(origin.clone()),
}],
)
.unwrap();
let mut finish = Metric::now("compact.finish").with_correlation_id(correlation_id);
finish.ts = 10;
record_metric(&store, session_id, result_segment_id, &finish).unwrap();
let mut post = Metric::now("compact.post_request").with_correlation_id(correlation_id);
post.ts = 11;
record_metric(&store, session_id, result_segment_id, &post).unwrap();
let source_metrics = read_segment_metrics(&store, session_id, source_segment_id).unwrap();
assert_eq!(source_metrics.len(), 1);
assert_eq!(source_metrics[0].compacted_from, None);
let metrics = read_session_metrics(&store, session_id).unwrap();
assert_eq!(metrics.len(), 3);
assert_eq!(metrics[0].metric.name, "compact.start");
let finish = metrics
.iter()
.find(|record| record.metric.name == "compact.finish")
.unwrap();
assert_eq!(finish.segment_id, result_segment_id);
assert_eq!(finish.compacted_from, Some(origin));
assert!(
metrics
.iter()
.all(|record| { record.metric.correlation_id.as_deref() == Some(correlation_id) })
);
let exported = export_metrics_jsonl(&metrics).unwrap();
let ordinary_snapshot = session_store::public_snapshot::project_current_session_snapshot(
&store.read_all(session_id, result_segment_id).unwrap(),
);
let ordinary_json = serde_json::to_string(&ordinary_snapshot).unwrap();
assert!(!ordinary_json.contains("compact.finish"));
assert!(!ordinary_json.contains("compact.post_request"));
let decoded = exported
.lines()
.map(|line| serde_json::from_str::<LocatedMetric>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(decoded, metrics);
let reopened = FsStore::new(temp.path()).unwrap();
let restored = read_session_metrics(&reopened, session_id).unwrap();
assert_eq!(restored, metrics);
}
#[test] #[test]
fn fold_skips_undeserializable_payloads() { fn fold_skips_undeserializable_payloads() {
// 将来 schema が変わって読めない payload も skip して落ちない。 // 将来 schema が変わって読めない payload も skip して落ちない。
+375 -8
View File
@@ -14,8 +14,24 @@
use crate::{SegmentId, SessionId}; use crate::{SegmentId, SessionId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, Weak};
fn metadata_lock(path: &Path) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
let mut locks = LOCKS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.expect("metadata lock registry poisoned");
if let Some(lock) = locks.get(path).and_then(Weak::upgrade) {
return lock;
}
let lock = Arc::new(Mutex::new(()));
locks.insert(path.to_path_buf(), Arc::downgrade(&lock));
lock
}
/// Errors from Worker metadata persistence. /// Errors from Worker metadata persistence.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -63,6 +79,8 @@ pub struct WorkerSpawnedScopeRule {
pub target: PathBuf, pub target: PathBuf,
pub permission: String, pub permission: String,
pub recursive: bool, pub recursive: bool,
#[serde(default)]
pub symlink_policy: protocol::SymlinkPolicy,
} }
/// One child Worker spawned by this Worker and persisted with the spawner's /// One child Worker spawned by this Worker and persisted with the spawner's
@@ -180,6 +198,24 @@ pub trait WorkerMetadataStore: Send + Sync {
Ok(metadata) Ok(metadata)
} }
/// Compare and swap the active Segment pointer while preserving unrelated metadata.
/// Returns `false` without mutation when the durable pointer no longer matches.
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
let mut matched = false;
self.update_by_name(worker_name, |metadata| {
if metadata.active.as_ref() == Some(expected) {
metadata.active = Some(replacement);
matched = true;
}
})?;
Ok(matched)
}
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot. /// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
fn set_active( fn set_active(
&self, &self,
@@ -328,6 +364,7 @@ pub trait WorkerMetadataStore: Send + Sync {
pub struct WorkerAggregateStore { pub struct WorkerAggregateStore {
root: PathBuf, root: PathBuf,
worker_name: String, worker_name: String,
update_lock: Arc<Mutex<()>>,
} }
impl WorkerAggregateStore { impl WorkerAggregateStore {
@@ -339,7 +376,11 @@ impl WorkerAggregateStore {
let worker_name = worker_name.into(); let worker_name = worker_name.into();
validate_worker_name(&worker_name)?; validate_worker_name(&worker_name)?;
fs::create_dir_all(&root)?; fs::create_dir_all(&root)?;
Ok(Self { root, worker_name }) Ok(Self {
update_lock: metadata_lock(&root),
root,
worker_name,
})
} }
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> { fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
@@ -406,6 +447,47 @@ impl WorkerMetadataStore for WorkerAggregateStore {
Ok(Some(metadata)) Ok(Some(metadata))
} }
fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let mut metadata = self
.read_by_name(worker_name)?
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
update(&mut metadata);
self.write(&metadata)?;
Ok(metadata)
}
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let Some(mut metadata) = self.read_by_name(worker_name)? else {
return Ok(false);
};
if metadata.active.as_ref() != Some(expected) {
return Ok(false);
}
metadata.active = Some(replacement);
self.write(&metadata)?;
Ok(true)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> { fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
Ok(if self.metadata_path().is_file() { Ok(if self.metadata_path().is_file() {
vec![self.worker_name.clone()] vec![self.worker_name.clone()]
@@ -432,6 +514,7 @@ impl WorkerMetadataStore for WorkerAggregateStore {
#[derive(Clone)] #[derive(Clone)]
pub struct FsWorkerStore { pub struct FsWorkerStore {
root: PathBuf, root: PathBuf,
update_lock: Arc<Mutex<()>>,
} }
impl FsWorkerStore { impl FsWorkerStore {
@@ -439,7 +522,10 @@ impl FsWorkerStore {
pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> { pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
let root = root.into(); let root = root.into();
fs::create_dir_all(&root)?; fs::create_dir_all(&root)?;
Ok(Self { root }) Ok(Self {
update_lock: metadata_lock(&root),
root,
})
} }
fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> { fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
@@ -455,12 +541,32 @@ impl FsWorkerStore {
impl WorkerMetadataStore for FsWorkerStore { impl WorkerMetadataStore for FsWorkerStore {
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> { fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
let path = self.metadata_path(&metadata.worker_name)?; let path = self.metadata_path(&metadata.worker_name)?;
if let Some(parent) = path.parent() { let mut content = serde_json::to_vec_pretty(metadata)?;
fs::create_dir_all(parent)?; content.push(b'\n');
let parent = path.parent().expect("metadata path has parent");
fs::create_dir_all(parent)?;
let temp = parent.join(format!(
".metadata.json.tmp-{}-{}",
std::process::id(),
uuid::Uuid::now_v7()
));
let result = (|| -> Result<(), WorkerStoreError> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)?;
file.write_all(&content)?;
file.sync_all()?;
drop(file);
fs::rename(&temp, &path)?;
std::fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
} }
let content = serde_json::to_vec_pretty(metadata)?; result
fs::write(path, content)?;
Ok(())
} }
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> { fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
@@ -473,6 +579,47 @@ impl WorkerMetadataStore for FsWorkerStore {
Ok(Some(serde_json::from_str(&content)?)) Ok(Some(serde_json::from_str(&content)?))
} }
fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let mut metadata = self
.read_by_name(worker_name)?
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
update(&mut metadata);
self.write(&metadata)?;
Ok(metadata)
}
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let Some(mut metadata) = self.read_by_name(worker_name)? else {
return Ok(false);
};
if metadata.active.as_ref() != Some(expected) {
return Ok(false);
}
metadata.active = Some(replacement);
self.write(&metadata)?;
Ok(true)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> { fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
let mut names = Vec::new(); let mut names = Vec::new();
if !self.root.exists() { if !self.root.exists() {
@@ -648,6 +795,26 @@ where
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> { fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
self.worker_metadata_store.read_by_name(worker_name) self.worker_metadata_store.read_by_name(worker_name)
} }
fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
self.worker_metadata_store
.update_by_name(worker_name, update)
}
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
self.worker_metadata_store
.compare_and_swap_active(worker_name, expected, replacement)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> { fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
self.worker_metadata_store.list_names() self.worker_metadata_store.list_names()
} }
@@ -662,6 +829,7 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{LogEntry, Store};
#[test] #[test]
fn worker_metadata_manifest_snapshot_roundtrips() { fn worker_metadata_manifest_snapshot_roundtrips() {
@@ -682,6 +850,25 @@ mod tests {
assert_eq!(restored, metadata); assert_eq!(restored, metadata);
} }
#[test]
fn spawned_scope_rule_defaults_resolved_and_roundtrips_logical_policy() {
let legacy: WorkerSpawnedScopeRule = serde_json::from_value(serde_json::json!({
"target": "/workspace/src",
"permission": "read",
"recursive": true
}))
.unwrap();
assert_eq!(legacy.symlink_policy, protocol::SymlinkPolicy::Resolved);
let logical = WorkerSpawnedScopeRule {
symlink_policy: protocol::SymlinkPolicy::Logical,
..legacy
};
let restored: WorkerSpawnedScopeRule =
serde_json::from_value(serde_json::to_value(&logical).unwrap()).unwrap();
assert_eq!(restored, logical);
}
#[test] #[test]
fn worker_aggregate_store_writes_one_fixed_metadata_identity() { fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -835,6 +1022,7 @@ mod tests {
target: std::path::Path::new("/tmp/delegated").into(), target: std::path::Path::new("/tmp/delegated").into(),
permission: "write".into(), permission: "write".into(),
recursive: true, recursive: true,
symlink_policy: Default::default(),
}; };
store store
.set_spawned_children( .set_spawned_children(
@@ -862,4 +1050,183 @@ mod tests {
assert_eq!(restored.reclaimed_children.len(), 1); assert_eq!(restored.reclaimed_children.len(), 1);
assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]); assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]);
} }
#[test]
fn staged_segment_is_invisible_until_cas_and_reopen_selects_committed_history() {
let temp = tempfile::tempdir().unwrap();
let sessions = temp.path().join("sessions");
let workers = temp.path().join("workers");
let open = || {
CombinedStore::new(
crate::FsStore::new(&sessions).unwrap(),
FsWorkerStore::new(&workers).unwrap(),
)
};
let store = open();
let session_id = crate::new_session_id();
let old_segment_id = crate::new_segment_id();
let new_segment_id = crate::new_segment_id();
let entry = |label: &str| LogEntry::Extension {
ts: 1,
domain: label.into(),
payload: serde_json::json!({}),
};
store
.create_segment(session_id, old_segment_id, &[entry("old-history")])
.unwrap();
store
.write(&WorkerMetadata::new(
"agent",
Some(WorkerActiveSegmentRef::active_segment(
session_id,
old_segment_id,
)),
))
.unwrap();
store
.create_segment(session_id, new_segment_id, &[entry("new-history")])
.unwrap();
drop(store);
let reopened = open();
assert_eq!(
reopened
.read_by_name("agent")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(old_segment_id)
);
assert!(
reopened
.compare_and_swap_active(
"agent",
&WorkerActiveSegmentRef::active_segment(session_id, old_segment_id),
WorkerActiveSegmentRef::active_segment(session_id, new_segment_id),
)
.unwrap()
);
drop(reopened);
let reopened = open();
assert_eq!(
reopened
.read_by_name("agent")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(new_segment_id)
);
assert!(matches!(
reopened.read_all(session_id, new_segment_id).unwrap().as_slice(),
[LogEntry::Extension { domain, .. }] if domain == "new-history"
));
}
#[test]
fn aggregate_store_uses_expected_old_segment_cas() {
let temp = tempfile::tempdir().unwrap();
let store = WorkerAggregateStore::new(temp.path(), "agent").unwrap();
let session_id = crate::new_session_id();
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
store
.write(&WorkerMetadata::new("agent", Some(old.clone())))
.unwrap();
assert!(
store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
)
.unwrap()
);
assert!(
!store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
)
.unwrap()
);
}
#[test]
fn combined_store_delegates_atomic_active_segment_cas() {
let temp = tempfile::tempdir().unwrap();
let metadata = FsWorkerStore::new(temp.path().join("workers")).unwrap();
let store = CombinedStore::new(
crate::FsStore::new(temp.path().join("sessions")).unwrap(),
metadata,
);
let session_id = crate::new_session_id();
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
store
.write(&WorkerMetadata::new("agent", Some(old.clone())))
.unwrap();
let barrier = Arc::new(std::sync::Barrier::new(3));
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
let store = store.clone();
let old = old.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
)
.unwrap()
})
});
barrier.wait();
assert_eq!(
handles
.into_iter()
.map(|handle| handle.join().unwrap())
.filter(|won| *won)
.count(),
1
);
}
#[test]
fn active_segment_cas_allows_exactly_one_concurrent_winner() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let session_id = crate::new_session_id();
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
store
.write(&WorkerMetadata::new("agent", Some(old.clone())))
.unwrap();
let barrier = Arc::new(std::sync::Barrier::new(3));
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
let store = store.clone();
let old = old.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
)
.unwrap()
})
});
barrier.wait();
let winners = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.filter(|won| *won)
.count();
assert_eq!(winners, 1);
}
} }
+2 -8
View File
@@ -318,10 +318,7 @@ impl StandaloneHost {
} }
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> { pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
let command = protocol::WorkerCommandEnvelope::for_snapshot( let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
u64::MAX,
&self.handle.shared_state.snapshot(),
);
let _ = self.handle.send(Method::Shutdown { command }).await; let _ = self.handle.send(Method::Shutdown { command }).await;
let Some(shutdown) = self.shutdown.take() else { let Some(shutdown) = self.shutdown.take() else {
self.retain_lease(); self.retain_lease();
@@ -504,10 +501,7 @@ fn active_pointer(
} }
async fn stop_started_worker(started: BootstrappedWorker) { async fn stop_started_worker(started: BootstrappedWorker) {
let command = protocol::WorkerCommandEnvelope::for_snapshot( let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
u64::MAX,
&started.handle.shared_state.snapshot(),
);
let _ = started.handle.send(Method::Shutdown { command }).await; let _ = started.handle.send(Method::Shutdown { command }).await;
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await; let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
} }
+2
View File
@@ -300,11 +300,13 @@ mod tests {
target: root.path().to_path_buf(), target: root.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: output.path().to_path_buf(), target: output.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
+7 -2
View File
@@ -298,7 +298,12 @@ mod tests {
.execute(&inp.to_string(), Default::default()) .execute(&inp.to_string(), Default::default())
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err}"); match err {
assert!(msg.contains("modified externally"), "{msg}"); ToolError::ExecutionFailed(message) => assert_eq!(
message,
"The target file's content or existence changed since it was last observed; read the file again before retrying: a.txt"
),
other => panic!("expected execution failure, got {other:?}"),
}
} }
} }
+47 -1
View File
@@ -43,7 +43,8 @@ impl From<ToolsError> for ToolError {
| workdir::WorkdirError::Io { .. } | workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed | workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_), | workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_),
) => ToolError::ExecutionFailed(err.to_string()), ) => ToolError::ExecutionFailed(err.to_string()),
ToolsError::FileSystem(_) ToolsError::FileSystem(_)
| ToolsError::WorkdirSession(_) | ToolsError::WorkdirSession(_)
@@ -55,3 +56,48 @@ impl From<ToolsError> for ToolError {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use workdir::http::{WorkdirTransportError, WorkdirTransportErrorCode};
#[test]
fn local_workdir_content_conflict_is_retryable_execution_failure() {
let error = ToolError::from(ToolsError::WorkdirSession(
fs_operation::FsError::Conflict("src/main.rs".to_string()).into(),
));
match error {
ToolError::ExecutionFailed(message) => assert_eq!(
message,
"The target file's content or existence changed since it was last observed; read the file again before retrying: src/main.rs"
),
other => panic!("expected execution failure, got {other:?}"),
}
}
#[test]
fn remote_workdir_content_conflict_is_retryable_without_host_path() {
let transport = WorkdirTransportError::from_workdir_error(
&workdir::WorkdirError::Conflict("/runtime/private/checkout/src/main.rs".to_string()),
);
assert_eq!(transport.code, WorkdirTransportErrorCode::Conflict);
assert_eq!(
transport.message,
"The target file's content or existence changed since it was last observed; read the file again before retrying"
);
let error = ToolError::from(ToolsError::WorkdirSession(transport.into_workdir_error()));
match error {
ToolError::ExecutionFailed(message) => {
assert_eq!(
message,
"The target file's content or existence changed since it was last observed; read the file again before retrying"
);
assert!(!message.contains("/runtime/private"));
}
other => panic!("expected execution failure, got {other:?}"),
}
}
}
+7 -2
View File
@@ -219,8 +219,13 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err}"); match err {
assert!(msg.contains("modified externally"), "{msg}"); ToolError::ExecutionFailed(message) => assert_eq!(
message,
"The target file's content or existence changed since it was last observed; read the file again before retrying: a.txt"
),
other => panic!("expected execution failure, got {other:?}"),
}
} }
#[tokio::test] #[tokio::test]
+1
View File
@@ -40,6 +40,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
target: spill.path().to_path_buf(), target: spill.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
let scope = Scope::from_config(&config).unwrap(); let scope = Scope::from_config(&config).unwrap();
let fs: WorkdirSessionHandle = let fs: WorkdirSessionHandle =
+1
View File
@@ -27,6 +27,7 @@ fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
target: spill.to_path_buf(), target: spill.to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
Scope::from_config(&config).unwrap() Scope::from_config(&config).unwrap()
} }
+134 -50
View File
@@ -277,6 +277,9 @@ pub struct App {
/// Turn/protocol errors retained when a real `SegmentStart` replaces the /// Turn/protocol errors retained when a real `SegmentStart` replaces the
/// replayable conversation rows during segment rotation. /// replayable conversation rows during segment rotation.
run_error_messages: Vec<String>, run_error_messages: Vec<String>,
/// Current compaction identity/revision used to fence snapshot/live updates.
active_compaction: Option<(String, u64)>,
pub compaction_progress: Option<protocol::InFlightCompaction>,
/// Presentation-only Internal Worker projections keyed by session identity. /// Presentation-only Internal Worker projections keyed by session identity.
/// They are rendered in separate selectable views and never mixed into `blocks`. /// They are rendered in separate selectable views and never mixed into `blocks`.
pub internal_workers: Vec<InternalWorkerView>, pub internal_workers: Vec<InternalWorkerView>,
@@ -339,7 +342,7 @@ impl App {
Self { Self {
worker_name, worker_name,
connected: false, connected: false,
worker_state: WorkerStateSnapshot::initial(1), worker_state: WorkerStateSnapshot::initial(),
next_command_id: 1, next_command_id: 1,
worker_status: WorkerStatus::Idle, worker_status: WorkerStatus::Idle,
running: false, running: false,
@@ -364,6 +367,8 @@ impl App {
quit_confirm: None, quit_confirm: None,
shutdown_confirm: None, shutdown_confirm: None,
blocks: Vec::new(), blocks: Vec::new(),
active_compaction: None,
compaction_progress: None,
run_error_messages: Vec::new(), run_error_messages: Vec::new(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
selected_internal_worker_session_id: None, selected_internal_worker_session_id: None,
@@ -1123,25 +1128,14 @@ impl App {
let command_id = self let command_id = self
.next_command_id .next_command_id
.max(self.worker_state.last_command_id.saturating_add(1)); .max(self.worker_state.last_command_id.saturating_add(1));
let command = WorkerCommandEnvelope::for_snapshot(command_id, &self.worker_state); let command = WorkerCommandEnvelope::new(command_id);
self.next_command_id = command_id.saturating_add(1); self.next_command_id = command_id.saturating_add(1);
command command
} }
fn apply_worker_state_snapshot(&mut self, snapshot: &WorkerStateSnapshot) { fn apply_worker_state_snapshot(&mut self, snapshot: &WorkerStateSnapshot) {
match protocol::apply_worker_state_snapshot(&mut self.worker_state, snapshot) { self.worker_state = snapshot.clone();
Ok(protocol::WorkerStateSnapshotApply::Applied) => { self.set_worker_status(self.worker_state.catalog_status());
self.set_worker_status(self.worker_state.catalog_status());
}
Ok(
protocol::WorkerStateSnapshotApply::Duplicate
| protocol::WorkerStateSnapshotApply::Stale,
) => {}
Err(error) => self.handle_error(
ErrorCode::Internal,
format!("worker state stream rejected: {error}"),
),
}
} }
pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> { pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
@@ -1397,14 +1391,50 @@ impl App {
self.reset_run_state(); self.reset_run_state();
} }
} }
Event::CompactStart { .. } => { Event::CompactionProgress { compaction } => {
if self.last_streaming_compact_mut().is_none() { self.compaction_progress = compaction.filter(|progress| {
self.blocks.push(Block::Compact(CompactEvent::Streaming { matches!(
started_at: Instant::now(), (&self.worker_state.state, progress.trigger),
})); (
protocol::WorkerState::Busy(protocol::WorkerBusyState::Maintenance(
protocol::WorkerMaintenanceState::Compacting
)),
protocol::CompactionTrigger::Manual
) | (
protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(_)),
protocol::CompactionTrigger::PreRun
| protocol::CompactionTrigger::RequestThreshold
)
)
});
}
Event::CompactStart { lifecycle } => {
let should_apply = match &self.active_compaction {
None => true,
Some((id, revision)) => {
id == &lifecycle.compaction_id && lifecycle.revision > *revision
}
};
if should_apply {
self.active_compaction = Some((lifecycle.compaction_id, lifecycle.revision));
if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(),
}));
}
} }
} }
Event::CompactDone { lifecycle } => { Event::CompactDone { lifecycle } => {
let should_apply = match &self.active_compaction {
None => true,
Some((id, revision)) => {
id == &lifecycle.compaction_id && lifecycle.revision > *revision
}
};
if !should_apply {
return None;
}
self.active_compaction = None;
self.session_context_tokens = 0; self.session_context_tokens = 0;
let new_segment_id = lifecycle let new_segment_id = lifecycle
.new_segment_id .new_segment_id
@@ -1430,6 +1460,16 @@ impl App {
} }
} }
Event::CompactFailed { lifecycle } => { Event::CompactFailed { lifecycle } => {
let should_apply = match &self.active_compaction {
None => true,
Some((id, revision)) => {
id == &lifecycle.compaction_id && lifecycle.revision > *revision
}
};
if !should_apply {
return None;
}
self.active_compaction = None;
let error = lifecycle let error = lifecycle
.error .error
.unwrap_or_else(|| "compaction failed".to_string()); .unwrap_or_else(|| "compaction failed".to_string());
@@ -1470,9 +1510,9 @@ impl App {
} => { } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.pending_submissions = session.pending_submissions.clone(); self.pending_submissions = session.pending_submissions.clone();
self.apply_worker_state_snapshot(&state);
self.restore_snapshot(&session, greeting, in_flight); self.restore_snapshot(&session, greeting, in_flight);
self.replace_internal_worker_snapshots(internal_workers); self.replace_internal_worker_snapshots(internal_workers);
self.apply_worker_state_snapshot(&state);
} }
Event::InternalWorker { Event::InternalWorker {
worker, worker,
@@ -1485,9 +1525,19 @@ impl App {
Event::WorkerState { snapshot } => { Event::WorkerState { snapshot } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.apply_worker_state_snapshot(&snapshot); self.apply_worker_state_snapshot(&snapshot);
if let Some(progress) = self.compaction_progress.take() {
let _ = self.handle_worker_event(Event::CompactionProgress {
compaction: Some(progress),
});
}
} }
Event::CommandAcknowledged { acknowledgement } => { Event::CommandAcknowledged { acknowledgement } => {
self.apply_worker_state_snapshot(&acknowledgement.state); self.apply_worker_state_snapshot(&acknowledgement.state);
if let Some(progress) = self.compaction_progress.take() {
let _ = self.handle_worker_event(Event::CompactionProgress {
compaction: Some(progress),
});
}
} }
// Command telemetry is an operational Web Console surface. The // Command telemetry is an operational Web Console surface. The
// TUI continues to render the final Bash ToolResult from history. // TUI continues to render the final Bash ToolResult from history.
@@ -1614,6 +1664,7 @@ impl App {
} }
fn apply_in_flight_snapshot(&mut self, snapshot: InFlightSnapshot) { fn apply_in_flight_snapshot(&mut self, snapshot: InFlightSnapshot) {
let compaction = snapshot.compaction;
for block in snapshot.blocks { for block in snapshot.blocks {
match block { match block {
InFlightBlock::Text { text, finished } => { InFlightBlock::Text { text, finished } => {
@@ -1655,6 +1706,8 @@ impl App {
} }
} }
} }
self.active_compaction = None;
let _ = self.handle_worker_event(Event::CompactionProgress { compaction });
} }
fn append_assistant_text(&mut self, text: &str) { fn append_assistant_text(&mut self, text: &str) {
@@ -3587,8 +3640,6 @@ mod completion_flow_tests {
assert_eq!(app.worker_status, WorkerStatus::Idle); assert_eq!(app.worker_status, WorkerStatus::Idle);
let running = WorkerStateSnapshot { let running = WorkerStateSnapshot {
execution_generation: 1,
revision: 1,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running, protocol::WorkerRunState::Running,
)), )),
@@ -3605,11 +3656,9 @@ mod completion_flow_tests {
} }
#[test] #[test]
fn worker_state_events_and_acknowledgements_share_monotonic_application() { fn worker_state_events_and_acknowledgements_replace_full_state() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let running = WorkerStateSnapshot { let running = WorkerStateSnapshot {
execution_generation: 4,
revision: 3,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running, protocol::WorkerRunState::Running,
)), )),
@@ -3618,22 +3667,22 @@ mod completion_flow_tests {
app.handle_worker_event(Event::WorkerState { app.handle_worker_event(Event::WorkerState {
snapshot: running.clone(), snapshot: running.clone(),
}); });
app.handle_worker_event(Event::WorkerState {
snapshot: WorkerStateSnapshot {
revision: 2,
state: protocol::WorkerState::Idle,
..running.clone()
},
});
assert_eq!(app.worker_state, running); assert_eq!(app.worker_state, running);
let fresh_idle = WorkerStateSnapshot {
state: protocol::WorkerState::Idle,
last_command_id: 0,
};
app.handle_worker_event(Event::WorkerState {
snapshot: fresh_idle.clone(),
});
assert_eq!(app.worker_state, fresh_idle);
let paused = WorkerStateSnapshot { let paused = WorkerStateSnapshot {
revision: 4,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Paused, protocol::WorkerRunState::Paused,
)), )),
last_command_id: 3, last_command_id: 3,
..running.clone()
}; };
app.handle_worker_event(Event::CommandAcknowledged { app.handle_worker_event(Event::CommandAcknowledged {
acknowledgement: protocol::WorkerCommandAcknowledgement { acknowledgement: protocol::WorkerCommandAcknowledgement {
@@ -3644,17 +3693,6 @@ mod completion_flow_tests {
}, },
}); });
assert_eq!(app.worker_state, paused); assert_eq!(app.worker_state, paused);
app.handle_worker_event(Event::WorkerState {
snapshot: WorkerStateSnapshot {
state: protocol::WorkerState::Idle,
..paused.clone()
},
});
assert_eq!(app.worker_state, paused);
assert!(app.run_error_messages.iter().any(|message| {
message.contains("conflicting worker state snapshots at generation 4 revision 4")
}));
} }
#[test] #[test]
@@ -3773,6 +3811,7 @@ mod completion_flow_tests {
}, },
], ],
commands: Vec::new(), commands: Vec::new(),
compaction: None,
}, },
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -4222,6 +4261,7 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
}); });
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
lifecycle.revision = 2;
lifecycle.new_segment_id = Some(id.to_string()); lifecycle.new_segment_id = Some(id.to_string());
app.handle_worker_event(Event::CompactDone { lifecycle }); app.handle_worker_event(Event::CompactDone { lifecycle });
@@ -4243,6 +4283,7 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
}); });
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
lifecycle.revision = 2;
lifecycle.error = Some("provider 429".into()); lifecycle.error = Some("provider 429".into());
app.handle_worker_event(Event::CompactFailed { lifecycle }); app.handle_worker_event(Event::CompactFailed { lifecycle });
@@ -4256,6 +4297,52 @@ mod completion_flow_tests {
)); ));
} }
#[test]
fn compaction_progress_is_hidden_when_worker_state_is_inconsistent() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::CompactionProgress {
compaction: Some(protocol::InFlightCompaction {
phase: protocol::CompactionPhase::Preparing,
started_at_ms: 100,
trigger: protocol::CompactionTrigger::Manual,
}),
});
assert!(app.compaction_progress.is_none());
}
#[test]
fn snapshot_restores_and_runtime_clear_removes_compaction_progress() {
let mut app = App::new("test".into());
assert_eq!(app.worker_state.state, protocol::WorkerState::Idle);
let mut state = protocol::WorkerStateSnapshot::initial();
state.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Maintenance(
protocol::WorkerMaintenanceState::Compacting,
));
app.handle_worker_event(Event::Snapshot {
session: public_session(Vec::new()),
greeting: test_greeting(),
state,
in_flight: InFlightSnapshot {
compaction: Some(protocol::InFlightCompaction {
phase: protocol::CompactionPhase::Summarizing,
started_at_ms: 100,
trigger: protocol::CompactionTrigger::Manual,
}),
..InFlightSnapshot::default()
},
internal_workers: Vec::new(),
});
assert_eq!(compact_block_count(&app), 0);
assert_eq!(
app.compaction_progress.as_ref().map(|item| item.phase),
Some(protocol::CompactionPhase::Summarizing)
);
app.handle_worker_event(Event::CompactionProgress { compaction: None });
assert!(app.compaction_progress.is_none());
}
#[test] #[test]
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());
@@ -4282,10 +4369,7 @@ mod completion_flow_tests {
} }
fn test_worker_state(status: WorkerStatus) -> WorkerStateSnapshot { fn test_worker_state(status: WorkerStatus) -> WorkerStateSnapshot {
let mut snapshot = WorkerStateSnapshot::from(status); WorkerStateSnapshot::from(status)
snapshot.execution_generation = 1;
snapshot.revision = 1;
snapshot
} }
fn test_greeting() -> protocol::Greeting { fn test_greeting() -> protocol::Greeting {
+101 -12
View File
@@ -3,8 +3,9 @@ use std::io;
use std::time::Duration; use std::time::Duration;
use client::{ use client::{
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers, BackendRuntimeListTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
list_backend_workers, restore_backend_worker, BackendWorkerSummary, list_backend_stopped_workers, list_backend_workers,
restore_backend_worker,
}; };
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Frame; use ratatui::Frame;
@@ -84,17 +85,20 @@ pub(crate) async fn run(
let restore_target = target let restore_target = target
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone()) .runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
.map_err(|error| io::Error::other(error.to_string()))?; .map_err(|error| io::Error::other(error.to_string()))?;
restore_backend_worker(&restore_target) let restore = restore_backend_worker(&restore_target)
.await .await
.map_err(|error| { .map_err(|error| {
io::Error::other(format!( io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}", "failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id selected.runtime_id, selected.worker_id
)) ))
})? })?;
.result restored_worker(restore).map_err(|error| {
.worker io::Error::other(format!(
.unwrap_or(selected) "failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id
))
})?
} else { } else {
selected selected
}; };
@@ -105,6 +109,33 @@ pub(crate) async fn run(
} }
} }
fn restored_worker(response: BackendWorkerRestoreResponse) -> Result<BackendWorkerSummary, String> {
if response.result.state != BackendWorkerOperationState::Accepted {
let diagnostics = response
.result
.diagnostics
.iter()
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
.collect::<Vec<_>>()
.join("; ");
let state = match response.result.state {
BackendWorkerOperationState::Accepted => unreachable!(),
BackendWorkerOperationState::Rejected => "rejected",
BackendWorkerOperationState::Unsupported => "unsupported",
};
return Err(if diagnostics.is_empty() {
format!("restore was {state} without a diagnostic")
} else {
format!("restore was {state}: {diagnostics}")
});
}
response
.result
.worker
.ok_or_else(|| "restore was accepted without a Worker snapshot".to_string())
}
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) { fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone()))); workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
@@ -405,7 +436,8 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
mod tests { mod tests {
use super::*; use super::*;
use client::{ use client::{
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendDiagnostic, BackendDiagnosticSeverity, BackendWorkerCapabilitySummary,
BackendWorkerImplementationSummary, BackendWorkerRestoreResult,
BackendWorkerWorkspaceSummary, BackendWorkerWorkspaceSummary,
}; };
@@ -427,8 +459,6 @@ mod tests {
}, },
state: "idle".to_string(), state: "idle".to_string(),
worker_state: Some(protocol::WorkerStateSnapshot { worker_state: Some(protocol::WorkerStateSnapshot {
execution_generation: 1,
revision: 1,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running, protocol::WorkerRunState::Running,
)), )),
@@ -463,6 +493,67 @@ mod tests {
text_width(&text[..byte_offset]) text_width(&text[..byte_offset])
} }
fn restore_response(
state: BackendWorkerOperationState,
worker: Option<BackendWorkerSummary>,
diagnostics: Vec<BackendDiagnostic>,
) -> BackendWorkerRestoreResponse {
BackendWorkerRestoreResponse {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
worker_id: "worker-a".to_string(),
result: BackendWorkerRestoreResult {
state,
worker,
diagnostics,
},
}
}
#[test]
fn rejected_restore_surfaces_diagnostic_instead_of_attaching_selected_worker() {
let error = restored_worker(restore_response(
BackendWorkerOperationState::Rejected,
None,
vec![BackendDiagnostic {
code: "working_directory_not_found".to_string(),
severity: BackendDiagnosticSeverity::Error,
message: "working directory was not found".to_string(),
}],
))
.expect_err("rejected restore must not produce a Worker to attach");
assert_eq!(
error,
"restore was rejected: working_directory_not_found: working directory was not found"
);
}
#[test]
fn accepted_restore_requires_returned_worker_snapshot() {
let error = restored_worker(restore_response(
BackendWorkerOperationState::Accepted,
None,
Vec::new(),
))
.expect_err("accepted restore without a Worker must not attach the stale selection");
assert_eq!(error, "restore was accepted without a Worker snapshot");
}
#[test]
fn accepted_restore_returns_authoritative_worker_snapshot() {
let worker = worker("runtime-a", "worker-a", Some("builtin:companion"));
let restored = restored_worker(restore_response(
BackendWorkerOperationState::Accepted,
Some(worker.clone()),
Vec::new(),
))
.expect("accepted restore should return its Worker snapshot");
assert_eq!(restored, worker);
}
#[test] #[test]
fn worker_row_orders_and_simplifies_columns() { fn worker_row_orders_and_simplifies_columns() {
let mut worker = worker("runtime-a", "worker-b", Some("builtin:coder")); let mut worker = worker("runtime-a", "worker-b", Some("builtin:coder"));
@@ -500,8 +591,6 @@ mod tests {
short.display_name = short.label.clone(); short.display_name = short.label.clone();
short.state = "idle".to_string(); short.state = "idle".to_string();
short.worker_state = Some(protocol::WorkerStateSnapshot { short.worker_state = Some(protocol::WorkerStateSnapshot {
execution_generation: 1,
revision: 2,
state: protocol::WorkerState::Idle, state: protocol::WorkerState::Idle,
last_command_id: 0, last_command_id: 0,
}); });
+5 -5
View File
@@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
println!("Repository path/URI is required."); println!("Repository path/URI is required.");
return Ok(None); return Ok(None);
} }
let repository_name = prompt_line("Repository display name [Main]: ")?; let repository_key = prompt_line("Repository key [main]: ")?;
let default_ref = prompt_line("Default ref [repository default]: ")?; let default_ref = prompt_line("Default ref [repository default]: ")?;
let operation_key = format!( let operation_key = format!(
"tui-workspace-create-{}-{}", "tui-workspace-create-{}-{}",
@@ -204,11 +204,11 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
display_name, display_name,
repository: CreateBackendWorkspaceRepository { repository: CreateBackendWorkspaceRepository {
uri, uri,
display_name: Some(if repository_name.is_empty() { repository_key: if repository_key.is_empty() {
"Main".to_string() "main".to_string()
} else { } else {
repository_name repository_key
}), },
default_ref: (!default_ref.is_empty()).then_some(default_ref), default_ref: (!default_ref.is_empty()).then_some(default_ref),
}, },
})) }))
+1 -4
View File
@@ -410,10 +410,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.args.raw(); let _ = invocation.args.raw();
CommandExecution { CommandExecution {
method: Some(Method::Compact { method: Some(Method::Compact {
command: protocol::WorkerCommandEnvelope::for_snapshot( command: protocol::WorkerCommandEnvelope::new(0),
0,
&protocol::WorkerStateSnapshot::initial(1),
),
}), }),
diagnostics: vec![CommandDiagnostic::new("compact requested")], diagnostics: vec![CommandDiagnostic::new("compact requested")],
exit_command_mode: true, exit_command_mode: true,
+28 -6
View File
@@ -139,10 +139,17 @@ fn draw_run_status(frame: &mut Frame, app: &App, area: Rect) {
} }
fn run_status_line(app: &App, now: Instant) -> Line<'static> { fn run_status_line(app: &App, now: Instant) -> Line<'static> {
let elapsed = app let elapsed = if let Some(progress) = &app.compaction_progress {
.run_started_at let now_ms = std::time::SystemTime::now()
.and_then(|started_at| now.checked_duration_since(started_at)) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default(); .unwrap_or_default()
.as_millis() as u64;
std::time::Duration::from_millis(now_ms.saturating_sub(progress.started_at_ms))
} else {
app.run_started_at
.and_then(|started_at| now.checked_duration_since(started_at))
.unwrap_or_default()
};
let spinner_index = let spinner_index =
((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len(); ((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len();
let request_label = if app.run_requests == 1 { let request_label = if app.run_requests == 1 {
@@ -151,7 +158,7 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
format!("{} reqs", app.run_requests) format!("{} reqs", app.run_requests)
}; };
Line::from(vec![ let mut spans = vec![
Span::styled( Span::styled(
RUN_SPINNER_FRAMES[spinner_index], RUN_SPINNER_FRAMES[spinner_index],
Style::default() Style::default()
@@ -159,6 +166,20 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw(" "), Span::raw(" "),
];
if let Some(progress) = &app.compaction_progress {
let phase = match progress.phase {
protocol::CompactionPhase::Preparing => "preparing",
protocol::CompactionPhase::Summarizing => "summarizing",
protocol::CompactionPhase::Committing => "committing",
};
spans.push(Span::styled(
format!("Compacting · {phase}"),
Style::default().fg(Color::Cyan),
));
spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray)));
}
spans.extend([
Span::styled( Span::styled(
fmt_run_elapsed(elapsed.as_secs()), fmt_run_elapsed(elapsed.as_secs()),
Style::default().fg(Color::Gray), Style::default().fg(Color::Gray),
@@ -177,7 +198,8 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
fmt_tokens(app.run_output_tokens), fmt_tokens(app.run_output_tokens),
Style::default().fg(Color::Yellow), Style::default().fg(Color::Yellow),
), ),
]) ]);
Line::from(spans)
} }
fn fmt_run_elapsed(secs: u64) -> String { fn fmt_run_elapsed(secs: u64) -> String {
+59 -4
View File
@@ -11,7 +11,8 @@ use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId, ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
WorkdirSessionCapabilities, WriteRequest, WriteResult, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
WriteRequest, WriteResult,
}; };
/// Opaque Runtime-owned identifier for one ephemeral Workdir session. /// Opaque Runtime-owned identifier for one ephemeral Workdir session.
@@ -55,6 +56,8 @@ pub struct OpenWorkdirSessionResponse {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "request", rename_all = "snake_case")] #[serde(tag = "operation", content = "request", rename_all = "snake_case")]
pub enum WorkdirSessionOperation { pub enum WorkdirSessionOperation {
AuthorizeScope(WorkdirScopeAuthorizationRequest),
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
Stat(StatRequest), Stat(StatRequest),
Read(ReadRequest), Read(ReadRequest),
Write(WriteRequest), Write(WriteRequest),
@@ -79,6 +82,8 @@ pub struct WorkdirSessionOperationRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "result", rename_all = "snake_case")] #[serde(tag = "operation", content = "result", rename_all = "snake_case")]
pub enum WorkdirSessionOperationResult { pub enum WorkdirSessionOperationResult {
AuthorizeScope,
ScopeRulesOverlap { overlaps: bool },
Stat(StatResult), Stat(StatResult),
Read(ReadResult), Read(ReadResult),
Write(WriteResult), Write(WriteResult),
@@ -169,7 +174,10 @@ impl WorkdirTransportError {
use WorkdirTransportErrorCode as Code; use WorkdirTransportErrorCode as Code;
let (code, message) = match error { let (code, message) = match error {
WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"), WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"),
WorkdirError::Conflict(_) => (Code::Conflict, "Workdir content changed"), WorkdirError::Conflict(_) => (
Code::Conflict,
"The target file's content or existence changed since it was last observed; read the file again before retrying",
),
WorkdirError::Unsupported(capability) => { WorkdirError::Unsupported(capability) => {
return Self { return Self {
code: Code::Unsupported, code: Code::Unsupported,
@@ -447,6 +455,32 @@ mod client {
self.capabilities self.capabilities
} }
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
match self
.operate(WorkdirSessionOperation::AuthorizeScope(request))
.await?
{
WorkdirSessionOperationResult::AuthorizeScope => Ok(()),
_ => Err(Self::mismatch("authorize_scope")),
}
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
match self
.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))
.await?
{
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
_ => Err(Self::mismatch("scope_rules_overlap")),
}
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Stat(request)).await? { match self.operate(WorkdirSessionOperation::Stat(request)).await? {
WorkdirSessionOperationResult::Stat(result) => Ok(result), WorkdirSessionOperationResult::Stat(result) => Ok(result),
@@ -642,7 +676,7 @@ mod tests {
( (
WorkdirTransportErrorCode::Conflict, WorkdirTransportErrorCode::Conflict,
409, 409,
"modified externally", "The target file's content or existence changed since it was last observed",
), ),
(WorkdirTransportErrorCode::Unsupported, 400, "unsupported"), (WorkdirTransportErrorCode::Unsupported, 400, "unsupported"),
(WorkdirTransportErrorCode::Denied, 403, "denied"), (WorkdirTransportErrorCode::Denied, 403, "denied"),
@@ -684,7 +718,12 @@ mod tests {
] { ] {
let transport = WorkdirTransportError { let transport = WorkdirTransportError {
code, code,
message: "safe provider message".to_string(), message: if code == WorkdirTransportErrorCode::Conflict {
"The target file's content or existence changed since it was last observed; read the file again before retrying"
.to_string()
} else {
"safe provider message".to_string()
},
}; };
assert_eq!(code.http_status(), expected_status); assert_eq!(code.http_status(), expected_status);
let workdir_error = transport.clone().into_workdir_error(); let workdir_error = transport.clone().into_workdir_error();
@@ -751,5 +790,21 @@ mod tests {
transport.into_workdir_error(), transport.into_workdir_error(),
WorkdirError::Io { .. } WorkdirError::Io { .. }
)); ));
let error = WorkdirError::Conflict(
"The target file's content or existence changed since it was last observed; read the file again before retrying: /secret/runtime/root/file"
.to_string(),
);
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, WorkdirTransportErrorCode::Conflict);
assert_eq!(
transport.message,
"The target file's content or existence changed since it was last observed; read the file again before retrying"
);
assert!(!transport.message.contains("/secret"));
assert_eq!(
transport.into_workdir_error().to_string(),
"The target file's content or existence changed since it was last observed; read the file again before retrying"
);
} }
} }
+35 -4
View File
@@ -28,8 +28,9 @@ pub use local::{
}; };
pub use operation::*; pub use operation::*;
pub use scope::{ pub use scope::{
ReadOnlyWorkdirSession, WorkdirScopeLease, WorkdirToolBroker, WorkdirToolScope, ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease,
WorkdirToolScopePermission, WorkdirToolScopeRule, WorkdirScopeOverlapRequest, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
WorkdirToolScopeRule,
}; };
/// Persistent, opaque identity of one materialized Workdir. /// Persistent, opaque identity of one materialized Workdir.
@@ -147,6 +148,34 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
fn workdir(&self) -> &Workdir; fn workdir(&self) -> &Workdir;
fn capabilities(&self) -> WorkdirSessionCapabilities; fn capabilities(&self) -> WorkdirSessionCapabilities;
/// Validate an attenuated filesystem rule at the provider boundary without
/// exposing the resolved host path. Providers that cannot resolve symbolic
/// links must reject resolved-policy checks rather than downgrade them.
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
if request.rules.iter().any(|rule| {
rule.symlink_policy == manifest::SymlinkPolicy::Logical
&& scope::rule_allows_path(rule, &request.path, request.permission)
}) {
Ok(())
} else {
Err(WorkdirError::Denied(
"Workdir provider cannot establish resolved scope authority".to_string(),
))
}
}
async fn scope_rules_overlap(
&self,
_request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
Err(WorkdirError::Denied(
"Workdir provider cannot compare resolved scope authority".to_string(),
))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>; async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>; async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>; async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
@@ -206,7 +235,7 @@ pub enum WorkdirError {
#[error("Workdir transport failed: {0}")] #[error("Workdir transport failed: {0}")]
Transport(String), Transport(String),
#[error("Workdir content was modified externally before the operation could be applied: {0}")] #[error("{0}")]
Conflict(String), Conflict(String),
#[error("unknown Workdir session command: {0}")] #[error("unknown Workdir session command: {0}")]
@@ -320,7 +349,9 @@ impl From<fs_operation::FsError> for WorkdirError {
fs_operation::FsError::SymlinkTargetIsDirectory { path, target } => { fs_operation::FsError::SymlinkTargetIsDirectory { path, target } => {
Self::SymlinkTargetIsDirectory { path, target } Self::SymlinkTargetIsDirectory { path, target }
} }
fs_operation::FsError::Conflict(message) => Self::Conflict(message), fs_operation::FsError::Conflict(path) => Self::Conflict(format!(
"The target file's content or existence changed since it was last observed; read the file again before retrying: {path}"
)),
fs_operation::FsError::InvalidGlob(message) => Self::InvalidGlob(message), fs_operation::FsError::InvalidGlob(message) => Self::InvalidGlob(message),
fs_operation::FsError::InvalidRegex(message) => Self::InvalidRegex(message), fs_operation::FsError::InvalidRegex(message) => Self::InvalidRegex(message),
fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message), fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message),
+310 -14
View File
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait; use async_trait::async_trait;
use manifest::{Scope, SharedScope}; use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::{Mutex, broadcast, watch}; use tokio::sync::{Mutex, broadcast, watch};
@@ -28,8 +28,10 @@ use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirSession, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
WorkdirSessionCapabilities, WorkdirSessionCapability, WriteRequest, WriteResult, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
WriteResult,
}; };
#[cfg(test)] #[cfg(test)]
use crate::{EntryKind, WriteOutcome}; use crate::{EntryKind, WriteOutcome};
@@ -211,6 +213,52 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
fn is_writable(&self, path: &Path) -> bool { fn is_writable(&self, path: &Path) -> bool {
self.0.is_writable(path) self.0.is_writable(path)
} }
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
matches!(
self.0.permission_at_paths(logical, resolved),
Some(Permission::Read | Permission::Write)
)
}
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
self.0.permission_at_paths(logical, resolved) == Some(Permission::Write)
}
}
fn path_sets_overlap(
left: &Path,
left_recursive: bool,
right: &Path,
right_recursive: bool,
) -> bool {
match (left_recursive, right_recursive) {
(true, true) => left.starts_with(right) || right.starts_with(left),
(true, false) => {
right.starts_with(left)
|| left == right
|| left.parent().is_some_and(|parent| parent == right)
}
(false, true) => {
left.starts_with(right)
|| left == right
|| right.parent().is_some_and(|parent| parent == left)
}
(false, false) => {
left == right
|| left.parent().is_some_and(|parent| parent == right)
|| right.parent().is_some_and(|parent| parent == left)
}
}
}
fn rule_targets(
root: &Path,
rule: &crate::WorkdirToolScopeRule,
) -> std::io::Result<(PathBuf, PathBuf)> {
let logical = root.join(rule.target.as_str());
let resolved = fs_operation::resolve_access_path(&logical)?;
Ok((logical, resolved))
} }
#[derive(Debug)] #[derive(Debug)]
@@ -397,6 +445,11 @@ impl LocalWorkdirSession {
return Err(WorkdirError::RelativePath(path.to_path_buf())); return Err(WorkdirError::RelativePath(path.to_path_buf()));
} }
let symlink = first_symlink(path); let symlink = first_symlink(path);
if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(broken_symlink_error(path, info));
}
let scope = self.inner.scope.load(); let scope = self.inner.scope.load();
if !scope.is_readable(path) { if !scope.is_readable(path) {
return Err(symlink_out_of_scope_or_plain( return Err(symlink_out_of_scope_or_plain(
@@ -406,11 +459,6 @@ impl LocalWorkdirSession {
&scope, &scope,
)); ));
} }
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(broken_symlink_error(path, info));
}
}
let meta = std::fs::metadata(path).map_err(|e| match e.kind() { let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()), std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()),
_ => WorkdirError::io(path, e), _ => WorkdirError::io(path, e),
@@ -556,6 +604,86 @@ impl WorkdirSession for LocalWorkdirSession {
self.inner.capabilities self.inner.capabilities
} }
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
self.ensure_open()?;
let logical = self.inner.root.join(request.path.as_str());
let resolved = fs_operation::resolve_access_path(&logical)
.map_err(|error| WorkdirError::io(&logical, error))?;
let parent_permission = self
.inner
.scope
.load()
.permission_at_paths(&logical, &resolved);
let parent_allows = match request.permission {
WorkdirToolScopePermission::Read => matches!(
parent_permission,
Some(Permission::Read | Permission::Write)
),
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
};
if !parent_allows {
return Err(WorkdirError::Denied(format!(
"Workdir path `{}` exceeds the provider attachment scope",
request.path
)));
}
let allowed = request.rules.iter().any(|rule| {
if request.permission == WorkdirToolScopePermission::Write
&& rule.permission != WorkdirToolScopePermission::Write
{
return false;
}
let logical_target = self.inner.root.join(rule.target.as_str());
let (candidate, target) = match rule.symlink_policy {
SymlinkPolicy::Logical => (logical.as_path(), logical_target),
SymlinkPolicy::Resolved => {
let Ok(target) = fs_operation::resolve_access_path(&logical_target) else {
return false;
};
(resolved.as_path(), target)
}
};
if rule.recursive {
candidate.starts_with(target)
} else {
candidate == target || candidate.parent() == Some(target.as_path())
}
});
if allowed {
Ok(())
} else {
Err(WorkdirError::Denied(format!(
"Workdir path `{}` is outside the provider-resolved delegated scope",
request.path
)))
}
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.ensure_open()?;
let (left_logical, left_resolved) = rule_targets(&self.inner.root, &request.left)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
let (right_logical, right_resolved) = rule_targets(&self.inner.root, &request.right)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
Ok(path_sets_overlap(
&left_logical,
request.left.recursive,
&right_logical,
request.right.recursive,
) || path_sets_overlap(
&left_resolved,
request.left.recursive,
&right_resolved,
request.right.recursive,
))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?; self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone(); let logical = request.path.clone();
@@ -1334,6 +1462,22 @@ mod tests {
) )
} }
fn make_logical_fs(dir: &TempDir) -> LocalWorkdirSession {
LocalWorkdirSession::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: Vec::new(),
})
.unwrap(),
dir.path().to_path_buf(),
)
}
#[tokio::test] #[tokio::test]
async fn logical_provider_operations_cover_read_write_edit_stat_and_list() { async fn logical_provider_operations_cover_read_write_edit_stat_and_list() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1425,7 +1569,10 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(error, WorkdirError::Conflict(_))); assert_eq!(
error.to_string(),
"The target file's content or existence changed since it was last observed; read the file again before retrying: notes/item.txt"
);
std::fs::remove_file(dir.path().join("notes/item.txt")).unwrap(); std::fs::remove_file(dir.path().join("notes/item.txt")).unwrap();
let error = WorkdirSession::write( let error = WorkdirSession::write(
@@ -1438,7 +1585,38 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(error, WorkdirError::Conflict(_))); assert_eq!(
error.to_string(),
"The target file's content or existence changed since it was last observed; read the file again before retrying: notes/item.txt"
);
}
#[tokio::test]
async fn write_conflicts_when_observed_absence_becomes_a_file() {
let dir = TempDir::new().unwrap();
let workdir = make_fs(&dir);
let path = WorkdirPath::new("race.txt").unwrap();
let error = WorkdirSession::stat(&workdir, StatRequest { path: path.clone() })
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::NotFound(_)));
std::fs::write(dir.path().join("race.txt"), "created externally").unwrap();
let error = WorkdirSession::write(
&workdir,
WriteRequest {
path,
content: b"worker content".to_vec(),
expected_hash: None,
},
)
.await
.unwrap_err();
assert_eq!(
error.to_string(),
"The target file's content or existence changed since it was last observed; read the file again before retrying: race.txt"
);
} }
#[tokio::test] #[tokio::test]
@@ -1533,6 +1711,102 @@ mod tests {
assert_eq!(read.bytes, b"persisted"); assert_eq!(read.bytes, b"persisted");
} }
#[cfg(unix)]
#[tokio::test]
async fn resolved_provider_scope_rejects_read_and_write_through_outside_alias() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let target = outside.path().join("target.txt");
fs::write(&target, "secret").unwrap();
symlink(&target, root.path().join("alias.txt")).unwrap();
symlink(outside.path(), root.path().join("alias-dir")).unwrap();
let workdir = make_fs(&root);
assert!(matches!(
WorkdirSession::read(
&workdir,
ReadRequest {
path: WorkdirPath::new("alias.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
}
)
.await,
Err(WorkdirError::SymlinkOutOfScope { .. })
));
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias.txt").unwrap(),
content: b"changed".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::SymlinkOutOfScope { .. })
));
assert_eq!(fs::read_to_string(target).unwrap(), "secret");
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias-dir/new.txt").unwrap(),
content: b"new".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::ReadOnly(_))
));
assert!(!outside.path().join("new.txt").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn resolved_deny_blocks_missing_write_through_logical_alias() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
symlink(outside.path(), root.path().join("alias")).unwrap();
let workdir = LocalWorkdirSession::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: vec![ScopeRule {
target: outside.path().join("blocked.txt"),
permission: Permission::Read,
recursive: false,
symlink_policy: SymlinkPolicy::Logical,
}],
})
.unwrap(),
root.path().to_path_buf(),
);
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias/blocked.txt").unwrap(),
content: b"blocked".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::ReadOnly(_))
));
assert!(!outside.path().join("blocked.txt").exists());
}
#[tokio::test] #[tokio::test]
async fn capability_boundary_rejects_direct_unsupported_operation() { async fn capability_boundary_rejects_direct_unsupported_operation() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1645,7 +1919,7 @@ mod tests {
let link = dir.path().join("outside-repo.txt"); let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); let fs = make_logical_fs(&dir);
assert_eq!(fs.read_bytes(&link).unwrap(), b"secret"); assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
} }
@@ -1748,7 +2022,7 @@ mod tests {
let link = dir.path().join("outside-repo.txt"); let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); let fs = make_logical_fs(&dir);
fs.write(&link, b"new").unwrap(); fs.write(&link, b"new").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new"); assert_eq!(fs::read(&target).unwrap(), b"new");
assert!( assert!(
@@ -1778,11 +2052,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: vec![ScopeRule { deny: vec![ScopeRule {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}; };
let scope = Scope::from_config(&cfg).unwrap(); let scope = Scope::from_config(&cfg).unwrap();
@@ -1846,6 +2122,7 @@ mod tests {
target: extra.path().to_path_buf(), target: extra.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1882,6 +2159,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1918,6 +2196,7 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1935,14 +2214,14 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn provider_uses_logical_paths_through_symlinked_directories() { async fn provider_uses_explicit_logical_policy_through_symlinked_directories() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap(); let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("worker.json"), "scope-needle\n").unwrap(); std::fs::write(outside.path().join("worker.json"), "scope-needle\n").unwrap();
symlink(outside.path(), dir.path().join("yoi.local")).unwrap(); symlink(outside.path(), dir.path().join("yoi.local")).unwrap();
let workdir = make_fs(&dir); let workdir = make_logical_fs(&dir);
let read = WorkdirSession::read( let read = WorkdirSession::read(
&workdir, &workdir,
@@ -1956,6 +2235,19 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(read.bytes, b"scope-needle\n"); assert_eq!(read.bytes, b"scope-needle\n");
let list = WorkdirSession::list(
&workdir,
ListRequest {
path: WorkdirPath::new("yoi.local").unwrap(),
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
list.entries[0].path,
WorkdirPath::new("yoi.local/worker.json").unwrap()
);
let glob = WorkdirSession::glob( let glob = WorkdirSession::glob(
&workdir, &workdir,
GlobRequest { GlobRequest {
@@ -2084,11 +2376,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: spill.path().to_path_buf(), target: spill.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
@@ -2165,11 +2459,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: spill.path().to_path_buf(), target: spill.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
+534 -101
View File
@@ -8,6 +8,7 @@ use fs_operation::{
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
}; };
use manifest::SymlinkPolicy;
use tokio::sync::broadcast; use tokio::sync::broadcast;
const MAX_SCOPED_COMMANDS: usize = 16; const MAX_SCOPED_COMMANDS: usize = 16;
@@ -31,6 +32,25 @@ pub struct WorkdirToolScopeRule {
pub target: FsPath, pub target: FsPath,
pub permission: WorkdirToolScopePermission, pub permission: WorkdirToolScopePermission,
pub recursive: bool, pub recursive: bool,
#[serde(default)]
pub symlink_policy: SymlinkPolicy,
}
/// Provider-side check for one operation under an attenuated tool scope.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkdirScopeAuthorizationRequest {
pub rules: Vec<WorkdirToolScopeRule>,
pub path: FsPath,
pub permission: WorkdirToolScopePermission,
}
/// Provider-side overlap comparison that keeps resolved host paths private.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkdirScopeOverlapRequest {
pub left: WorkdirToolScopeRule,
pub right: WorkdirToolScopeRule,
} }
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -70,6 +90,7 @@ impl WorkdirToolBroker {
capabilities, capabilities,
validity: SessionValidity::root(), validity: SessionValidity::root(),
child_write_leases: Mutex::new(HashMap::new()), child_write_leases: Mutex::new(HashMap::new()),
scope_lock: tokio::sync::Mutex::new(()),
next_lease_id: AtomicU64::new(1), next_lease_id: AtomicU64::new(1),
close_lock: Arc::new(tokio::sync::Mutex::new(())), close_lock: Arc::new(tokio::sync::Mutex::new(())),
owned_commands: Arc::new(Mutex::new(HashSet::new())), owned_commands: Arc::new(Mutex::new(HashSet::new())),
@@ -310,6 +331,7 @@ struct ScopedWorkdirSession {
capabilities: WorkdirSessionCapabilities, capabilities: WorkdirSessionCapabilities,
validity: Arc<SessionValidity>, validity: Arc<SessionValidity>,
child_write_leases: Mutex<HashMap<u64, ActiveWriteLease>>, child_write_leases: Mutex<HashMap<u64, ActiveWriteLease>>,
scope_lock: tokio::sync::Mutex<()>,
next_lease_id: AtomicU64, next_lease_id: AtomicU64,
close_lock: Arc<tokio::sync::Mutex<()>>, close_lock: Arc<tokio::sync::Mutex<()>>,
owned_commands: Arc<Mutex<HashSet<String>>>, owned_commands: Arc<Mutex<HashSet<String>>>,
@@ -374,9 +396,6 @@ impl ScopedWorkdirSession {
))); )));
} }
} }
if permission == WorkdirToolScopePermission::Write {
self.ensure_parent_write_available(path)?;
}
Ok(()) Ok(())
} }
@@ -463,38 +482,86 @@ impl ScopedWorkdirSession {
}); });
} }
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> { async fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
let mut leases = self let active_write_rules = {
.child_write_leases let mut leases = self
.lock() .child_write_leases
.expect("Workdir tool scope lease mutex poisoned"); .lock()
leases.retain(|_, lease| { .expect("Workdir tool scope lease mutex poisoned");
lease leases.retain(|_, lease| {
.validity lease
.upgrade() .validity
.is_some_and(|validity| validity.is_active())
|| lease
.cleanup_pending
.upgrade() .upgrade()
.is_some_and(|pending| pending.load(Ordering::Acquire)) .is_some_and(|validity| validity.is_active())
}); || lease
if leases.values().any(|lease| { .cleanup_pending
lease.rules.iter().any(|rule| { .upgrade()
rule.permission == WorkdirToolScopePermission::Write .is_some_and(|pending| pending.load(Ordering::Acquire))
&& rule_allows_path(rule, path, WorkdirToolScopePermission::Write) });
}) leases
}) { .values()
Err(WorkdirError::Denied(format!( .flat_map(|lease| lease.rules.iter().cloned())
"logical workdir path `{path}` is leased to child Workdir tools" .collect::<Vec<_>>()
))) };
} else { let requested = WorkdirToolScopeRule {
Ok(()) target: path.clone(),
permission: WorkdirToolScopePermission::Write,
recursive: false,
symlink_policy: SymlinkPolicy::Resolved,
};
for active in active_write_rules {
if self
.source
.scope_rules_overlap(WorkdirScopeOverlapRequest {
left: active,
right: requested.clone(),
})
.await?
{
return Err(WorkdirError::Denied(format!(
"path `{path}` is leased to child Workdir tools"
)));
}
} }
Ok(())
} }
fn resolve_operation_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> { async fn ensure_scope_targets_are_authorized(
&self,
rules: &[WorkdirToolScopeRule],
) -> Result<(), WorkdirError> {
for rule in rules {
self.source
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules: rules.to_vec(),
path: rule.target.clone(),
permission: rule.permission,
})
.await?;
}
Ok(())
}
async fn resolve_operation_path(
&self,
path: &FsPath,
permission: WorkdirToolScopePermission,
) -> Result<FsPath, WorkdirError> {
self.ensure_active()?; self.ensure_active()?;
self.resolve_path(path) let resolved = self.resolve_path(path)?;
if permission == WorkdirToolScopePermission::Write {
self.ensure_parent_write_available(&resolved).await?;
}
if let Some(rules) = self.scope.as_ref() {
self.source
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules: rules.clone(),
path: resolved.clone(),
permission,
})
.await?;
}
Ok(resolved)
} }
fn validate_scope( fn validate_scope(
@@ -571,6 +638,7 @@ impl ScopedWorkdirSession {
self: &Arc<Self>, self: &Arc<Self>,
request: WorkdirToolScope, request: WorkdirToolScope,
) -> Result<WorkdirScopeLease, WorkdirError> { ) -> Result<WorkdirScopeLease, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
let capabilities = self.validate_scope(&request.rules, request.command)?; let capabilities = self.validate_scope(&request.rules, request.command)?;
if !request if !request
.rules .rules
@@ -582,53 +650,66 @@ impl ScopedWorkdirSession {
request.cwd request.cwd
))); )));
} }
self.ensure_scope_targets_are_authorized(&request.rules)
.await?;
let validity = SessionValidity::child(self.validity.clone()); let validity = SessionValidity::child(self.validity.clone());
let cleanup_pending = Arc::new(AtomicBool::new(true)); let cleanup_pending = Arc::new(AtomicBool::new(true));
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed); let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
if request let write_rules = request
.rules .rules
.iter() .iter()
.any(|rule| rule.permission == WorkdirToolScopePermission::Write) .filter(|rule| rule.permission == WorkdirToolScopePermission::Write)
{ .cloned()
let mut leases = self .collect::<Vec<_>>();
.child_write_leases if !write_rules.is_empty() {
.lock() let active_write_rules = {
.expect("Workdir tool scope lease mutex poisoned"); let mut leases = self
leases.retain(|_, lease| { .child_write_leases
lease .lock()
.validity .expect("Workdir tool scope lease mutex poisoned");
.upgrade() leases.retain(|_, lease| {
.is_some_and(|validity| validity.is_active())
|| lease
.cleanup_pending
.upgrade()
.is_some_and(|pending| pending.load(Ordering::Acquire))
});
let requested_write_rules = request
.rules
.iter()
.filter(|rule| rule.permission == WorkdirToolScopePermission::Write);
for requested in requested_write_rules {
if leases.values().any(|lease| {
lease lease
.rules .validity
.iter() .upgrade()
.any(|active| rules_overlap(active, requested)) .is_some_and(|validity| validity.is_active())
}) { || lease
return Err(WorkdirError::Denied(format!( .cleanup_pending
"scoped write path `{}` overlaps an active child scope", .upgrade()
requested.target .is_some_and(|pending| pending.load(Ordering::Acquire))
))); });
leases
.values()
.flat_map(|lease| lease.rules.iter().cloned())
.collect::<Vec<_>>()
};
for requested in &write_rules {
for active in &active_write_rules {
if self
.source
.scope_rules_overlap(WorkdirScopeOverlapRequest {
left: active.clone(),
right: requested.clone(),
})
.await?
{
return Err(WorkdirError::Denied(format!(
"scoped write path `{}` overlaps an active child scope after provider resolution",
requested.target
)));
}
} }
} }
leases.insert( self.child_write_leases
id, .lock()
ActiveWriteLease { .expect("Workdir tool scope lease mutex poisoned")
validity: Arc::downgrade(&validity), .insert(
cleanup_pending: Arc::downgrade(&cleanup_pending), id,
rules: request.rules.clone(), ActiveWriteLease {
}, validity: Arc::downgrade(&validity),
); cleanup_pending: Arc::downgrade(&cleanup_pending),
rules: write_rules,
},
);
} }
let owned_commands = Arc::new(Mutex::new(HashSet::new())); let owned_commands = Arc::new(Mutex::new(HashSet::new()));
let pending_command_events = Arc::new(Mutex::new(HashMap::new())); let pending_command_events = Arc::new(Mutex::new(HashMap::new()));
@@ -654,6 +735,7 @@ impl ScopedWorkdirSession {
capabilities, capabilities,
validity: validity.clone(), validity: validity.clone(),
child_write_leases: Mutex::new(HashMap::new()), child_write_leases: Mutex::new(HashMap::new()),
scope_lock: tokio::sync::Mutex::new(()),
next_lease_id: AtomicU64::new(1), next_lease_id: AtomicU64::new(1),
close_lock: close_lock.clone(), close_lock: close_lock.clone(),
owned_commands, owned_commands,
@@ -692,49 +774,65 @@ impl WorkdirSession for ScopedWorkdirSession {
} }
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path)?; let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?; self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path; request.path = path;
self.source.stat(request).await self.source.stat(request).await
} }
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> { async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path)?; let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?; self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path; request.path = path;
self.source.read(request).await self.source.read(request).await
} }
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path)?; let _scope_guard = self.scope_lock.lock().await;
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Write)
.await?;
self.ensure_write(&path, WorkdirSessionCapability::Write)?; self.ensure_write(&path, WorkdirSessionCapability::Write)?;
request.path = path; request.path = path;
self.source.write(request).await self.source.write(request).await
} }
async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> { async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path)?; let _scope_guard = self.scope_lock.lock().await;
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Write)
.await?;
self.ensure_write(&path, WorkdirSessionCapability::Edit)?; self.ensure_write(&path, WorkdirSessionCapability::Edit)?;
request.path = path; request.path = path;
self.source.edit(request).await self.source.edit(request).await
} }
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> { async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path)?; let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?; self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path; request.path = path;
self.source.list(request).await self.source.list(request).await
} }
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> { async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path)?; let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Glob)?; self.ensure_read(&path, WorkdirSessionCapability::Glob)?;
request.path = path; request.path = path;
self.source.glob(request).await self.source.glob(request).await
} }
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> { async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path)?; let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Grep)?; self.ensure_read(&path, WorkdirSessionCapability::Grep)?;
request.path = path; request.path = path;
self.source.grep(request).await self.source.grep(request).await
@@ -943,6 +1041,23 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
WorkdirSessionCapabilities::READ_ONLY WorkdirSessionCapabilities::READ_ONLY
} }
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
if request.permission == WorkdirToolScopePermission::Write {
return Err(WorkdirError::Denied("read-only workdir session".into()));
}
self.inner.authorize_scope_path(request).await
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.inner.scope_rules_overlap(request).await
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.inner.stat(request).await self.inner.stat(request).await
} }
@@ -1099,14 +1214,7 @@ fn unix_timestamp_ms() -> u64 {
.min(u128::from(u64::MAX)) as u64 .min(u128::from(u64::MAX)) as u64
} }
fn rules_overlap(left: &WorkdirToolScopeRule, right: &WorkdirToolScopeRule) -> bool { pub(crate) fn rule_allows_path(
left.permission == WorkdirToolScopePermission::Write
&& right.permission == WorkdirToolScopePermission::Write
&& (rule_allows_path(left, &right.target, WorkdirToolScopePermission::Write)
|| rule_allows_path(right, &left.target, WorkdirToolScopePermission::Write))
}
fn rule_allows_path(
rule: &WorkdirToolScopeRule, rule: &WorkdirToolScopeRule,
path: &FsPath, path: &FsPath,
required: WorkdirToolScopePermission, required: WorkdirToolScopePermission,
@@ -1138,6 +1246,11 @@ fn rule_contains_rule(parent: &WorkdirToolScopeRule, child: &WorkdirToolScopeRul
{ {
return false; return false;
} }
// Resolved < Logical: a child may narrow a Logical grant to Resolved,
// but cannot turn a Resolved parent grant into logical-alias authority.
if parent.symlink_policy < child.symlink_policy {
return false;
}
if !path_in_rule(parent, &child.target) { if !path_in_rule(parent, &child.target) {
return false; return false;
} }
@@ -1168,6 +1281,7 @@ mod tests {
target: root.to_path_buf(), target: root.to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}) })
@@ -1182,12 +1296,141 @@ mod tests {
))) )))
} }
#[derive(Debug)]
struct BlockingWriteSession {
inner: Arc<LocalWorkdirSession>,
entered: tokio::sync::watch::Sender<bool>,
release: Arc<tokio::sync::Notify>,
block_next_write: std::sync::atomic::AtomicBool,
}
#[async_trait]
impl WorkdirSession for BlockingWriteSession {
fn workdir(&self) -> &Workdir {
self.inner.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.inner.capabilities()
}
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
self.inner.authorize_scope_path(request).await
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.inner.scope_rules_overlap(request).await
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.inner.stat(request).await
}
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
self.inner.read(request).await
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
if self.block_next_write.swap(false, Ordering::AcqRel) {
let _ = self.entered.send(true);
self.release.notified().await;
}
WorkdirSession::write(self.inner.as_ref(), request).await
}
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
self.inner.edit(request).await
}
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
self.inner.list(request).await
}
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
self.inner.glob(request).await
}
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
self.inner.grep(request).await
}
async fn start_command(
&self,
request: CommandRequest,
) -> Result<CommandHandle, WorkdirError> {
self.inner.start_command(request).await
}
async fn command_status(
&self,
handle: CommandHandle,
) -> Result<CommandStatus, WorkdirError> {
self.inner.command_status(handle).await
}
async fn command_output(
&self,
request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
self.inner.command_output(request).await
}
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
self.inner.cancel_command(handle).await
}
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
self.inner.subscribe_command_events()
}
fn command_snapshot(&self) -> Vec<CommandSnapshot> {
self.inner.command_snapshot()
}
async fn close(&self) -> Result<(), WorkdirError> {
self.inner.close().await
}
}
fn blocking_session(
root: &Path,
) -> (
WorkdirToolBroker,
tokio::sync::watch::Receiver<bool>,
Arc<tokio::sync::Notify>,
) {
let scope = SharedScope::new(Scope::writable(root).unwrap());
let inner = Arc::new(LocalWorkdirSession::materialized_bound(
Workdir::new("blocking-delegation-test"),
root.to_path_buf(),
root.to_path_buf(),
scope,
WorkdirSessionCapabilities::ALL,
));
let (entered, receiver) = tokio::sync::watch::channel(false);
let release = Arc::new(tokio::sync::Notify::new());
let source = Arc::new(BlockingWriteSession {
inner,
entered,
release: release.clone(),
block_next_write: std::sync::atomic::AtomicBool::new(true),
});
(WorkdirToolBroker::new(source), receiver, release)
}
fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope { fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope {
WorkdirToolScope { WorkdirToolScope {
rules: vec![WorkdirToolScopeRule { rules: vec![WorkdirToolScopeRule {
target: fs_path(path), target: fs_path(path),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
cwd: fs_path(path), cwd: fs_path(path),
command: permission == WorkdirToolScopePermission::Write, command: permission == WorkdirToolScopePermission::Write,
@@ -1298,6 +1541,7 @@ mod tests {
target: fs_path("work"), target: fs_path("work"),
permission: WorkdirToolScopePermission::Write, permission: WorkdirToolScopePermission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
cwd: fs_path("work"), cwd: fs_path("work"),
command: false, command: false,
@@ -1385,12 +1629,24 @@ mod tests {
); );
} }
#[test]
fn workdir_rule_defaults_to_resolved_symlink_policy_on_restore() {
let rule: WorkdirToolScopeRule = serde_json::from_value(serde_json::json!({
"target": "src",
"permission": "read",
"recursive": true
}))
.unwrap();
assert_eq!(rule.symlink_policy, SymlinkPolicy::Resolved);
}
#[test] #[test]
fn non_recursive_rule_covers_target_and_direct_children_only() { fn non_recursive_rule_covers_target_and_direct_children_only() {
let rule = WorkdirToolScopeRule { let rule = WorkdirToolScopeRule {
target: fs_path("docs"), target: fs_path("docs"),
permission: WorkdirToolScopePermission::Read, permission: WorkdirToolScopePermission::Read,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}; };
assert!(path_in_rule(&rule, &fs_path("docs"))); assert!(path_in_rule(&rule, &fs_path("docs")));
assert!(path_in_rule(&rule, &fs_path("docs/readme.md"))); assert!(path_in_rule(&rule, &fs_path("docs/readme.md")));
@@ -1443,7 +1699,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn provider_scope_allows_read_through_its_logical_symlink_path() { async fn provider_scope_rejects_symlink_aliases_by_default() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1457,6 +1713,185 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert!(matches!(
child.read(read("link")).await,
Err(WorkdirError::Denied(message))
if message.contains("provider-resolved delegated scope")
));
}
#[cfg(unix)]
#[tokio::test]
async fn resolved_scope_follows_its_target_but_rejects_nested_escape() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("target")).unwrap();
fs::create_dir_all(root.path().join("secret")).unwrap();
fs::write(root.path().join("target/visible"), "visible").unwrap();
fs::write(root.path().join("secret/key"), "hidden").unwrap();
symlink("target", root.path().join("granted")).unwrap();
symlink("../secret/key", root.path().join("target/escape")).unwrap();
let parent = session(root.path());
let child = parent
.scope(request("granted", WorkdirToolScopePermission::Read))
.await
.unwrap();
assert_eq!(child.read(read("visible")).await.unwrap().bytes, b"visible");
assert!(matches!(
child.read(read("escape")).await,
Err(WorkdirError::Denied(message))
if message.contains("provider-resolved delegated scope")
));
}
#[tokio::test]
async fn write_and_overlapping_scope_admission_are_serialized() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("shared")).unwrap();
let (parent, mut entered, release) = blocking_session(root.path());
let writer = {
let parent = parent.clone();
tokio::spawn(async move { parent.write(write("shared/file", "written")).await })
};
entered.changed().await.unwrap();
assert!(*entered.borrow());
let mut admission = {
let parent = parent.clone();
tokio::spawn(async move {
parent
.scope(request("shared", WorkdirToolScopePermission::Write))
.await
})
};
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), &mut admission)
.await
.is_err(),
"scope admission must wait for the in-flight parent write"
);
release.notify_waiters();
writer.await.unwrap().unwrap();
let lease = tokio::time::timeout(std::time::Duration::from_secs(1), admission)
.await
.expect("scope admission should resume after write completion")
.unwrap()
.unwrap();
drop(lease);
}
#[tokio::test]
async fn read_rules_do_not_expand_child_write_lease_conflicts() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("leased")).unwrap();
fs::create_dir_all(root.path().join("other")).unwrap();
let parent = session(root.path());
let child = parent
.scope(WorkdirToolScope {
rules: vec![
WorkdirToolScopeRule {
target: fs_path("leased"),
permission: WorkdirToolScopePermission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
},
WorkdirToolScopeRule {
target: FsPath::root(),
permission: WorkdirToolScopePermission::Read,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
},
],
cwd: fs_path("leased"),
command: false,
})
.await
.unwrap();
parent
.write(write("other/parent", "allowed"))
.await
.unwrap();
let sibling = parent
.scope(request("other", WorkdirToolScopePermission::Write))
.await
.unwrap();
sibling.write(write("sibling", "allowed")).await.unwrap();
drop(child);
}
#[cfg(unix)]
#[tokio::test]
async fn sibling_write_scopes_reject_distinct_aliases_to_same_resolved_target() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("target")).unwrap();
symlink("target", root.path().join("alias-a")).unwrap();
symlink("target", root.path().join("alias-b")).unwrap();
let parent = session(root.path());
let _first = parent
.scope(request("alias-a", WorkdirToolScopePermission::Write))
.await
.unwrap();
assert!(matches!(
parent
.scope(request("alias-b", WorkdirToolScopePermission::Write))
.await,
Err(WorkdirError::Denied(message))
if message.contains("overlaps an active child scope after provider resolution")
));
assert!(matches!(
parent
.write(WriteRequest {
path: FsPath::new("target/from-parent").unwrap(),
content: b"blocked".to_vec(),
expected_hash: None,
})
.await,
Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools")
));
}
#[tokio::test]
async fn nested_scope_cannot_expand_resolved_policy_to_logical() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("granted")).unwrap();
let parent = session(root.path());
let child = parent
.scope(request("granted", WorkdirToolScopePermission::Read))
.await
.unwrap();
let mut expanded = request(".", WorkdirToolScopePermission::Read);
expanded.rules[0].symlink_policy = SymlinkPolicy::Logical;
assert!(matches!(
child.scope(expanded).await,
Err(WorkdirError::Denied(message))
if message.contains("exceeds the parent tool scope")
));
}
#[cfg(unix)]
#[tokio::test]
async fn provider_scope_allows_read_through_its_logical_symlink_path() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("granted")).unwrap();
fs::create_dir_all(root.path().join("secret")).unwrap();
fs::write(root.path().join("secret/key"), "hidden").unwrap();
symlink("../secret/key", root.path().join("granted/link")).unwrap();
let parent = session(root.path());
let mut scope = request("granted", WorkdirToolScopePermission::Read);
scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
let child = parent.scope(scope).await.unwrap();
assert_eq!(child.read(read("link")).await.unwrap().bytes, b"hidden"); assert_eq!(child.read(read("link")).await.unwrap().bytes, b"hidden");
} }
@@ -1470,10 +1905,9 @@ mod tests {
fs::create_dir_all(root.path().join("secret")).unwrap(); fs::create_dir_all(root.path().join("secret")).unwrap();
symlink("../secret", root.path().join("granted/outside")).unwrap(); symlink("../secret", root.path().join("granted/outside")).unwrap();
let parent = session(root.path()); let parent = session(root.path());
let child = parent let mut scope = request("granted", WorkdirToolScopePermission::Write);
.scope(request("granted", WorkdirToolScopePermission::Write)) scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
.await let child = parent.scope(scope).await.unwrap();
.unwrap();
child child
.write(write("outside/new", "through-logical-path")) .write(write("outside/new", "through-logical-path"))
@@ -1487,7 +1921,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn write_delegation_leases_the_logical_symlink_path() { async fn write_delegation_leases_logical_alias_and_resolved_target() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1496,21 +1930,20 @@ mod tests {
symlink("../secret", root.path().join("granted/outside")).unwrap(); symlink("../secret", root.path().join("granted/outside")).unwrap();
let parent = session(root.path()); let parent = session(root.path());
let child = parent let mut scope = request("granted/outside", WorkdirToolScopePermission::Write);
.scope(request( scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
"granted/outside", let child = parent.scope(scope).await.unwrap();
WorkdirToolScopePermission::Write,
))
.await
.unwrap();
child child
.write(write("from-child", "child-authoritative")) .write(write("from-child", "child-authoritative"))
.await .await
.unwrap(); .unwrap();
parent assert!(matches!(
.write(write("secret/parent", "still-authoritative")) parent
.await .write(write("secret/parent", "must-be-blocked"))
.unwrap(); .await,
Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools")
));
assert_eq!( assert_eq!(
fs::read_to_string(root.path().join("secret/from-child")).unwrap(), fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
"child-authoritative" "child-authoritative"
+2 -1
View File
@@ -18,7 +18,7 @@ required-features = ["ws-server", "fs-store"]
[features] [features]
default = ["ws-server", "fs-store"] default = ["ws-server", "fs-store"]
fs-store = [] fs-store = ["dep:fs4"]
http-server = ["dep:axum", "dep:tower", "dep:reqwest"] http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"] ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
@@ -29,6 +29,7 @@ axum = { workspace = true, optional = true }
futures = { workspace = true, optional = true } futures = { workspace = true, optional = true }
decodal.workspace = true decodal.workspace = true
flow = { path = "../flow" } flow = { path = "../flow" }
fs4 = { workspace = true, optional = true }
manifest.workspace = true manifest.workspace = true
protocol.workspace = true protocol.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
+16 -6
View File
@@ -266,11 +266,11 @@ pub struct CreateWorkerRequest {
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>, pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
} }
/// Worker lifecycle status for the in-memory embedded runtime. /// Last persisted Worker lifecycle status.
/// ///
/// Run termination details are carried separately by the Worker protocol. In /// This is not proof that the current Runtime process holds a live execution handle. Run
/// particular, cancellation returns a Worker to `Idle`; it is not a lifecycle /// termination details remain separate Worker protocol state; in particular, cancellation
/// state of its own. /// returns a Worker to `Idle` and is not a lifecycle state of its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerStatus { pub enum WorkerStatus {
@@ -293,12 +293,17 @@ pub(crate) enum WorkerRestoreIntent {
Explicit, Explicit,
} }
/// Lightweight catalog row. /// Lightweight persisted Worker identity projection.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSummary { pub struct WorkerSummary {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub status: WorkerStatus, pub status: WorkerStatus,
/// Creation timestamp in Unix epoch milliseconds for records created on this schema.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at_ms: Option<u64>,
/// Whether the persisted execution metadata was valid when this identity was loaded.
pub execution_metadata_available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>, pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -313,12 +318,17 @@ pub struct WorkerSummary {
pub config_bundle: Option<ConfigBundleRef>, pub config_bundle: Option<ConfigBundleRef>,
} }
/// Full Worker catalog/lifecycle detail. /// Full persisted Worker identity and lifecycle detail.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerDetail { pub struct WorkerDetail {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub status: WorkerStatus, pub status: WorkerStatus,
/// Creation timestamp in Unix epoch milliseconds for records created on this schema.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at_ms: Option<u64>,
/// Whether the persisted execution metadata was valid when this identity was loaded.
pub execution_metadata_available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>, pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
+9
View File
@@ -21,6 +21,12 @@ pub enum RuntimeError {
message: String, message: String,
}, },
#[error("worker {worker_id} metadata deletion failed: {message}")]
WorkerDeletePersistenceFailed {
worker_id: WorkerId,
message: String,
},
#[error("worker creation has no execution backend: {message}")] #[error("worker creation has no execution backend: {message}")]
ExecutionBackendUnavailable { message: String }, ExecutionBackendUnavailable { message: String },
@@ -79,6 +85,9 @@ pub enum RuntimeError {
name: String, name: String,
}, },
#[error("Runtime store is already owned by another process")]
RuntimeStoreAlreadyOpen { path: PathBuf },
#[error("runtime store {operation} failed at {}: {source}", path.display())] #[error("runtime store {operation} failed at {}: {source}", path.display())]
StoreIo { StoreIo {
operation: &'static str, operation: &'static str,
-4
View File
@@ -243,8 +243,6 @@ impl fmt::Debug for WorkerExecutionContext {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct WorkerExecutionSpawnRequest { pub struct WorkerExecutionSpawnRequest {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
/// Monotonic execution generation reserved durably before launch.
pub run_generation: u64,
pub request: crate::catalog::CreateWorkerRequest, pub request: crate::catalog::CreateWorkerRequest,
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>, pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
pub context: WorkerExecutionContext, pub context: WorkerExecutionContext,
@@ -256,8 +254,6 @@ pub struct WorkerExecutionSpawnRequest {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct WorkerExecutionRestoreRequest { pub struct WorkerExecutionRestoreRequest {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
/// Monotonic execution generation reserved durably before restore.
pub run_generation: u64,
pub request: crate::catalog::CreateWorkerRequest, pub request: crate::catalog::CreateWorkerRequest,
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>, pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
pub context: WorkerExecutionContext, pub context: WorkerExecutionContext,
File diff suppressed because it is too large Load Diff
+89 -5
View File
@@ -1012,6 +1012,14 @@ async fn run_workdir_session_operation(
let operation = request.operation; let operation = request.operation;
let result = match operation { let result = match operation {
WorkdirSessionOperation::AuthorizeScope(request) => {
session.authorize_scope_path(request).await?;
WorkdirSessionOperationResult::AuthorizeScope
}
WorkdirSessionOperation::ScopeRulesOverlap(request) => {
let overlaps = session.scope_rules_overlap(request).await?;
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }
}
WorkdirSessionOperation::Stat(request) => { WorkdirSessionOperation::Stat(request) => {
WorkdirSessionOperationResult::Stat(session.stat(request).await?) WorkdirSessionOperationResult::Stat(session.stat(request).await?)
} }
@@ -2459,6 +2467,7 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
StatusCode::NOT_FOUND StatusCode::NOT_FOUND
} }
RuntimeError::RuntimeStopped RuntimeError::RuntimeStopped
| RuntimeError::RuntimeStoreAlreadyOpen { .. }
| RuntimeError::WorkerExecutionUnavailable { .. } | RuntimeError::WorkerExecutionUnavailable { .. }
| RuntimeError::ExecutionBackendUnavailable { .. } | RuntimeError::ExecutionBackendUnavailable { .. }
| RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT, | RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT,
@@ -2473,6 +2482,7 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
RuntimeError::StoreIo { .. } RuntimeError::StoreIo { .. }
| RuntimeError::StoreMissing { .. } | RuntimeError::StoreMissing { .. }
| RuntimeError::StoreCorrupt { .. } | RuntimeError::StoreCorrupt { .. }
| RuntimeError::WorkerDeletePersistenceFailed { .. }
| RuntimeError::StatePoisoned => StatusCode::INTERNAL_SERVER_ERROR, | RuntimeError::StatePoisoned => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }
@@ -2480,10 +2490,14 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
fn code_for_runtime_error(error: &RuntimeError) -> String { fn code_for_runtime_error(error: &RuntimeError) -> String {
match error { match error {
RuntimeError::RuntimeStopped => "runtime_stopped".to_string(), RuntimeError::RuntimeStopped => "runtime_stopped".to_string(),
RuntimeError::RuntimeStoreAlreadyOpen { .. } => "runtime_store_already_open".to_string(),
RuntimeError::WorkerNotFound { .. } => "worker_not_found".to_string(), RuntimeError::WorkerNotFound { .. } => "worker_not_found".to_string(),
RuntimeError::WorkerExecutionUnavailable { .. } => { RuntimeError::WorkerExecutionUnavailable { .. } => {
"worker_execution_unavailable".to_string() "worker_execution_unavailable".to_string()
} }
RuntimeError::WorkerDeletePersistenceFailed { .. } => {
"worker_delete_persistence_failed".to_string()
}
RuntimeError::ExecutionBackendUnavailable { .. } => { RuntimeError::ExecutionBackendUnavailable { .. } => {
"execution_backend_unavailable".to_string() "execution_backend_unavailable".to_string()
} }
@@ -2983,6 +2997,60 @@ mod tests {
.expect("owned operation"); .expect("owned operation");
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_))); assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
let authorization = WorkdirSessionOperationRequest {
operation: WorkdirSessionOperation::AuthorizeScope(
workdir::WorkdirScopeAuthorizationRequest {
rules: vec![workdir::WorkdirToolScopeRule {
target: WorkdirPath::new("hello.txt").unwrap(),
permission: workdir::WorkdirToolScopePermission::Read,
recursive: false,
symlink_policy: Default::default(),
}],
path: WorkdirPath::new("hello.txt").unwrap(),
permission: workdir::WorkdirToolScopePermission::Read,
},
),
};
let Json(result) = run_workdir_session_operation(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(auth.clone())),
Ok(Json(authorization)),
)
.await
.expect("provider-side scope authorization");
assert!(matches!(
result,
WorkdirSessionOperationResult::AuthorizeScope
));
let overlap_rule = workdir::WorkdirToolScopeRule {
target: WorkdirPath::new("hello.txt").unwrap(),
permission: workdir::WorkdirToolScopePermission::Write,
recursive: false,
symlink_policy: Default::default(),
};
let overlap = WorkdirSessionOperationRequest {
operation: WorkdirSessionOperation::ScopeRulesOverlap(
workdir::WorkdirScopeOverlapRequest {
left: overlap_rule.clone(),
right: overlap_rule,
},
),
};
let Json(result) = run_workdir_session_operation(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(auth.clone())),
Ok(Json(overlap)),
)
.await
.expect("provider-side resolved overlap check");
assert!(matches!(
result,
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps: true }
));
let grep = WorkdirSessionOperationRequest { let grep = WorkdirSessionOperationRequest {
operation: WorkdirSessionOperation::Grep(GrepRequest { operation: WorkdirSessionOperation::Grep(GrepRequest {
pattern: "hello".into(), pattern: "hello".into(),
@@ -3062,7 +3130,6 @@ mod tests {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot { worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into() ..protocol::WorkerStatus::Idle.into()
}, },
working_directory: request working_directory: request
@@ -3079,7 +3146,6 @@ mod tests {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot { worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into() ..protocol::WorkerStatus::Idle.into()
}, },
working_directory: request.previous_working_directory, working_directory: request.previous_working_directory,
@@ -3443,6 +3509,25 @@ mod tests {
assert!(matches!(error, RuntimeHttpServerError::AuthRequired)); assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
} }
#[test]
fn worker_delete_persistence_error_is_bounded_and_typed() {
let worker_id = crate::identity::WorkerId::now_v7();
let error = RuntimeError::WorkerDeletePersistenceFailed {
worker_id,
message: "Worker metadata deletion failed; the persisted Worker identity was retained for retry".to_string(),
};
assert_eq!(
status_for_runtime_error(&error),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
code_for_runtime_error(&error),
"worker_delete_persistence_failed"
);
assert!(!error.to_string().contains('/'));
}
#[test] #[test]
fn workdir_runtime_errors_preserve_diagnostic_code() { fn workdir_runtime_errors_preserve_diagnostic_code() {
let cases = [ let cases = [
@@ -3497,7 +3582,6 @@ mod ws_tests {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot { worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into() ..protocol::WorkerStatus::Idle.into()
}, },
working_directory: request working_directory: request
@@ -3540,10 +3624,11 @@ mod ws_tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
state: protocol::WorkerStateSnapshot::initial(1), state: protocol::WorkerStateSnapshot::initial(),
in_flight: protocol::InFlightSnapshot { in_flight: protocol::InFlightSnapshot {
blocks: Vec::new(), blocks: Vec::new(),
commands: Vec::new(), commands: Vec::new(),
compaction: None,
}, },
internal_workers: Vec::new(), internal_workers: Vec::new(),
}) })
@@ -3768,7 +3853,6 @@ mod ws_tests {
.unwrap() .unwrap()
.worker_state .worker_state
.expect("connected test Worker must expose its initial state"); .expect("connected test Worker must expose its initial state");
snapshot.revision += 1;
snapshot.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( snapshot.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running, protocol::WorkerRunState::Running,
)); ));
+2
View File
@@ -30,6 +30,8 @@ pub mod worker_backend;
pub mod worker_source; pub mod worker_source;
pub mod working_directory; pub mod working_directory;
pub mod workspace_issuer; pub mod workspace_issuer;
#[cfg(feature = "http-server")]
pub mod workspace_request;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
+41 -10
View File
@@ -1,7 +1,7 @@
// Worker-backed Runtime REST process wrapper. // Worker-backed Runtime host service.
// //
// This binary starts a Runtime command API with a real worker execution backend. // This binary starts a Runtime command API with a real Worker execution backend.
// A REST Runtime process that cannot spawn Workers is not a valid Runtime for the // A Runtime service that cannot create and restore Workers is not available to the
// Workspace Browser. // Workspace Browser.
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -33,6 +33,7 @@ use worker_runtime::workspace_issuer::{
WorkspaceIssuerTrustState, add_workspace_issuer_trust, replace_workspace_issuer_trust, WorkspaceIssuerTrustState, add_workspace_issuer_trust, replace_workspace_issuer_trust,
revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records, revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records,
}; };
use worker_runtime::workspace_request::RuntimeWorkspaceRequestClient;
use worker_runtime::{Runtime, RuntimeOptions}; use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode { fn main() -> ExitCode {
@@ -211,6 +212,22 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
if let Some(identity) = runtime_auth.identity.clone() { if let Some(identity) = runtime_auth.identity.clone() {
factory = factory.with_remote_worker_mutation_identity(identity); factory = factory.with_remote_worker_mutation_identity(identity);
} }
if let Some(identity) = runtime_auth.identity.as_ref() {
for issuer in runtime_auth
.workspace_issuers
.iter()
.filter(|issuer| issuer.state == WorkspaceIssuerTrustState::Active)
{
factory = factory.with_workspace_request_client(
RuntimeWorkspaceRequestClient::new(
issuer.workspace_id.clone(),
issuer.backend_url.clone(),
identity.identity_id.clone(),
)
.with_runtime_request_source(identity, issuer.backend_url.clone()),
);
}
}
let mut backend_resource_client: Option< let mut backend_resource_client: Option<
Arc<dyn worker_runtime::resource::BackendResourceClient>, Arc<dyn worker_runtime::resource::BackendResourceClient>,
> = None; > = None;
@@ -231,7 +248,14 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
endpoint, endpoint,
config.backend_resource_token.clone(), config.backend_resource_token.clone(),
) )
.with_runtime_request_source(identity, workspace_issuer.backend_url.clone()), .with_workspace_request_client(
RuntimeWorkspaceRequestClient::new(
workspace_issuer.workspace_id.clone(),
workspace_issuer.backend_url.clone(),
identity.identity_id.clone(),
)
.with_runtime_request_source(identity, workspace_issuer.backend_url.clone()),
),
); );
factory = factory.with_resource_client(client.clone()); factory = factory.with_resource_client(client.clone());
backend_resource_client = Some(client); backend_resource_client = Some(client);
@@ -257,7 +281,14 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
endpoint, endpoint,
config.backend_resource_token.clone(), config.backend_resource_token.clone(),
) )
.with_runtime_request_source(identity, workspace_issuer.backend_url.clone()), .with_workspace_request_client(
RuntimeWorkspaceRequestClient::new(
workspace_issuer.workspace_id.clone(),
workspace_issuer.backend_url.clone(),
identity.identity_id.clone(),
)
.with_runtime_request_source(identity, workspace_issuer.backend_url.clone()),
),
); );
workspace_backend_resource_clients workspace_backend_resource_clients
.push((workspace_issuer.workspace_id.clone(), client)); .push((workspace_issuer.workspace_id.clone(), client));
@@ -1121,7 +1152,7 @@ fn usage() -> &'static str {
yoi-runtime migrate --dry-run [--runtime-id <ID>] [OPTIONS] yoi-runtime migrate --dry-run [--runtime-id <ID>] [OPTIONS]
Starts a worker-backed Runtime REST command API for a trusted backend/proxy. Starts a worker-backed Runtime REST command API for a trusted backend/proxy.
Browsers must not connect to this Runtime process directly. Browsers must not connect to this Runtime service directly.
Options: Options:
--bind <ADDR> Bind socket address (default: 127.0.0.1:38800) --bind <ADDR> Bind socket address (default: 127.0.0.1:38800)
@@ -1242,14 +1273,14 @@ mod tests {
} }
#[test] #[test]
fn migration_dry_run_accepts_previous_schema_document_without_workers_field() { fn migration_dry_run_accepts_previous_schema_without_workers_field() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime"); let root = temp.path().join("runtime");
std::fs::create_dir_all(root.join("workers")).unwrap(); std::fs::create_dir_all(root.join("workers")).unwrap();
std::fs::write( std::fs::write(
root.join("runtime.json"), root.join("runtime.json"),
serde_json::to_vec_pretty(&serde_json::json!({ serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 3, "schema_version": 7,
"display_name": "local", "display_name": "local",
"backend": "fs_store", "backend": "fs_store",
"status": "running", "status": "running",
@@ -1281,14 +1312,14 @@ mod tests {
} }
#[test] #[test]
fn migration_dry_run_rejects_previous_schema_document_that_cannot_decode_as_v4() { fn migration_dry_run_rejects_previous_schema_that_cannot_decode_as_current() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime"); let root = temp.path().join("runtime");
std::fs::create_dir_all(root.join("workers")).unwrap(); std::fs::create_dir_all(root.join("workers")).unwrap();
std::fs::write( std::fs::write(
root.join("runtime.json"), root.join("runtime.json"),
serde_json::to_vec_pretty(&serde_json::json!({ serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 3, "schema_version": 7,
"display_name": "local", "display_name": "local",
"backend": "fs_store", "backend": "fs_store",
"status": 3, "status": 3,
+78 -67
View File
@@ -1,9 +1,7 @@
use crate::auth::{ use crate::auth::BACKEND_RESOURCE_FETCH_PERMISSION;
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 crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -188,10 +186,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>, workspace_request_client: Option<RuntimeWorkspaceRequestClient>,
request_source_audience: Option<String>,
request_timeout: std::time::Duration, request_timeout: std::time::Duration,
client: reqwest::Client,
} }
#[cfg(feature = "http-server")] #[cfg(feature = "http-server")]
@@ -200,10 +196,8 @@ impl HttpBackendResourceClient {
Self { Self {
endpoint: endpoint.into(), endpoint: endpoint.into(),
bearer_token, bearer_token,
request_source_signer: None, workspace_request_client: None,
request_source_audience: None,
request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT, request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
client: reqwest::Client::new(),
} }
} }
@@ -212,13 +206,8 @@ impl HttpBackendResourceClient {
self self
} }
pub fn with_runtime_request_source( pub fn with_workspace_request_client(mut self, client: RuntimeWorkspaceRequestClient) -> Self {
mut self, self.workspace_request_client = Some(client);
identity: &RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
self.request_source_audience = Some(audience.into());
self self
} }
} }
@@ -240,59 +229,73 @@ impl BackendResourceClient for HttpBackendResourceClient {
message: error.to_string(), message: error.to_string(),
} }
})?; })?;
let mut builder = self let client = self.workspace_request_client.as_ref().ok_or_else(|| {
.client BackendResourceError::Unauthorized {
.post(endpoint.clone()) message: "Workspace request client is unavailable".to_string(),
.timeout(self.request_timeout)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone());
if let Some(signer) = self.request_source_signer.as_ref() {
let audience = self.request_source_audience.as_deref().ok_or_else(|| {
BackendResourceError::Unauthorized {
message: "Runtime request proof audience is unavailable".to_owned(),
}
})?;
let proof = signer
.issue(
audience,
&request.handle.workspace_id,
None,
BACKEND_RESOURCE_FETCH_PERMISSION,
"POST",
endpoint.path(),
&body,
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| BackendResourceError::Unauthorized {
message: error.to_string(),
})?;
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
let builder = if let Some(token) = self.bearer_token.as_deref() {
builder.bearer_auth(token)
} else {
builder
};
let response = builder.send().await.map_err(|error| {
if error.is_timeout() {
BackendResourceError::Timeout
} else {
BackendResourceError::Transport {
message: error.to_string(),
}
} }
})?; })?;
if response.status().is_success() { if client.workspace_id() != request.handle.workspace_id {
response return Err(BackendResourceError::Unauthorized {
.json::<BackendResourceFetchResponse>() message: "Workspace request client does not match the resource workspace"
.await .to_string(),
.map_err(|err| BackendResourceError::InvalidResponse { });
}
let base_url = client.base_url().trim_end_matches('/');
let endpoint_text = endpoint.as_str();
let endpoint_suffix = endpoint_text.strip_prefix(base_url).ok_or_else(|| {
BackendResourceError::Unauthorized {
message: "Workspace resource endpoint does not match its request client"
.to_string(),
}
})?;
if !endpoint_suffix.starts_with('/') {
return Err(BackendResourceError::Unauthorized {
message: "Workspace resource endpoint does not match its request client"
.to_string(),
});
}
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
);
if let Some(token) = self.bearer_token.as_deref() {
let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|error| BackendResourceError::Transport {
message: error.to_string(),
})?;
headers.insert(reqwest::header::AUTHORIZATION, value);
}
let response = client
.execute(RuntimeWorkspaceRequest {
method: reqwest::Method::POST,
path_and_query: endpoint_suffix.to_string(),
body,
headers,
permission: BACKEND_RESOURCE_FETCH_PERMISSION.to_string(),
worker_id: None,
timeout: Some(self.request_timeout),
max_response_bytes: 8 * 1024 * 1024,
})
.await
.map_err(|error| {
if error.is_timeout() {
BackendResourceError::Timeout
} else {
BackendResourceError::Transport {
message: error.to_string(),
}
}
})?;
if response.status.is_success() {
serde_json::from_slice::<BackendResourceFetchResponse>(&response.body).map_err(|err| {
BackendResourceError::InvalidResponse {
message: err.to_string(), message: err.to_string(),
}) }
})
} else { } else {
let status = response.status(); let status = response.status;
match response.json::<BackendResourceError>().await { match serde_json::from_slice::<BackendResourceError>(&response.body) {
Ok(error) => Err(error), Ok(error) => Err(error),
Err(err) => Err(BackendResourceError::Transport { Err(err) => Err(BackendResourceError::Transport {
message: format!("backend resource fetch failed with HTTP {status}: {err}"), message: format!("backend resource fetch failed with HTTP {status}: {err}"),
@@ -383,6 +386,7 @@ pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), Str
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::RuntimeIdentityMaterial;
use crate::profile_archive::ProfileSourceGraphSummary; use crate::profile_archive::ProfileSourceGraphSummary;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -435,7 +439,14 @@ mod tests {
let handle = handle_for(b"archive-bytes"); let handle = handle_for(b"archive-bytes");
let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None) let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None)
.with_request_timeout(std::time::Duration::from_millis(25)) .with_request_timeout(std::time::Duration::from_millis(25))
.with_runtime_request_source(&identity, base_url); .with_workspace_request_client(
RuntimeWorkspaceRequestClient::new(
"workspace-test",
base_url.clone(),
"runtime-test",
)
.with_runtime_request_source(&identity, base_url),
);
let error = client let error = client
.fetch_resource(BackendResourceFetchRequest { .fetch_resource(BackendResourceFetchRequest {
+31 -93
View File
@@ -39,7 +39,6 @@ pub struct WorkerRetentionInventory {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String, pub runtime_id: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub run_generation: u64,
pub session_id: Option<String>, pub session_id: Option<String>,
pub segment_ids: Vec<String>, pub segment_ids: Vec<String>,
pub session_bytes: u64, pub session_bytes: u64,
@@ -118,7 +117,6 @@ pub struct WorkerRetentionExecutionRequest {
pub source_runtime_id: String, pub source_runtime_id: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub expected_worker_revision: String, pub expected_worker_revision: String,
pub expected_run_generation: u64,
pub source_created_at: String, pub source_created_at: String,
pub removed_at: String, pub removed_at: String,
pub effective_profile: Option<String>, pub effective_profile: Option<String>,
@@ -171,7 +169,6 @@ pub(crate) trait WorkerRetentionProvider: Send + Sync {
workspace_id: &str, workspace_id: &str,
runtime_id: &str, runtime_id: &str,
worker_id: WorkerId, worker_id: WorkerId,
run_generation: u64,
) -> Result<WorkerRetentionInventory, RuntimeError>; ) -> Result<WorkerRetentionInventory, RuntimeError>;
fn execute( fn execute(
@@ -283,7 +280,7 @@ impl FsWorkerRetentionProvider {
continue; continue;
}; };
let worker_dir = self.worker_dir(worker_id); let worker_dir = self.worker_dir(worker_id);
let snapshot: WorkerGenerationSnapshot = match read_json( let snapshot: WorkerAggregateSnapshot = match read_json(
&worker_dir.join("worker.json"), &worker_dir.join("worker.json"),
"scan Worker retention inventory", "scan Worker retention inventory",
) { ) {
@@ -303,12 +300,7 @@ impl FsWorkerRetentionProvider {
)); ));
continue; continue;
} }
match self.inventory( match self.inventory(workspace_id, runtime_id, worker_id) {
workspace_id,
runtime_id,
worker_id,
snapshot.run_generation(),
) {
Ok(item) => workers.push(item), Ok(item) => workers.push(item),
Err(_) => diagnostics.push(runtime_aggregate_diagnostic( Err(_) => diagnostics.push(runtime_aggregate_diagnostic(
&bounded_id, &bounded_id,
@@ -380,26 +372,18 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
workspace_id: &str, workspace_id: &str,
runtime_id: &str, runtime_id: &str,
worker_id: WorkerId, worker_id: WorkerId,
run_generation: u64,
) -> Result<WorkerRetentionInventory, RuntimeError> { ) -> Result<WorkerRetentionInventory, RuntimeError> {
let worker_dir = self.worker_dir(worker_id); let worker_dir = self.worker_dir(worker_id);
if !worker_dir.is_dir() { if !worker_dir.is_dir() {
return Err(RuntimeError::WorkerNotFound { worker_id }); return Err(RuntimeError::WorkerNotFound { worker_id });
} }
let worker: WorkerGenerationSnapshot = read_json( let worker: WorkerAggregateSnapshot = read_json(
&worker_dir.join("worker.json"), &worker_dir.join("worker.json"),
"inventory Worker retention", "inventory Worker retention",
)?; )?;
if worker.workspace_id.as_deref() != Some(workspace_id) { if worker.workspace_id.as_deref() != Some(workspace_id) {
return Err(RuntimeError::WorkerNotFound { worker_id }); return Err(RuntimeError::WorkerNotFound { worker_id });
} }
let current_run_generation = worker.run_generation();
if current_run_generation != run_generation {
return Err(RuntimeError::InvalidRequest(format!(
"Worker retention inventory expected generation {run_generation}, current generation is {}",
current_run_generation
)));
}
let session_dir = worker_dir.join("session"); let session_dir = worker_dir.join("session");
let (session_id, segment_ids, session_bytes) = if session_dir.is_dir() { let (session_id, segment_ids, session_bytes) = if session_dir.is_dir() {
let manifest: CanonicalSessionManifest = read_json( let manifest: CanonicalSessionManifest = read_json(
@@ -437,7 +421,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
worker_id, worker_id,
run_generation,
session_id, session_id,
segment_ids, segment_ids,
session_bytes, session_bytes,
@@ -497,21 +480,13 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
worker_id: request.worker_id, worker_id: request.worker_id,
}); });
} }
let snapshot: WorkerGenerationSnapshot = let snapshot: WorkerAggregateSnapshot =
read_json(&worker_dir.join("worker.json"), "execute Worker retention")?; read_json(&worker_dir.join("worker.json"), "execute Worker retention")?;
if snapshot.workspace_id.as_deref() != Some(request.workspace_id.as_str()) { if snapshot.workspace_id.as_deref() != Some(request.workspace_id.as_str()) {
return Err(RuntimeError::WorkerNotFound { return Err(RuntimeError::WorkerNotFound {
worker_id: request.worker_id, worker_id: request.worker_id,
}); });
} }
let run_generation = snapshot.run_generation();
if run_generation != request.expected_run_generation {
return Err(RuntimeError::InvalidRequest(format!(
"Worker retention plan expected generation {}, current generation is {}",
request.expected_run_generation, run_generation
)));
}
let archive = match request.session_disposition { let archive = match request.session_disposition {
SessionDisposition::Archive => { SessionDisposition::Archive => {
Some(commit_session_archive(self, request, &worker_dir)?) Some(commit_session_archive(self, request, &worker_dir)?)
@@ -572,30 +547,9 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct WorkerGenerationSnapshot { struct WorkerAggregateSnapshot {
#[serde(default)] #[serde(default)]
workspace_id: Option<String>, workspace_id: Option<String>,
execution: WorkerGenerationExecution,
}
#[derive(Deserialize)]
struct WorkerGenerationExecution {
binding: Option<WorkerGenerationBinding>,
}
#[derive(Deserialize)]
struct WorkerGenerationBinding {
run_generation: u64,
}
impl WorkerGenerationSnapshot {
fn run_generation(&self) -> u64 {
self.execution
.binding
.as_ref()
.map(|binding| binding.run_generation)
.unwrap_or(0)
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -1286,13 +1240,12 @@ mod tests {
fs::write(path, serde_json::to_vec_pretty(value).unwrap()).unwrap(); fs::write(path, serde_json::to_vec_pretty(value).unwrap()).unwrap();
} }
fn source(root: &Path, worker_id: WorkerId, generation: u64) { fn source(root: &Path, worker_id: WorkerId) {
let worker = root.join("workers").join(worker_id.to_string()); let worker = root.join("workers").join(worker_id.to_string());
write_json( write_json(
&worker.join("worker.json"), &worker.join("worker.json"),
&serde_json::json!({ &serde_json::json!({
"workspace_id": "workspace-a", "workspace_id": "workspace-a"
"execution": {"binding": {"run_generation": generation}}
}), }),
); );
write_json( write_json(
@@ -1301,22 +1254,17 @@ mod tests {
); );
fs::create_dir_all(worker.join("session/segments")).unwrap(); fs::create_dir_all(worker.join("session/segments")).unwrap();
fs::write(worker.join("session/segments/segment-a.jsonl"), b"one\n").unwrap(); fs::write(worker.join("session/segments/segment-a.jsonl"), b"one\n").unwrap();
fs::create_dir_all(worker.join(format!("runs/{generation}"))).unwrap(); fs::create_dir_all(worker.join("runs/attempt-a")).unwrap();
fs::write( fs::write(
worker.join(format!("runs/{generation}/worker.out.log")), worker.join("runs/attempt-a/worker.out.log"),
b"diagnostic\n", b"diagnostic\n",
) )
.unwrap(); .unwrap();
fs::write( fs::write(worker.join("runs/attempt-a/worker.sock"), b"not retained").unwrap();
worker.join(format!("runs/{generation}/worker.sock")),
b"not retained",
)
.unwrap();
} }
fn request( fn request(
worker_id: WorkerId, worker_id: WorkerId,
generation: u64,
disposition: SessionDisposition, disposition: SessionDisposition,
) -> WorkerRetentionExecutionRequest { ) -> WorkerRetentionExecutionRequest {
WorkerRetentionExecutionRequest { WorkerRetentionExecutionRequest {
@@ -1328,7 +1276,6 @@ mod tests {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
source_runtime_id: "runtime-a".to_string(), source_runtime_id: "runtime-a".to_string(),
worker_id, worker_id,
expected_run_generation: generation,
source_created_at: "2026-01-01T00:00:00Z".to_string(), source_created_at: "2026-01-01T00:00:00Z".to_string(),
removed_at: "2026-01-02T00:00:00Z".to_string(), removed_at: "2026-01-02T00:00:00Z".to_string(),
effective_profile: Some("builtin:coder".to_string()), effective_profile: Some("builtin:coder".to_string()),
@@ -1344,9 +1291,9 @@ mod tests {
fn archive_is_verified_before_source_removal_and_retry_converges() { fn archive_is_verified_before_source_removal_and_retry_converges() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(7); let worker_id = WorkerId::from_legacy_u64(7);
source(temp.path(), worker_id, 4); source(temp.path(), worker_id);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let request = request(worker_id, 4, SessionDisposition::Archive); let request = request(worker_id, SessionDisposition::Archive);
let first = provider.execute(&request).unwrap(); let first = provider.execute(&request).unwrap();
assert!(first.source_removed); assert!(first.source_removed);
@@ -1375,7 +1322,7 @@ mod tests {
fn archive_failure_keeps_live_source_for_retry() { fn archive_failure_keeps_live_source_for_retry() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(8); let worker_id = WorkerId::from_legacy_u64(8);
source(temp.path(), worker_id, 2); source(temp.path(), worker_id);
let collision = temp.path().join("archives/workers/archive-a"); let collision = temp.path().join("archives/workers/archive-a");
fs::create_dir_all(&collision).unwrap(); fs::create_dir_all(&collision).unwrap();
fs::write(collision.join("manifest.json"), b"not-json").unwrap(); fs::write(collision.join("manifest.json"), b"not-json").unwrap();
@@ -1383,7 +1330,7 @@ mod tests {
assert!( assert!(
provider provider
.execute(&request(worker_id, 2, SessionDisposition::Archive)) .execute(&request(worker_id, SessionDisposition::Archive))
.is_err() .is_err()
); );
assert!( assert!(
@@ -1403,13 +1350,13 @@ mod tests {
fn target_inventory_and_execute_reject_cross_workspace_aggregate() { fn target_inventory_and_execute_reject_cross_workspace_aggregate() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(16); let worker_id = WorkerId::from_legacy_u64(16);
source(temp.path(), worker_id, 3); source(temp.path(), worker_id);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
assert!(matches!( assert!(matches!(
provider.inventory("other-workspace", "runtime-a", worker_id, 3), provider.inventory("other-workspace", "runtime-a", worker_id),
Err(RuntimeError::WorkerNotFound { .. }) Err(RuntimeError::WorkerNotFound { .. })
)); ));
let mut request = request(worker_id, 3, SessionDisposition::Purge); let mut request = request(worker_id, SessionDisposition::Purge);
request.workspace_id = "other-workspace".to_string(); request.workspace_id = "other-workspace".to_string();
assert!(matches!( assert!(matches!(
provider.execute(&request), provider.execute(&request),
@@ -1434,20 +1381,12 @@ mod tests {
} }
#[test] #[test]
fn purge_removes_aggregate_and_rejects_stale_generation() { fn purge_removes_worker_aggregate() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let worker_id = WorkerId::from_legacy_u64(9); let worker_id = WorkerId::from_legacy_u64(9);
source(temp.path(), worker_id, 5); source(temp.path(), worker_id);
let stale = request(worker_id, 4, SessionDisposition::Purge); let mut current = request(worker_id, SessionDisposition::Purge);
assert!(provider.execute(&stale).is_err());
assert!(
temp.path()
.join(format!("workers/{worker_id}/session"))
.is_dir()
);
let mut current = request(worker_id, 5, SessionDisposition::Purge);
current.operation_id = "operation-current".to_string(); current.operation_id = "operation-current".to_string();
current.input_fingerprint = "fingerprint-current".to_string(); current.input_fingerprint = "fingerprint-current".to_string();
let result = provider.execute(&current).unwrap(); let result = provider.execute(&current).unwrap();
@@ -1464,9 +1403,9 @@ mod tests {
fn pending_receipt_recovers_delete_to_receipt_crash_window() { fn pending_receipt_recovers_delete_to_receipt_crash_window() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(11); let worker_id = WorkerId::from_legacy_u64(11);
source(temp.path(), worker_id, 1); source(temp.path(), worker_id);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let request = request(worker_id, 1, SessionDisposition::Archive); let request = request(worker_id, SessionDisposition::Archive);
let completed = provider.execute(&request).unwrap(); let completed = provider.execute(&request).unwrap();
let receipt_path = temp.path().join("retention/operations/operation-a.json"); let receipt_path = temp.path().join("retention/operations/operation-a.json");
let mut receipt: RetentionOperationReceipt = let mut receipt: RetentionOperationReceipt =
@@ -1482,9 +1421,9 @@ mod tests {
#[test] #[test]
fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() { fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
source(temp.path(), WorkerId::from_legacy_u64(13), 2); source(temp.path(), WorkerId::from_legacy_u64(13));
let other_worker = WorkerId::from_legacy_u64(14); let other_worker = WorkerId::from_legacy_u64(14);
source(temp.path(), other_worker, 1); source(temp.path(), other_worker);
write_json( write_json(
&temp &temp
.path() .path()
@@ -1492,8 +1431,7 @@ mod tests {
.join(other_worker.to_string()) .join(other_worker.to_string())
.join("worker.json"), .join("worker.json"),
&serde_json::json!({ &serde_json::json!({
"workspace_id": "other-workspace", "workspace_id": "other-workspace"
"execution": {"binding": {"run_generation": 1}}
}), }),
); );
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap(); fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
@@ -1532,9 +1470,9 @@ mod tests {
fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() { fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(12); let worker_id = WorkerId::from_legacy_u64(12);
source(temp.path(), worker_id, 1); source(temp.path(), worker_id);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let mut request = request(worker_id, 1, SessionDisposition::Archive); let mut request = request(worker_id, SessionDisposition::Archive);
request.diagnostics_disposition = DiagnosticsDisposition::Retain; request.diagnostics_disposition = DiagnosticsDisposition::Retain;
provider.execute(&request).unwrap(); provider.execute(&request).unwrap();
@@ -1543,10 +1481,10 @@ mod tests {
serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap(); serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap();
receipt.result.source_removed = false; receipt.result.source_removed = false;
fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap(); fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap();
source(temp.path(), worker_id, 1); source(temp.path(), worker_id);
fs::write( fs::write(
temp.path() temp.path()
.join("archives/diagnostics/operation-a/runs/1/worker.out.log"), .join("archives/diagnostics/operation-a/runs/attempt-a/worker.out.log"),
b"corrupt\n", b"corrupt\n",
) )
.unwrap(); .unwrap();
@@ -1564,9 +1502,9 @@ mod tests {
fn concurrent_retry_produces_one_archive() { fn concurrent_retry_produces_one_archive() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(10); let worker_id = WorkerId::from_legacy_u64(10);
source(temp.path(), worker_id, 1); source(temp.path(), worker_id);
let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path())); let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path()));
let request = Arc::new(request(worker_id, 1, SessionDisposition::Archive)); let request = Arc::new(request(worker_id, SessionDisposition::Archive));
let barrier = Arc::new(Barrier::new(3)); let barrier = Arc::new(Barrier::new(3));
let handles = (0..2) let handles = (0..2)
.map(|_| { .map(|_| {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+116 -221
View File
@@ -1,16 +1,16 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
use worker::{ use worker::{
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution, WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
}; };
use crate::auth::{ use crate::auth::{
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial, RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION, WORKER_REMOVE_PERMISSION, WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION,
WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION, WorkerMutationActorKind, WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
}; };
use crate::runtime::RuntimeWorkspaceScope; use crate::runtime::RuntimeWorkspaceScope;
use crate::worker_backend::WorkspacePromptProjectionCache; use crate::worker_backend::WorkspacePromptProjectionCache;
@@ -133,9 +133,7 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
#[derive(Clone)] #[derive(Clone)]
enum RuntimeWorkerMutationTransport { enum RuntimeWorkerMutationTransport {
Remote { Remote {
base_url: String, request_client: RuntimeWorkspaceRequestClient,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
}, },
Embedded { Embedded {
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>, dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
@@ -155,17 +153,13 @@ impl RuntimeWorkerMutationForwarder {
identity: &RuntimeIdentityMaterial, identity: &RuntimeIdentityMaterial,
scope: RuntimeWorkspaceScope, scope: RuntimeWorkspaceScope,
source_worker_id: impl Into<String>, source_worker_id: impl Into<String>,
base_url: impl Into<String>, request_client: RuntimeWorkspaceRequestClient,
) -> Self { ) -> Self {
Self { Self {
authority: RuntimeWorkerMutationSourceAuthority::remote(identity), authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
scope: scope.clone(), scope: scope.clone(),
source_worker_id: source_worker_id.into(), source_worker_id: source_worker_id.into(),
transport: RuntimeWorkerMutationTransport::Remote { transport: RuntimeWorkerMutationTransport::Remote { request_client },
base_url: base_url.into().trim_end_matches('/').to_string(),
request_source_signer: RuntimeRequestSourceSigner::from_identity(identity),
request_source_audience: scope.server_id,
},
} }
} }
@@ -201,18 +195,11 @@ impl RuntimeWorkerMutationForwarder {
)?; )?;
match (&self.transport, proof) { match (&self.transport, proof) {
( (
RuntimeWorkerMutationTransport::Remote { RuntimeWorkerMutationTransport::Remote { request_client },
base_url,
request_source_signer,
request_source_audience,
},
RuntimeOwnedWorkerMutationProof::Remote(token), RuntimeOwnedWorkerMutationProof::Remote(token),
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest { ) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
base_url: base_url.clone(), request_client: request_client.clone(),
workspace_id: self.scope.workspace_id.clone(),
source_worker_id: self.source_worker_id.clone(), source_worker_id: self.source_worker_id.clone(),
request_source_signer: request_source_signer.clone(),
request_source_audience: request_source_audience.clone(),
token, token,
target_runtime_id: target_runtime_id.to_string(), target_runtime_id: target_runtime_id.to_string(),
target_worker_id: target_worker_id.to_string(), target_worker_id: target_worker_id.to_string(),
@@ -233,11 +220,8 @@ impl RuntimeWorkerMutationForwarder {
} }
struct RemoteWorkerRemoveHttpRequest { struct RemoteWorkerRemoveHttpRequest {
base_url: String, request_client: RuntimeWorkspaceRequestClient,
workspace_id: String,
source_worker_id: String, source_worker_id: String,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
token: String, token: String,
target_runtime_id: String, target_runtime_id: String,
target_worker_id: String, target_worker_id: String,
@@ -270,54 +254,54 @@ fn execute_remote_worker_remove_http(
fn execute_remote_worker_remove_http_blocking( fn execute_remote_worker_remove_http_blocking(
request: RemoteWorkerRemoveHttpRequest, request: RemoteWorkerRemoveHttpRequest,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> { ) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
let path = format!("/api/w/{}/workers/remove", request.workspace_id); let path = format!(
let url = format!("{}{}", request.base_url, path); "/api/w/{}/workers/remove",
let body = serde_json::to_string(&serde_json::json!({ request.request_client.workspace_id()
);
let body = serde_json::to_vec(&serde_json::json!({
"target_runtime_id": request.target_runtime_id, "target_runtime_id": request.target_runtime_id,
"target_worker_id": request.target_worker_id, "target_worker_id": request.target_worker_id,
"reason": request.reason, "reason": request.reason,
})) }))
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let request_source_proof = request.request_source_signer.issue( let mut headers = reqwest::header::HeaderMap::new();
&request.request_source_audience, headers.insert(
&request.workspace_id, crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
Some(&request.source_worker_id), reqwest::header::HeaderValue::from_str(&request.token)
WORKSPACE_REQUEST_PERMISSION, .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?,
"POST", );
&path, headers.insert(
body.as_bytes(), reqwest::header::CONTENT_TYPE,
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), reqwest::header::HeaderValue::from_static("application/json"),
30, );
)?; let response = request
let client = reqwest::blocking::Client::new(); .request_client
let response = client .execute_blocking(RuntimeWorkspaceRequest {
.post(url) method: reqwest::Method::POST,
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, request_source_proof) path_and_query: path,
.header( body,
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER, headers,
request.token, permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
) worker_id: Some(request.source_worker_id),
.header(reqwest::header::CONTENT_TYPE, "application/json") timeout: Some(Duration::from_secs(5)),
.body(body) max_response_bytes: 8 * 1024 * 1024,
.send() })
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let status = response.status().as_u16(); let body = String::from_utf8(response.body)
let body = response
.text()
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
Ok(WorkspaceResponse { status, body }) Ok(WorkspaceResponse {
status: response.status.as_u16(),
body,
})
} }
#[derive(Clone)] #[derive(Clone)]
pub struct RuntimeOwnedWorkspaceClient { pub struct RuntimeOwnedWorkspaceClient {
workspace_id: String, workspace_id: String,
base_url: String, request_client: RuntimeWorkspaceRequestClient,
runtime_id: String,
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>>,
} }
@@ -328,15 +312,32 @@ impl RuntimeOwnedWorkspaceClient {
runtime_id: impl Into<String>, runtime_id: impl Into<String>,
worker_id: impl Into<String>, worker_id: impl Into<String>,
) -> Self { ) -> Self {
let workspace_id = workspace_id.into();
Self { Self {
workspace_id: workspace_id.into(), request_client: RuntimeWorkspaceRequestClient::new(
base_url: base_url.into().trim_end_matches('/').to_string(), workspace_id.clone(),
runtime_id: runtime_id.into(), base_url,
runtime_id,
),
workspace_id,
worker_id: worker_id.into(),
request_timeout: None,
worker_remove: None,
prompt_projection_cache: None,
}
}
pub(crate) fn from_request_client(
request_client: RuntimeWorkspaceRequestClient,
worker_id: impl Into<String>,
) -> Self {
let workspace_id = request_client.workspace_id().to_string();
Self {
workspace_id,
request_client,
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,
} }
} }
@@ -351,8 +352,9 @@ impl RuntimeOwnedWorkspaceClient {
identity: &RuntimeIdentityMaterial, identity: &RuntimeIdentityMaterial,
audience: impl Into<String>, audience: impl Into<String>,
) -> Self { ) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity)); self.request_client = self
self.request_source_audience = Some(audience.into()); .request_client
.with_runtime_request_source(identity, audience);
self self
} }
@@ -375,44 +377,43 @@ impl RuntimeOwnedWorkspaceClient {
request: WorkspaceRequest, request: WorkspaceRequest,
permission: &'static str, permission: &'static str,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone(); let method = match request.method {
let workspace_id = self.workspace_id.clone(); WorkspaceRequestMethod::Get => reqwest::Method::GET,
let runtime_id = self.runtime_id.clone(); WorkspaceRequestMethod::Post => reqwest::Method::POST,
let worker_id = self.worker_id.clone(); WorkspaceRequestMethod::Put => reqwest::Method::PUT,
let request_source_signer = self.request_source_signer.clone(); WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
let request_source_audience = self.request_source_audience.clone(); WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
let request_timeout = self.request_timeout; };
if tokio::runtime::Handle::try_current().is_ok() { let body = request.body.unwrap_or_default().into_bytes();
std::thread::spawn(move || { let mut headers = reqwest::header::HeaderMap::new();
execute_runtime_owned_workspace_http( if !body.is_empty() {
&base_url, headers.insert(
&workspace_id, reqwest::header::CONTENT_TYPE,
&runtime_id, reqwest::header::HeaderValue::from_static("application/json"),
&worker_id, );
request_source_signer.as_ref(),
request_source_audience.as_deref(),
request_timeout,
permission,
request,
)
})
.join()
.map_err(|_| {
WorkspaceClientError::Request("workspace request thread panicked".to_string())
})?
} else {
execute_runtime_owned_workspace_http(
&self.base_url,
&self.workspace_id,
&self.runtime_id,
&self.worker_id,
self.request_source_signer.as_ref(),
self.request_source_audience.as_deref(),
self.request_timeout,
permission,
request,
)
} }
let request_label = format!("{method} {}", request.path);
let response = self
.request_client
.execute_blocking(RuntimeWorkspaceRequest {
method,
path_and_query: request.path,
body,
headers,
permission: permission.to_string(),
worker_id: Some(self.worker_id.clone()),
timeout: self.request_timeout,
max_response_bytes: 8 * 1024 * 1024,
})
.map_err(|error| {
WorkspaceClientError::Request(format!("{request_label} failed: {error}"))
})?;
let body = String::from_utf8(response.body)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
Ok(WorkspaceResponse {
status: response.status.as_u16(),
body,
})
} }
} }
@@ -420,8 +421,8 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter formatter
.debug_struct("RuntimeOwnedWorkspaceClient") .debug_struct("RuntimeOwnedWorkspaceClient")
.field("workspace_id", &self.workspace_id) .field("workspace_id", &self.request_client.workspace_id())
.field("base_url", &self.base_url) .field("base_url", &self.request_client.base_url())
.field("source", &"Runtime-owned") .field("source", &"Runtime-owned")
.field( .field(
"worker_remove", "worker_remove",
@@ -433,7 +434,7 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
impl WorkspaceClient for RuntimeOwnedWorkspaceClient { impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn workspace_id(&self) -> Option<&str> { fn workspace_id(&self) -> Option<&str> {
Some(&self.workspace_id) Some(self.request_client.workspace_id())
} }
fn kind(&self) -> &str { fn kind(&self) -> &str {
@@ -588,117 +589,6 @@ fn percent_encode_query(value: &str) -> String {
encoded encoded
} }
fn execute_runtime_owned_workspace_http(
base_url: &str,
workspace_id: &str,
runtime_id: &str,
worker_id: &str,
request_source_signer: Option<&RuntimeRequestSourceSigner>,
request_source_audience: Option<&str>,
request_timeout: Option<Duration>,
permission: &'static str,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !request.path.starts_with('/') || request.path.starts_with("//") {
return Err(WorkspaceClientError::InvalidPath(request.path));
}
let url = format!("{base_url}{}", request.path);
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
WorkspaceRequestMethod::Post => reqwest::Method::POST,
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
};
let client = reqwest::blocking::Client::builder()
.timeout(request_timeout)
.build()
.map_err(|error| {
WorkspaceClientError::Unavailable(format!(
"failed to build Workspace API HTTP client: {}",
reqwest_error_chain(&error)
))
})?;
let request_label = format!("{method} {}", request.path);
let body = request.body.unwrap_or_default();
let mut request_builder = client
.request(method.clone(), url)
.header("x-yoi-runtime-id", runtime_id)
.header("x-yoi-worker-id", worker_id);
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),
permission,
method.as_str(),
&request.path,
body.as_bytes(),
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
request_builder = request_builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !body.is_empty() {
request_builder = request_builder
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
}
let response = request_builder
.send()
.map_err(|error| workspace_http_error(&request_label, "waiting for response", error))?;
let status = response.status().as_u16();
let body = response
.text()
.map_err(|error| workspace_http_error(&request_label, "reading response body", error))?;
Ok(WorkspaceResponse { status, body })
}
fn workspace_http_error(
request_label: &str,
stage: &str,
error: reqwest::Error,
) -> WorkspaceClientError {
let details = reqwest_error_chain(&error);
if error.is_timeout() {
WorkspaceClientError::Request(format!(
"Workspace API {request_label} timed out while {stage}: {details}"
))
} else if error.is_connect() {
WorkspaceClientError::Unavailable(format!(
"Workspace API {request_label} could not connect while {stage}: {details}"
))
} else {
WorkspaceClientError::Request(format!(
"Workspace API {request_label} transport failed while {stage}: {details}"
))
}
}
fn reqwest_error_chain(error: &reqwest::Error) -> String {
let mut details = error.to_string();
let mut source = std::error::Error::source(error);
for _ in 0..4 {
let Some(current) = source else {
break;
};
let current_text = current.to_string();
if !current_text.is_empty() && !details.ends_with(&current_text) {
details.push_str(": ");
details.push_str(&current_text);
}
source = std::error::Error::source(current);
}
details
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum RuntimeWorkerMutationForwardError { pub enum RuntimeWorkerMutationForwardError {
#[error(transparent)] #[error(transparent)]
@@ -722,9 +612,9 @@ fn unix_now_seconds() -> u64 {
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::{ use crate::auth::{
WorkerMutationSourceExpectation, decode_runtime_request_source_claims, RUNTIME_REQUEST_SOURCE_PROOF_HEADER, WorkerMutationSourceExpectation,
decode_worker_mutation_source_claims, request_body_digest, decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
verify_worker_mutation_source_proof, request_body_digest, verify_worker_mutation_source_proof,
}; };
#[test] #[test]
@@ -1132,7 +1022,12 @@ mod tests {
&identity, &identity,
scope, scope,
"worker-source", "worker-source",
format!("http://{address}"), RuntimeWorkspaceRequestClient::new(
"workspace-a",
format!("http://{address}"),
"runtime-a",
)
.with_runtime_request_source(&identity, "server-a"),
); );
let response = forwarder let response = forwarder
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker") .execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
+679 -111
View File
@@ -16,8 +16,8 @@ use std::net::Shutdown;
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Condvar, Mutex, Weak};
use std::thread::JoinHandle; use std::thread::JoinHandle;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use workdir::WorkdirSessionResource; use workdir::WorkdirSessionResource;
@@ -52,8 +52,6 @@ pub struct WorkingDirectoryEvidence {
pub credential_revision: Option<u64>, pub credential_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub host_trust_revision: Option<u64>, pub host_trust_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport_warning: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -257,17 +255,449 @@ fn binding_cleanliness(binding: &WorkingDirectoryBinding) -> String {
} }
} }
#[derive(Debug)]
struct PendingRepositoryAccessLease {
generation: u64,
access: RepositorySshMaterializationAccess,
}
#[derive(Debug)]
struct ActiveRepositoryAccessLease {
generation: u64,
expires_at_epoch_seconds: u64,
access: Weak<RepositoryCommandAccess>,
}
#[derive(Debug, Clone)]
enum RepositoryAccessExpiryKey {
Pending(String),
Active(u64),
}
#[derive(Debug, Default)]
struct RepositoryAccessExpiryState {
next_generation: u64,
pending: HashMap<String, PendingRepositoryAccessLease>,
active: HashMap<u64, ActiveRepositoryAccessLease>,
shutdown: bool,
}
#[derive(Debug)]
struct RepositoryAccessExpirySchedulerInner {
state: Mutex<RepositoryAccessExpiryState>,
wake: Condvar,
worker_starts: AtomicUsize,
}
#[derive(Debug)]
struct RepositoryAccessExpirySchedulerLifecycle {
inner: Arc<RepositoryAccessExpirySchedulerInner>,
worker: Mutex<Option<JoinHandle<()>>>,
}
#[derive(Clone, Debug)]
struct RepositoryAccessExpiryScheduler {
inner: Arc<RepositoryAccessExpirySchedulerInner>,
_lifecycle: Arc<RepositoryAccessExpirySchedulerLifecycle>,
}
impl RepositoryAccessExpiryScheduler {
const MAX_WAIT: Duration = Duration::from_secs(60 * 60);
fn new() -> Self {
let inner = Arc::new(RepositoryAccessExpirySchedulerInner {
state: Mutex::new(RepositoryAccessExpiryState::default()),
wake: Condvar::new(),
worker_starts: AtomicUsize::new(0),
});
let worker_inner = Arc::clone(&inner);
let worker = std::thread::Builder::new()
.name("repository-access-expiry".to_string())
.spawn(move || Self::run(worker_inner))
.expect("failed to spawn repository access expiry scheduler");
inner.worker_starts.fetch_add(1, Ordering::Relaxed);
let lifecycle = Arc::new(RepositoryAccessExpirySchedulerLifecycle {
inner: Arc::clone(&inner),
worker: Mutex::new(Some(worker)),
});
Self {
inner,
_lifecycle: lifecycle,
}
}
fn next_generation(
state: &mut RepositoryAccessExpiryState,
) -> Result<u64, WorkingDirectoryDiagnostic> {
state.next_generation = state.next_generation.checked_add(1).ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_generation_exhausted",
"Runtime Repository access generation is exhausted",
)
})?;
Ok(state.next_generation)
}
fn store_pending(
&self,
working_directory_id: &str,
access: RepositorySshMaterializationAccess,
) -> Result<u64, WorkingDirectoryDiagnostic> {
let mut state = self.inner.state.lock().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is unavailable",
)
})?;
if state.shutdown {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is shutting down",
));
}
let generation = Self::next_generation(&mut state)?;
state.pending.insert(
working_directory_id.to_string(),
PendingRepositoryAccessLease { generation, access },
);
drop(state);
self.inner.wake.notify_one();
Ok(generation)
}
fn pending(
&self,
working_directory_id: &str,
) -> Result<Option<RepositorySshMaterializationAccess>, WorkingDirectoryDiagnostic> {
self.inner
.state
.lock()
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is unavailable",
)
})
.map(|state| {
state
.pending
.get(working_directory_id)
.map(|lease| lease.access.clone())
})
}
fn take_pending(
&self,
working_directory_id: &str,
) -> Result<Option<RepositorySshMaterializationAccess>, WorkingDirectoryDiagnostic> {
let mut state = self.inner.state.lock().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is unavailable",
)
})?;
let access = state
.pending
.remove(working_directory_id)
.map(|lease| lease.access);
drop(state);
self.inner.wake.notify_one();
Ok(access)
}
fn remove_pending(&self, working_directory_id: &str) -> Result<(), WorkingDirectoryDiagnostic> {
let mut state = self.inner.state.lock().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is unavailable",
)
})?;
state.pending.remove(working_directory_id);
drop(state);
self.inner.wake.notify_one();
Ok(())
}
fn register_active(
&self,
access: &Arc<RepositoryCommandAccess>,
expires_at_epoch_seconds: u64,
) -> Result<RepositoryAccessExpiryRegistration, WorkingDirectoryDiagnostic> {
let mut state = self.inner.state.lock().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is unavailable",
)
})?;
if state.shutdown {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is shutting down",
));
}
let generation = Self::next_generation(&mut state)?;
let lease_id = generation;
state.active.insert(
lease_id,
ActiveRepositoryAccessLease {
generation,
expires_at_epoch_seconds,
access: Arc::downgrade(access),
},
);
drop(state);
self.inner.wake.notify_one();
Ok(RepositoryAccessExpiryRegistration {
scheduler: Arc::downgrade(&self.inner),
lease_id,
generation,
})
}
fn next_expiry(
state: &RepositoryAccessExpiryState,
) -> Option<(RepositoryAccessExpiryKey, u64, u64)> {
let mut next = None;
for (working_directory_id, lease) in &state.pending {
let candidate = (
RepositoryAccessExpiryKey::Pending(working_directory_id.clone()),
lease.generation,
lease.access.expires_at_epoch_seconds,
);
if next
.as_ref()
.map_or(true, |(_, _, expires_at)| candidate.2 < *expires_at)
{
next = Some(candidate);
}
}
for (lease_id, lease) in &state.active {
let candidate = (
RepositoryAccessExpiryKey::Active(*lease_id),
lease.generation,
lease.expires_at_epoch_seconds,
);
if next
.as_ref()
.map_or(true, |(_, _, expires_at)| candidate.2 < *expires_at)
{
next = Some(candidate);
}
}
next
}
fn expire_if_current(
state: &mut RepositoryAccessExpiryState,
key: &RepositoryAccessExpiryKey,
generation: u64,
now_epoch_seconds: u64,
) -> Option<Weak<RepositoryCommandAccess>> {
match key {
RepositoryAccessExpiryKey::Pending(working_directory_id) => {
let should_remove = state
.pending
.get(working_directory_id)
.is_some_and(|lease| {
lease.generation == generation
&& lease.access.expires_at_epoch_seconds <= now_epoch_seconds
});
if should_remove {
state.pending.remove(working_directory_id);
}
None
}
RepositoryAccessExpiryKey::Active(lease_id) => {
let should_remove = state.active.get(lease_id).is_some_and(|lease| {
lease.generation == generation
&& lease.expires_at_epoch_seconds <= now_epoch_seconds
});
if should_remove {
state.active.remove(lease_id).map(|lease| lease.access)
} else {
None
}
}
}
}
fn run(inner: Arc<RepositoryAccessExpirySchedulerInner>) {
loop {
let expired_access = {
let mut state = match inner.state.lock() {
Ok(state) => state,
Err(_) => return,
};
loop {
if state.shutdown {
return;
}
let Some((key, generation, expires_at_epoch_seconds)) =
Self::next_expiry(&state)
else {
state = match inner.wake.wait(state) {
Ok(state) => state,
Err(_) => return,
};
continue;
};
let now_epoch_seconds = repository_access_now_epoch_seconds();
if expires_at_epoch_seconds > now_epoch_seconds {
let wait = Duration::from_secs(
(expires_at_epoch_seconds - now_epoch_seconds)
.min(Self::MAX_WAIT.as_secs()),
);
state = match inner.wake.wait_timeout(state, wait) {
Ok((state, _)) => state,
Err(_) => return,
};
continue;
}
break Self::expire_if_current(&mut state, &key, generation, now_epoch_seconds);
}
};
if let Some(access) = expired_access.and_then(|access| access.upgrade()) {
access.stop();
}
}
}
fn cancel_active(inner: &RepositoryAccessExpirySchedulerInner, lease_id: u64, generation: u64) {
let Ok(mut state) = inner.state.lock() else {
return;
};
let should_remove = state
.active
.get(&lease_id)
.is_some_and(|lease| lease.generation == generation);
if should_remove {
state.active.remove(&lease_id);
}
drop(state);
inner.wake.notify_one();
}
#[cfg(test)]
fn pending_generation(&self, working_directory_id: &str) -> Option<u64> {
self.inner
.state
.lock()
.ok()?
.pending
.get(working_directory_id)
.map(|lease| lease.generation)
}
#[cfg(test)]
fn expire_pending_for_test(
&self,
working_directory_id: &str,
generation: u64,
now_epoch_seconds: u64,
) {
let Ok(mut state) = self.inner.state.lock() else {
return;
};
Self::expire_if_current(
&mut state,
&RepositoryAccessExpiryKey::Pending(working_directory_id.to_string()),
generation,
now_epoch_seconds,
);
}
#[cfg(test)]
fn pending_count(&self) -> usize {
self.inner
.state
.lock()
.map(|state| state.pending.len())
.unwrap_or_default()
}
#[cfg(test)]
fn active_count(&self) -> usize {
self.inner
.state
.lock()
.map(|state| state.active.len())
.unwrap_or_default()
}
#[cfg(test)]
fn worker_start_count(&self) -> usize {
self.inner.worker_starts.load(Ordering::Relaxed)
}
}
impl Drop for RepositoryAccessExpirySchedulerLifecycle {
fn drop(&mut self) {
let active = {
let mut state = match self.inner.state.lock() {
Ok(state) => state,
Err(poisoned) => poisoned.into_inner(),
};
state.shutdown = true;
state.pending.clear();
state
.active
.drain()
.map(|(_, lease)| lease.access)
.collect::<Vec<_>>()
};
self.inner.wake.notify_all();
for access in active {
if let Some(access) = access.upgrade() {
access.stop();
}
}
let worker = match self.worker.lock() {
Ok(mut worker) => worker.take(),
Err(mut poisoned) => poisoned.get_mut().take(),
};
if let Some(worker) = worker {
let _ = worker.join();
}
}
}
#[derive(Debug)]
struct RepositoryAccessExpiryRegistration {
scheduler: Weak<RepositoryAccessExpirySchedulerInner>,
lease_id: u64,
generation: u64,
}
impl Drop for RepositoryAccessExpiryRegistration {
fn drop(&mut self) {
if let Some(scheduler) = self.scheduler.upgrade() {
RepositoryAccessExpiryScheduler::cancel_active(
&scheduler,
self.lease_id,
self.generation,
);
}
}
}
fn repository_access_now_epoch_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RuntimeGitMaterializer { pub struct RuntimeGitMaterializer {
runtime_root: PathBuf, runtime_root: PathBuf,
repository_access: Arc<Mutex<HashMap<String, RepositorySshMaterializationAccess>>>, repository_access: RepositoryAccessExpiryScheduler,
} }
impl RuntimeGitMaterializer { impl RuntimeGitMaterializer {
pub fn new(runtime_root: impl Into<PathBuf>) -> Self { pub fn new(runtime_root: impl Into<PathBuf>) -> Self {
Self { Self {
runtime_root: runtime_root.into(), runtime_root: runtime_root.into(),
repository_access: Arc::new(Mutex::new(HashMap::new())), repository_access: RepositoryAccessExpiryScheduler::new(),
} }
} }
@@ -366,53 +796,8 @@ impl RuntimeGitMaterializer {
ssh: &RepositorySshMaterializationAccess, ssh: &RepositorySshMaterializationAccess,
) -> Result<(), WorkingDirectoryDiagnostic> { ) -> Result<(), WorkingDirectoryDiagnostic> {
validate_ssh_materialization_access(ssh)?; validate_ssh_materialization_access(ssh)?;
let working_directory_id = working_directory_id.to_string();
let credential_candidates = ssh
.credential_candidates
.iter()
.map(|candidate| {
(
candidate.credential_id.clone(),
candidate.credential_revision,
)
})
.collect::<Vec<_>>();
let expires_at = ssh.expires_at_epoch_seconds;
self.repository_access self.repository_access
.lock() .store_pending(working_directory_id, ssh.clone())?;
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is unavailable",
)
})?
.insert(working_directory_id.clone(), ssh.clone());
let repository_access = self.repository_access.clone();
std::thread::spawn(move || {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if expires_at > now {
std::thread::sleep(Duration::from_secs(expires_at - now));
}
if let Ok(mut access) = repository_access.lock()
&& access.get(&working_directory_id).is_some_and(|access| {
access
.credential_candidates
.iter()
.map(|candidate| {
(
candidate.credential_id.clone(),
candidate.credential_revision,
)
})
.eq(credential_candidates.iter().cloned())
})
{
access.remove(&working_directory_id);
}
});
Ok(()) Ok(())
} }
@@ -421,16 +806,7 @@ impl RuntimeGitMaterializer {
working_directory_id: &str, working_directory_id: &str,
mut binding: WorkingDirectoryBinding, mut binding: WorkingDirectoryBinding,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> { ) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
let access = self let access = self.repository_access.take_pending(working_directory_id)?;
.repository_access
.lock()
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is unavailable",
)
})?
.remove(working_directory_id);
let Some(access) = access else { let Some(access) = access else {
if binding if binding
.working_directory .working_directory
@@ -453,20 +829,9 @@ impl RuntimeGitMaterializer {
&binding.working_directory.repository_id, &binding.working_directory.repository_id,
&access, &access,
)?); )?);
let weak_access = Arc::downgrade(&command_access); let expiry_registration = self
let expires_at = access.expires_at_epoch_seconds; .repository_access
std::thread::spawn(move || { .register_active(&command_access, access.expires_at_epoch_seconds)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if expires_at > now {
std::thread::sleep(Duration::from_secs(expires_at - now));
}
if let Some(access) = weak_access.upgrade() {
access.stop();
}
});
binding binding
.command_environment .command_environment
.insert("SSH_AUTH_SOCK".to_string(), "/dev/null".to_string()); .insert("SSH_AUTH_SOCK".to_string(), "/dev/null".to_string());
@@ -482,10 +847,25 @@ impl RuntimeGitMaterializer {
} }
.to_string(), .to_string(),
); );
binding
.session_resources
.push(Arc::new(expiry_registration));
binding.session_resources.push(command_access); binding.session_resources.push(command_access);
Ok(binding) Ok(binding)
} }
fn validate_plain_http_source(
request: &WorkingDirectoryRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
if url::Url::parse(&request.repository.source.uri).is_ok_and(|url| url.scheme() == "http") {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_repository_plain_http_unsupported",
"plain HTTP Repository sources are not executable; register an HTTPS or SSH source instead",
));
}
Ok(())
}
fn validate_request( fn validate_request(
request: &WorkingDirectoryRequest, request: &WorkingDirectoryRequest,
) -> Result<(), WorkingDirectoryDiagnostic> { ) -> Result<(), WorkingDirectoryDiagnostic> {
@@ -501,11 +881,10 @@ impl RuntimeGitMaterializer {
"the configured Repository provider is unsupported", "the configured Repository provider is unsupported",
)); ));
} }
Self::validate_plain_http_source(request)?;
if matches!( if matches!(
request.repository.source.kind, request.repository.source.kind,
workspace_api::RepositorySourceKind::Https workspace_api::RepositorySourceKind::Https | workspace_api::RepositorySourceKind::Ssh
| workspace_api::RepositorySourceKind::Http
| workspace_api::RepositorySourceKind::Ssh
) { ) {
validate_remote_source_uri(request)?; validate_remote_source_uri(request)?;
let materialization = request.materialization.as_ref().ok_or_else(|| { let materialization = request.materialization.as_ref().ok_or_else(|| {
@@ -529,8 +908,7 @@ impl RuntimeGitMaterializer {
match request.repository.source.kind { match request.repository.source.kind {
workspace_api::RepositorySourceKind::LocalPath workspace_api::RepositorySourceKind::LocalPath
| workspace_api::RepositorySourceKind::File | workspace_api::RepositorySourceKind::File
| workspace_api::RepositorySourceKind::Https | workspace_api::RepositorySourceKind::Https => {}
| workspace_api::RepositorySourceKind::Http => {}
workspace_api::RepositorySourceKind::Ssh => { workspace_api::RepositorySourceKind::Ssh => {
let ssh = request let ssh = request
.materialization .materialization
@@ -581,15 +959,7 @@ impl RuntimeGitMaterializer {
} }
let access = self let access = self
.repository_access .repository_access
.lock() .pending(working_directory_id)?
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_unavailable",
"Runtime Repository access state is unavailable",
)
})?
.get(working_directory_id)
.cloned()
.ok_or_else(|| { .ok_or_else(|| {
WorkingDirectoryDiagnostic::new( WorkingDirectoryDiagnostic::new(
"working_directory_remote_repository_access_required", "working_directory_remote_repository_access_required",
@@ -622,6 +992,7 @@ impl RuntimeGitMaterializer {
request: &WorkingDirectoryRequest, request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> { ) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
validate_working_directory_id(&working_directory_id)?; validate_working_directory_id(&working_directory_id)?;
Self::validate_plain_http_source(request)?;
let request = let request =
self.request_with_authorized_repository_access(&working_directory_id, request)?; self.request_with_authorized_repository_access(&working_directory_id, request)?;
Self::validate_request(&request)?; Self::validate_request(&request)?;
@@ -723,8 +1094,6 @@ impl RuntimeGitMaterializer {
host_trust_revision: context host_trust_revision: context
.and_then(|value| value.ssh.as_ref()) .and_then(|value| value.ssh.as_ref())
.map(|value| value.host_trust_revision), .map(|value| value.host_trust_revision),
transport_warning: repository_transport_warning(request.repository.source.kind)
.map(str::to_string),
}, },
cleanup_target: WorkingDirectoryCleanupTarget { cleanup_target: WorkingDirectoryCleanupTarget {
kind: "runtime_git_clone".to_string(), kind: "runtime_git_clone".to_string(),
@@ -1049,8 +1418,10 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
session_resources: Vec::new(), session_resources: Vec::new(),
}; };
let _ = self.write_record(&updated); let _ = self.write_record(&updated);
} else if let Ok(mut access) = self.repository_access.lock() { } else {
access.remove(&binding.working_directory.id); let _ = self
.repository_access
.remove_pending(&binding.working_directory.id);
} }
remove_result remove_result
} }
@@ -2029,10 +2400,6 @@ fn validate_ssh_materialization_access(
Ok(()) Ok(())
} }
fn repository_transport_warning(kind: workspace_api::RepositorySourceKind) -> Option<&'static str> {
(kind == workspace_api::RepositorySourceKind::Http).then_some("plain_http_transport")
}
fn validate_remote_source_uri( fn validate_remote_source_uri(
request: &WorkingDirectoryRequest, request: &WorkingDirectoryRequest,
) -> Result<(), WorkingDirectoryDiagnostic> { ) -> Result<(), WorkingDirectoryDiagnostic> {
@@ -2074,12 +2441,10 @@ fn validate_remote_source_uri(
"remote Repository source URI is invalid", "remote Repository source URI is invalid",
) )
})?; })?;
let expected_scheme = match request.repository.source.kind { if request.repository.source.kind != workspace_api::RepositorySourceKind::Https {
workspace_api::RepositorySourceKind::Https => "https", return Ok(());
workspace_api::RepositorySourceKind::Http => "http", }
_ => return Ok(()), if url.scheme() != "https"
};
if url.scheme() != expected_scheme
|| url.host_str().is_none() || url.host_str().is_none()
|| url.password().is_some() || url.password().is_some()
|| url.query().is_some() || url.query().is_some()
@@ -2212,10 +2577,23 @@ fn read_bounded_command_output(mut reader: impl Read) -> Vec<u8> {
captured captured
} }
#[cfg(test)]
thread_local! {
static TEST_REPOSITORY_GIT_INVOCATIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn test_repository_git_invocation_count() -> usize {
TEST_REPOSITORY_GIT_INVOCATIONS.with(std::cell::Cell::get)
}
fn run_repository_git_stdout( fn run_repository_git_stdout(
mut command: Command, mut command: Command,
source_kind: workspace_api::RepositorySourceKind, source_kind: workspace_api::RepositorySourceKind,
) -> Result<String, WorkingDirectoryDiagnostic> { ) -> Result<String, WorkingDirectoryDiagnostic> {
#[cfg(test)]
TEST_REPOSITORY_GIT_INVOCATIONS.with(|count| count.set(count.get().saturating_add(1)));
command command
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@@ -2306,6 +2684,9 @@ fn run_repository_git(
mut command: Command, mut command: Command,
code: &'static str, code: &'static str,
) -> Result<(), WorkingDirectoryDiagnostic> { ) -> Result<(), WorkingDirectoryDiagnostic> {
#[cfg(test)]
TEST_REPOSITORY_GIT_INVOCATIONS.with(|count| count.set(count.get().saturating_add(1)));
tracing::info!( tracing::info!(
target: "yoi::repository_access", target: "yoi::repository_access",
event = "repository_git_operation_started", event = "repository_git_operation_started",
@@ -2751,6 +3132,155 @@ mod tests {
} }
} }
fn fake_repository_command_access(root: &Path) -> (Arc<RepositoryCommandAccess>, PathBuf) {
let access_root = root.join("command-access");
let agent_root = root.join("agent");
fs::create_dir_all(&access_root).unwrap();
fs::create_dir_all(&agent_root).unwrap();
let socket = agent_root.join("agent.sock");
fs::write(&socket, b"socket marker").unwrap();
let access = Arc::new(RepositoryCommandAccess {
root: access_root.clone(),
ssh_command: access_root.join("ssh-command"),
agent: Arc::new(RepositorySshAgent {
root: agent_root,
socket,
child: Mutex::new(None),
}),
ssh_broker: None,
});
(access, access_root)
}
#[test]
fn repository_access_expiry_fences_a_stale_pending_generation_after_refresh() {
let scheduler = RepositoryAccessExpiryScheduler::new();
let working_directory_id = "working-directory-refresh";
let old_expiry = repository_access_now_epoch_seconds() + 60;
let mut old_access = repository_ssh_access();
old_access.expires_at_epoch_seconds = old_expiry;
scheduler
.store_pending(working_directory_id, old_access)
.unwrap();
let old_generation = scheduler
.pending_generation(working_directory_id)
.expect("old pending generation");
let barrier = Arc::new(std::sync::Barrier::new(2));
let refresh_barrier = Arc::clone(&barrier);
let refresh_scheduler = scheduler.clone();
let refresh = std::thread::spawn(move || {
let mut refreshed_access = repository_ssh_access();
refreshed_access.expires_at_epoch_seconds = old_expiry + 60;
refresh_barrier.wait();
refresh_scheduler
.store_pending(working_directory_id, refreshed_access)
.unwrap();
});
barrier.wait();
refresh.join().unwrap();
let refreshed_generation = scheduler
.pending_generation(working_directory_id)
.expect("refreshed pending generation");
assert_ne!(old_generation, refreshed_generation);
scheduler.expire_pending_for_test(working_directory_id, old_generation, old_expiry);
let refreshed = scheduler
.pending(working_directory_id)
.unwrap()
.expect("stale expiry must retain refreshed access");
assert_eq!(refreshed.expires_at_epoch_seconds, old_expiry + 60);
}
#[test]
fn repository_access_expiry_uses_one_worker_for_many_live_leases() {
let scheduler = RepositoryAccessExpiryScheduler::new();
for index in 0..1_024 {
scheduler
.store_pending(
&format!("working-directory-{index}"),
repository_ssh_access(),
)
.unwrap();
}
assert_eq!(scheduler.worker_start_count(), 1);
assert_eq!(scheduler.pending_count(), 1_024);
for index in 0..512 {
assert!(
scheduler
.take_pending(&format!("working-directory-{index}"))
.unwrap()
.is_some()
);
}
for index in 512..1_024 {
scheduler
.remove_pending(&format!("working-directory-{index}"))
.unwrap();
}
assert_eq!(scheduler.pending_count(), 0);
assert_eq!(scheduler.worker_start_count(), 1);
}
#[test]
fn repository_access_expiry_unregisters_active_leases_on_session_close() {
let scheduler = RepositoryAccessExpiryScheduler::new();
let root = tempfile::tempdir().unwrap();
let (access, access_root) = fake_repository_command_access(root.path());
let registration = scheduler.register_active(&access, u64::MAX).unwrap();
assert_eq!(scheduler.active_count(), 1);
drop(registration);
assert_eq!(scheduler.active_count(), 0);
assert!(access_root.exists());
drop(access);
assert!(!access_root.exists());
}
#[test]
fn repository_access_expiry_expires_pending_and_active_leases() {
let scheduler = RepositoryAccessExpiryScheduler::new();
let expiry = repository_access_now_epoch_seconds() + 1;
let mut pending = repository_ssh_access();
pending.expires_at_epoch_seconds = expiry;
scheduler
.store_pending("working-directory-expiring", pending)
.unwrap();
let root = tempfile::tempdir().unwrap();
let (access, access_root) = fake_repository_command_access(root.path());
let registration = scheduler.register_active(&access, expiry).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while (scheduler.pending_count() != 0
|| scheduler.active_count() != 0
|| access_root.exists())
&& std::time::Instant::now() < deadline
{
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(scheduler.pending_count(), 0);
assert_eq!(scheduler.active_count(), 0);
assert!(!access_root.exists());
drop(registration);
drop(access);
}
#[test]
fn repository_access_expiry_shutdown_revokes_and_joins() {
let scheduler = RepositoryAccessExpiryScheduler::new();
let scheduler_inner = Arc::downgrade(&scheduler.inner);
let root = tempfile::tempdir().unwrap();
let (access, access_root) = fake_repository_command_access(root.path());
let registration = scheduler.register_active(&access, u64::MAX).unwrap();
drop(scheduler);
assert!(scheduler_inner.upgrade().is_none());
assert!(!access_root.exists());
drop(registration);
drop(access);
}
fn git(path: &Path, args: &[&str]) { fn git(path: &Path, args: &[&str]) {
let status = Command::new("git") let status = Command::new("git")
.arg("-C") .arg("-C")
@@ -3778,16 +4308,54 @@ mod tests {
); );
let mut http = request(repo.path()); let mut http = request(repo.path());
http.repository.source = workspace_api::RepositorySource { http.repository.source = serde_json::from_value(serde_json::json!({
kind: workspace_api::RepositorySourceKind::Http, "kind": "http",
uri: "http://example.test/repo.git".to_string(), "uri": "http://example.test/repo.git",
}; }))
.unwrap();
http.materialization = Some(context(None)); http.materialization = Some(context(None));
RuntimeGitMaterializer::validate_request(&http).unwrap(); let git_invocations_before = test_repository_git_invocation_count();
let error = materializer
.materialize(&worker_ref(3), &http)
.expect_err("historical plain HTTP source must fail before Git execution");
assert_eq!( assert_eq!(
repository_transport_warning(http.repository.source.kind), error.code,
Some("plain_http_transport") "working_directory_repository_plain_http_unsupported"
); );
assert!(error.message.contains("HTTPS or SSH"));
assert_eq!(
test_repository_git_invocation_count(),
git_invocations_before,
"plain HTTP rejection must occur before invoking Git"
);
for (offset, kind) in [
workspace_api::RepositorySourceKind::LocalPath,
workspace_api::RepositorySourceKind::File,
workspace_api::RepositorySourceKind::Https,
]
.into_iter()
.enumerate()
{
let mut mismatched_http = request(repo.path());
mismatched_http.repository.source = workspace_api::RepositorySource {
kind,
uri: "http://example.test/repo.git".to_string(),
};
let git_invocations_before = test_repository_git_invocation_count();
let error = materializer
.materialize(&worker_ref(4 + offset as u64), &mismatched_http)
.expect_err("plain HTTP URI must fail regardless of its declared source kind");
assert_eq!(
error.code,
"working_directory_repository_plain_http_unsupported"
);
assert_eq!(
test_repository_git_invocation_count(),
git_invocations_before,
"mismatched plain HTTP source must fail before invoking Git"
);
}
let mut ssh = request(repo.path()); let mut ssh = request(repo.path());
ssh.repository.source = workspace_api::RepositorySource { ssh.repository.source = workspace_api::RepositorySource {
@@ -0,0 +1,360 @@
use std::error::Error as _;
use std::io::Read;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use futures::StreamExt;
use reqwest::header::HeaderMap;
use thiserror::Error;
use crate::auth::{
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
RuntimeRequestSourceSigner,
};
const DEFAULT_REQUEST_PROOF_TTL_SECONDS: u64 = 60;
const RUNTIME_ID_HEADER: &str = "x-yoi-runtime-id";
const WORKER_ID_HEADER: &str = "x-yoi-worker-id";
#[derive(Clone, Debug)]
pub struct RuntimeWorkspaceRequestClient {
workspace_id: String,
base_url: String,
runtime_id: String,
request_source: Option<(RuntimeRequestSourceSigner, String)>,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeWorkspaceRequest {
pub method: reqwest::Method,
pub path_and_query: String,
pub body: Vec<u8>,
pub headers: HeaderMap,
pub permission: String,
pub worker_id: Option<String>,
pub timeout: Option<Duration>,
pub max_response_bytes: usize,
}
#[derive(Debug)]
pub(crate) struct RuntimeWorkspaceResponse {
pub status: reqwest::StatusCode,
pub headers: HeaderMap,
pub body: Vec<u8>,
}
#[derive(Debug, Error)]
pub(crate) enum RuntimeWorkspaceRequestError {
#[error("invalid Workspace request: {0}")]
InvalidRequest(String),
#[error("failed to sign Workspace request: {0}")]
Sign(#[from] RuntimeAuthError),
#[error("Workspace request failed: {message}")]
Transport { message: String, timeout: bool },
#[error("Workspace response exceeded {max_response_bytes} bytes")]
ResponseTooLarge { max_response_bytes: usize },
}
impl RuntimeWorkspaceRequestError {
fn transport(error: reqwest::Error) -> Self {
let timeout = error.is_timeout();
Self::Transport {
message: reqwest_error_chain(&error),
timeout,
}
}
pub(crate) fn is_timeout(&self) -> bool {
matches!(self, Self::Transport { timeout: true, .. })
}
}
impl RuntimeWorkspaceRequestClient {
pub fn new(
workspace_id: impl Into<String>,
base_url: impl Into<String>,
runtime_id: impl Into<String>,
) -> Self {
Self {
workspace_id: workspace_id.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
runtime_id: runtime_id.into(),
request_source: None,
}
}
pub fn with_runtime_request_source(
mut self,
identity: &RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.request_source = Some((
RuntimeRequestSourceSigner::from_identity(identity),
audience.into(),
));
self
}
pub fn workspace_id(&self) -> &str {
&self.workspace_id
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn runtime_id(&self) -> &str {
&self.runtime_id
}
pub fn audience(&self) -> Option<&str> {
self.request_source
.as_ref()
.map(|(_, audience)| audience.as_str())
}
pub fn matches_workspace(&self, workspace_id: &str, base_url: &str) -> bool {
self.workspace_id == workspace_id
&& self.base_url.trim_end_matches('/') == base_url.trim_end_matches('/')
}
pub(crate) async fn execute(
&self,
request: RuntimeWorkspaceRequest,
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
let prepared = self.prepare(&request)?;
let mut client_builder = reqwest::Client::builder();
if let Some(timeout) = request.timeout {
client_builder = client_builder.timeout(timeout);
}
let client = client_builder
.build()
.map_err(RuntimeWorkspaceRequestError::transport)?;
let mut builder = client
.request(request.method, prepared.url)
.headers(request.headers)
.header(RUNTIME_ID_HEADER, &self.runtime_id);
if let Some(worker_id) = request.worker_id.as_deref() {
builder = builder.header(WORKER_ID_HEADER, worker_id);
}
if let Some(proof) = prepared.proof {
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !request.body.is_empty() {
builder = builder.body(request.body);
}
let response = builder
.send()
.await
.map_err(RuntimeWorkspaceRequestError::transport)?;
let status = response.status();
let headers = response.headers().clone();
if response
.content_length()
.is_some_and(|size| size > request.max_response_bytes as u64)
{
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
max_response_bytes: request.max_response_bytes,
});
}
let mut body = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(RuntimeWorkspaceRequestError::transport)?;
if body.len().saturating_add(chunk.len()) > request.max_response_bytes {
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
max_response_bytes: request.max_response_bytes,
});
}
body.extend_from_slice(&chunk);
}
Ok(RuntimeWorkspaceResponse {
status,
headers,
body,
})
}
pub(crate) fn execute_blocking(
&self,
request: RuntimeWorkspaceRequest,
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
let client = self.clone();
std::thread::spawn(move || client.execute_blocking_inner(request))
.join()
.map_err(|_| RuntimeWorkspaceRequestError::Transport {
message: "Workspace request thread panicked".to_string(),
timeout: false,
})?
}
fn execute_blocking_inner(
&self,
request: RuntimeWorkspaceRequest,
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
let prepared = self.prepare(&request)?;
let mut client_builder = reqwest::blocking::Client::builder();
if let Some(timeout) = request.timeout {
client_builder = client_builder.timeout(timeout);
}
let client = client_builder
.build()
.map_err(RuntimeWorkspaceRequestError::transport)?;
let mut builder = client
.request(request.method, prepared.url)
.headers(request.headers)
.header(RUNTIME_ID_HEADER, &self.runtime_id);
if let Some(worker_id) = request.worker_id.as_deref() {
builder = builder.header(WORKER_ID_HEADER, worker_id);
}
if let Some(proof) = prepared.proof {
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !request.body.is_empty() {
builder = builder.body(request.body);
}
let response = builder
.send()
.map_err(RuntimeWorkspaceRequestError::transport)?;
let status = response.status();
let headers = response.headers().clone();
if response
.content_length()
.is_some_and(|size| size > request.max_response_bytes as u64)
{
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
max_response_bytes: request.max_response_bytes,
});
}
let limit = u64::try_from(request.max_response_bytes)
.unwrap_or(u64::MAX)
.saturating_add(1);
let mut body = Vec::new();
response
.take(limit)
.read_to_end(&mut body)
.map_err(|error| RuntimeWorkspaceRequestError::Transport {
message: error.to_string(),
timeout: false,
})?;
if body.len() > request.max_response_bytes {
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
max_response_bytes: request.max_response_bytes,
});
}
Ok(RuntimeWorkspaceResponse {
status,
headers,
body,
})
}
fn prepare(
&self,
request: &RuntimeWorkspaceRequest,
) -> Result<PreparedRuntimeWorkspaceRequest, RuntimeWorkspaceRequestError> {
if !request.path_and_query.starts_with('/') || request.path_and_query.starts_with("//") {
return Err(RuntimeWorkspaceRequestError::InvalidRequest(
"path must start with '/'".to_string(),
));
}
let url = reqwest::Url::parse(&format!("{}{}", self.base_url, request.path_and_query))
.map_err(|error| RuntimeWorkspaceRequestError::InvalidRequest(error.to_string()))?;
let mut request_target = url.path().to_string();
if let Some(query) = url.query() {
request_target.push('?');
request_target.push_str(query);
}
let proof = self
.request_source
.as_ref()
.map(|(signer, audience)| {
signer.issue(
audience,
&self.workspace_id,
request.worker_id.as_deref(),
&request.permission,
request.method.as_str(),
&request_target,
&request.body,
unix_now_seconds(),
DEFAULT_REQUEST_PROOF_TTL_SECONDS,
)
})
.transpose()?;
Ok(PreparedRuntimeWorkspaceRequest { url, proof })
}
}
struct PreparedRuntimeWorkspaceRequest {
url: reqwest::Url,
proof: Option<String>,
}
fn reqwest_error_chain(error: &reqwest::Error) -> String {
let mut message = error.to_string();
let mut source = error.source();
while let Some(error) = source {
message.push_str(": ");
message.push_str(&error.to_string());
source = error.source();
}
message
}
fn unix_now_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX))
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::decode_runtime_request_source_claims;
#[test]
fn route_issues_workspace_scoped_request_proof() {
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let client = RuntimeWorkspaceRequestClient::new(
"workspace-a",
"https://workspace.example.test/",
"runtime-a",
)
.with_runtime_request_source(&identity, "workspace-server-a");
let request = RuntimeWorkspaceRequest {
method: reqwest::Method::GET,
path_and_query: "/api/w/workspace-a/runtime-config?profile=coder".to_string(),
body: Vec::new(),
headers: HeaderMap::new(),
permission: "backend.resource.fetch".to_string(),
worker_id: None,
timeout: Some(Duration::from_secs(5)),
max_response_bytes: 1024,
};
let prepared = client.prepare(&request).unwrap();
let claims = decode_runtime_request_source_claims(&prepared.proof.unwrap()).unwrap();
assert_eq!(claims.aud, "workspace-server-a");
assert_eq!(claims.workspace_id, "workspace-a");
assert_eq!(claims.worker_id, None);
assert_eq!(claims.method, "GET");
assert_eq!(
claims.path,
"/api/w/workspace-a/runtime-config?profile=coder"
);
}
#[test]
fn route_matches_only_its_workspace_and_backend() {
let client = RuntimeWorkspaceRequestClient::new(
"workspace-a",
"https://workspace.example.test/",
"runtime-a",
);
assert!(client.matches_workspace("workspace-a", "https://workspace.example.test"));
assert!(!client.matches_workspace("workspace-b", "https://workspace.example.test"));
assert!(!client.matches_workspace("workspace-a", "https://other.example.test"));
}
}
-3
View File
@@ -43,7 +43,6 @@ memory = { workspace = true }
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true } session-metrics = { workspace = true }
arc-swap = "1.9.1" arc-swap = "1.9.1"
wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] }
tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] } tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] }
tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] } tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] }
futures-util = { version = "0.3", features = ["sink"] } futures-util = { version = "0.3", features = ["sink"] }
@@ -53,5 +52,3 @@ dotenv = "0.15.0"
futures = { workspace = true } futures = { workspace = true }
serial_test = "3.4.0" serial_test = "3.4.0"
tempfile = { workspace = true } tempfile = { workspace = true }
wat = "1.241.2"
yoi-plugin-pdk = { workspace = true }
+8 -3
View File
@@ -67,6 +67,7 @@ pub struct PreparedWorker<C: LlmClient, St: Store> {
pub struct BootstrappedWorker { pub struct BootstrappedWorker {
pub handle: WorkerHandle, pub handle: WorkerHandle,
pub shutdown: ShutdownReceiver, pub shutdown: ShutdownReceiver,
pub controller_task: tokio::task::JoinHandle<()>,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -227,7 +228,7 @@ where
runtime_base, runtime_base,
bash_output_dir, bash_output_dir,
} => { } => {
WorkerController::spawn_with_transport( WorkerController::spawn_with_transport_owned(
worker, worker,
&runtime_base, &runtime_base,
&bash_output_dir, &bash_output_dir,
@@ -239,7 +240,7 @@ where
run_dir, run_dir,
bash_output_dir, bash_output_dir,
} => { } => {
WorkerController::spawn_runtime_managed_run_with_transport( WorkerController::spawn_runtime_managed_run_with_transport_owned(
worker, worker,
&run_dir, &run_dir,
&bash_output_dir, &bash_output_dir,
@@ -250,7 +251,11 @@ where
}; };
match controller { match controller {
Ok((handle, shutdown)) => Ok(BootstrappedWorker { handle, shutdown }), Ok((handle, shutdown, controller_task)) => Ok(BootstrappedWorker {
handle,
shutdown,
controller_task,
}),
Err(source) => { Err(source) => {
let cleanup_failed = match cleanup_session { let cleanup_failed = match cleanup_session {
Some(session) => session.close().await.is_err(), Some(session) => session.close().await.is_err(),
+1
View File
@@ -1,6 +1,7 @@
pub(crate) mod metrics_tracker; pub(crate) mod metrics_tracker;
pub(crate) mod prune; pub(crate) mod prune;
pub(crate) mod state; pub(crate) mod state;
pub(crate) mod telemetry;
pub(crate) mod token_counter; pub(crate) mod token_counter;
pub(crate) mod usage_tracker; pub(crate) mod usage_tracker;
pub(crate) mod worker; pub(crate) mod worker;
+309 -140
View File
@@ -1,109 +1,236 @@
//! Shared state for compaction decisions. use std::sync::Mutex;
//!
//! Holds the two configured thresholds and circuit-breaker / thrash-detection
//! flags shared between:
//! - `WorkerInterceptor` (reads `request_threshold` — the *safety net* for
//! between-requests yielding)
//! - `Worker::try_pre_run_compact` (reads `post_run_threshold` — the
//! *proactive* check before the next turn starts)
//! - `Worker::run()` / `resume()` (circuit breaker, thrash detection)
//!
//! Current occupancy (input-token count) is **not** stored here. The single
//! source of truth is `session_store::UsageRecord` (persisted per LLM call)
//! projected through `Worker::total_tokens()`. Callers pass the current
//! occupancy to `exceeds_*` at check time.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use super::telemetry::CompactFailureCategory;
const MAX_COMPACT_FAILURES: usize = 3; /// Process-local automatic compaction guard for the current logical run.
///
/// This guard is deliberately not persisted or reconstructed from session
/// history, compaction metrics, or replacement-segment state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomaticCompactGuard {
Ready,
SuppressedForCurrentRun {
failure_category: CompactFailureCategory,
},
AwaitingPostCompactRequest,
}
/// Shared mutable state for compaction decisions. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactionOutcome {
Succeeded,
Failed(CompactFailureCategory),
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomaticCompactTrigger {
PreRun,
RequestThreshold,
}
/// Decision returned by an atomic threshold/attempt-state evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomaticCompactDecision {
Continue,
Start(AutomaticCompactTrigger),
Block(AutomaticCompactBlock),
}
/// Typed reason why a provider request may not proceed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomaticCompactBlock {
/// An automatic attempt was already claimed for this logical run and has
/// not yet produced an outcome.
Attempted,
/// Compaction succeeded, but no post-compaction provider request has yet
/// committed a new occupancy UsageRecord.
Thrash,
/// This logical run already used its automatic attempt and it failed.
Failed(CompactFailureCategory),
/// This logical run's automatic attempt was cancelled. Cancellation is not
/// classified or counted as a compaction failure.
Cancelled,
}
#[derive(Debug)]
struct AutomaticCompactRuntimeState {
guard: AutomaticCompactGuard,
attempt_claimed: bool,
cancelled_attempt: bool,
pending_request_block: Option<AutomaticCompactBlock>,
}
/// Tracks automatic compaction thresholds and the current logical-run guard.
#[derive(Debug)]
pub(crate) struct CompactState { pub(crate) struct CompactState {
/// Between-turns threshold (proactive). Checked before the next turn /// Proactive threshold checked before a fresh user run starts.
/// starts. `None` disables the pre-run check. compact_threshold: Option<u64>,
post_run_threshold: Option<u64>, /// Safety threshold checked immediately before every provider request.
/// Between-requests threshold (safety net). Checked inside a turn
/// before each LLM request. `None` disables the request check.
request_threshold: Option<u64>, request_threshold: Option<u64>,
/// Token budget retained verbatim at the tail after compaction.
retained_tokens: u64, retained_tokens: u64,
/// Consecutive compact failures. At `MAX_COMPACT_FAILURES`, compaction is disabled. runtime: Mutex<AutomaticCompactRuntimeState>,
consecutive_failures: AtomicUsize,
/// `true` immediately after a successful compact, cleared on next normal completion.
just_compacted: AtomicBool,
/// `true` when circuit breaker has tripped.
disabled: AtomicBool,
} }
impl CompactState { impl CompactState {
pub(crate) fn new( pub(crate) fn new(
post_run_threshold: Option<u64>, compact_threshold: Option<u64>,
request_threshold: Option<u64>, request_threshold: Option<u64>,
retained_tokens: u64, retained_tokens: u64,
) -> Self { ) -> Self {
Self { Self {
post_run_threshold, compact_threshold,
request_threshold, request_threshold,
retained_tokens, retained_tokens,
consecutive_failures: AtomicUsize::new(0), runtime: Mutex::new(AutomaticCompactRuntimeState {
just_compacted: AtomicBool::new(false), guard: AutomaticCompactGuard::Ready,
disabled: AtomicBool::new(false), attempt_claimed: false,
cancelled_attempt: false,
pending_request_block: None,
}),
} }
} }
/// Configured between-requests threshold (if any).
pub(crate) fn request_threshold(&self) -> Option<u64> {
self.request_threshold
}
/// Token budget retained verbatim at the tail after compaction.
pub(crate) fn retained_tokens(&self) -> u64 { pub(crate) fn retained_tokens(&self) -> u64 {
self.retained_tokens self.retained_tokens
} }
/// Whether compaction has been disabled by the circuit breaker. pub(crate) fn pre_run_eligible(&self, total_tokens: u64) -> bool {
pub(crate) fn is_disabled(&self) -> bool { if !self
self.disabled.load(Ordering::Relaxed) .compact_threshold
} .is_some_and(|threshold| total_tokens > threshold)
{
/// Whether `current_tokens` exceeds the between-requests threshold. return false;
/// Returns `false` when `request_threshold` is unset.
pub(crate) fn exceeds_request(&self, current_tokens: u64) -> bool {
self.request_threshold
.map(|t| current_tokens > t)
.unwrap_or(false)
}
/// Whether `current_tokens` exceeds the post-run threshold.
/// Returns `false` when `post_run_threshold` is unset.
pub(crate) fn exceeds_post_run(&self, current_tokens: u64) -> bool {
self.post_run_threshold
.map(|t| current_tokens > t)
.unwrap_or(false)
}
/// Whether a compact just completed (for thrash detection).
pub(crate) fn just_compacted(&self) -> bool {
self.just_compacted.load(Ordering::Relaxed)
}
/// Set or clear the just_compacted flag.
pub(crate) fn set_just_compacted(&self, val: bool) {
self.just_compacted.store(val, Ordering::Relaxed);
}
/// Record a successful compaction: reset failure counter, set just_compacted.
pub(crate) fn record_compact_success(&self) {
self.consecutive_failures.store(0, Ordering::Relaxed);
self.just_compacted.store(true, Ordering::Relaxed);
}
/// Record a compaction failure. Disables compaction after MAX_COMPACT_FAILURES.
pub(crate) fn record_compact_failure(&self) {
let prev = self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
if prev + 1 >= MAX_COMPACT_FAILURES {
self.disabled.store(true, Ordering::Relaxed);
} }
let runtime = self.lock_runtime();
runtime.guard == AutomaticCompactGuard::Ready && !runtime.attempt_claimed
}
/// Starts a fresh logical run. Pause/resume paths must not call this.
pub(crate) fn begin_logical_run(&self) {
self.clear_logical_run();
}
/// Clears per-run state after a terminal run outcome.
pub(crate) fn finish_logical_run(&self) {
self.clear_logical_run();
}
/// Atomically evaluates the proactive threshold and claims this logical
/// run's automatic attempt when eligible.
pub(crate) fn evaluate_pre_run(&self, total_tokens: u64) -> AutomaticCompactDecision {
if !self
.compact_threshold
.is_some_and(|threshold| total_tokens > threshold)
{
return AutomaticCompactDecision::Continue;
}
self.claim_attempt(AutomaticCompactTrigger::PreRun)
}
/// Atomically evaluates the request safety threshold and either claims an
/// automatic attempt or returns the typed reason the request must stop.
pub(crate) fn evaluate_request(&self, total_tokens: u64) -> AutomaticCompactDecision {
if !self
.request_threshold
.is_some_and(|threshold| total_tokens > threshold)
{
return AutomaticCompactDecision::Continue;
}
self.claim_attempt(AutomaticCompactTrigger::RequestThreshold)
}
/// Claims a hook-originated compaction yield under the same guard used by
/// threshold evaluation. This exists even in manual-only configurations.
pub(crate) fn claim_hook_yield(&self) -> AutomaticCompactDecision {
self.claim_attempt(AutomaticCompactTrigger::RequestThreshold)
}
pub(crate) fn has_claimed_attempt(&self) -> bool {
self.lock_runtime().attempt_claimed
}
pub(crate) fn record_request_block(&self, block: AutomaticCompactBlock) {
self.lock_runtime().pending_request_block = Some(block);
}
/// Completes the currently claimed automatic attempt exactly once.
pub(crate) fn complete_automatic(&self, outcome: CompactionOutcome) -> bool {
let mut runtime = self.lock_runtime();
if !runtime.attempt_claimed
|| runtime.guard != AutomaticCompactGuard::Ready
|| runtime.cancelled_attempt
{
return false;
}
match outcome {
CompactionOutcome::Succeeded => {
runtime.guard = AutomaticCompactGuard::AwaitingPostCompactRequest;
}
CompactionOutcome::Failed(failure_category) => {
runtime.guard = AutomaticCompactGuard::SuppressedForCurrentRun { failure_category };
}
CompactionOutcome::Cancelled => {
runtime.cancelled_attempt = true;
}
}
true
}
/// Re-arms automatic compaction only after the first real provider request
/// following successful compaction has a durably committed UsageRecord.
pub(crate) fn post_compact_request_committed(&self) {
let mut runtime = self.lock_runtime();
if runtime.guard == AutomaticCompactGuard::AwaitingPostCompactRequest {
runtime.guard = AutomaticCompactGuard::Ready;
runtime.attempt_claimed = false;
runtime.cancelled_attempt = false;
}
}
pub(crate) fn take_pending_request_block(&self) -> Option<AutomaticCompactBlock> {
self.lock_runtime().pending_request_block.take()
}
fn claim_attempt(&self, trigger: AutomaticCompactTrigger) -> AutomaticCompactDecision {
let mut runtime = self.lock_runtime();
match runtime.guard {
AutomaticCompactGuard::Ready if !runtime.attempt_claimed => {
runtime.attempt_claimed = true;
AutomaticCompactDecision::Start(trigger)
}
AutomaticCompactGuard::Ready if runtime.cancelled_attempt => {
AutomaticCompactDecision::Block(AutomaticCompactBlock::Cancelled)
}
AutomaticCompactGuard::Ready => {
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
}
AutomaticCompactGuard::SuppressedForCurrentRun { failure_category } => {
AutomaticCompactDecision::Block(AutomaticCompactBlock::Failed(failure_category))
}
AutomaticCompactGuard::AwaitingPostCompactRequest => {
AutomaticCompactDecision::Block(AutomaticCompactBlock::Thrash)
}
}
}
fn clear_logical_run(&self) {
let mut runtime = self.lock_runtime();
runtime.guard = AutomaticCompactGuard::Ready;
runtime.attempt_claimed = false;
runtime.cancelled_attempt = false;
runtime.pending_request_block = None;
}
fn lock_runtime(&self) -> std::sync::MutexGuard<'_, AutomaticCompactRuntimeState> {
self.runtime
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
pub(crate) fn guard(&self) -> AutomaticCompactGuard {
self.lock_runtime().guard
} }
} }
@@ -111,81 +238,123 @@ impl CompactState {
mod tests { mod tests {
use super::*; use super::*;
const FAILURE: CompactFailureCategory = CompactFailureCategory::Storage;
#[test] #[test]
fn both_thresholds_configured() { fn automatic_failure_suppresses_only_current_logical_run() {
let state = CompactState::new(Some(80_000), Some(90_000), 8_000); let state = CompactState::new(Some(10), Some(10), 2);
assert_eq!(state.request_threshold(), Some(90_000)); assert_eq!(
assert_eq!(state.retained_tokens(), 8_000); state.evaluate_pre_run(11),
AutomaticCompactDecision::Start(AutomaticCompactTrigger::PreRun)
);
assert!(state.complete_automatic(CompactionOutcome::Failed(FAILURE)));
assert_eq!(
state.guard(),
AutomaticCompactGuard::SuppressedForCurrentRun {
failure_category: FAILURE
}
);
assert_eq!(
state.evaluate_request(10),
AutomaticCompactDecision::Continue,
"a failed proactive compact still permits a request below the safety threshold"
);
assert_eq!(
state.evaluate_request(11),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Failed(FAILURE))
);
assert!(!state.exceeds_request(70_000)); state.begin_logical_run();
assert!(!state.exceeds_post_run(70_000)); assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
assert_eq!(
assert!(!state.exceeds_request(85_000)); state.evaluate_pre_run(11),
assert!(state.exceeds_post_run(85_000)); AutomaticCompactDecision::Start(AutomaticCompactTrigger::PreRun)
);
assert!(state.exceeds_request(95_000));
assert!(state.exceeds_post_run(95_000));
} }
#[test] #[test]
fn post_run_only() { fn claimed_attempt_cannot_be_started_twice() {
let state = CompactState::new(Some(80_000), None, 8_000); let state = CompactState::new(Some(10), Some(10), 2);
// request check always false when threshold is None. assert!(matches!(
assert!(!state.exceeds_request(1_000_000)); state.evaluate_pre_run(11),
assert!(state.exceeds_post_run(85_000)); AutomaticCompactDecision::Start(_)
));
assert_eq!(
state.evaluate_request(11),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
);
} }
#[test] #[test]
fn request_only() { fn cancellation_consumes_run_attempt_without_becoming_failure() {
let state = CompactState::new(None, Some(90_000), 8_000); let state = CompactState::new(None, Some(10), 2);
assert!(!state.exceeds_post_run(1_000_000)); assert!(matches!(
assert!(state.exceeds_request(95_000)); state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
assert!(state.complete_automatic(CompactionOutcome::Cancelled));
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
assert_eq!(
state.evaluate_request(11),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Cancelled)
);
state.begin_logical_run();
assert!(matches!(
state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
} }
#[test] #[test]
fn both_none_disables_all_checks() { fn successful_compaction_requires_committed_request_before_rearming() {
let state = CompactState::new(None, None, 8_000); let state = CompactState::new(None, Some(10), 2);
assert!(!state.exceeds_request(1_000_000)); assert!(matches!(
assert!(!state.exceeds_post_run(1_000_000)); state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
assert!(state.complete_automatic(CompactionOutcome::Succeeded));
assert_eq!(
state.evaluate_request(11),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Thrash)
);
state.post_compact_request_committed();
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
assert!(matches!(
state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
} }
#[test] #[test]
fn circuit_breaker_trips_after_max_failures() { fn pause_resume_preserves_guard_while_terminal_finish_clears_it() {
let state = CompactState::new(Some(80_000), Some(90_000), 8_000); let state = CompactState::new(None, Some(10), 2);
assert!(!state.is_disabled()); assert!(matches!(
state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
assert!(state.complete_automatic(CompactionOutcome::Failed(FAILURE)));
// Pause/resume deliberately performs no state transition.
assert!(matches!(
state.guard(),
AutomaticCompactGuard::SuppressedForCurrentRun { .. }
));
state.record_compact_failure(); state.finish_logical_run();
assert!(!state.is_disabled()); assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
state.record_compact_failure();
assert!(!state.is_disabled());
state.record_compact_failure();
assert!(state.is_disabled());
} }
#[test] #[test]
fn success_resets_failure_count() { fn hook_yield_is_guarded_without_threshold_configuration() {
let state = CompactState::new(Some(80_000), Some(90_000), 8_000); let state = CompactState::new(None, None, 2);
state.record_compact_failure(); assert!(matches!(
state.record_compact_failure(); state.claim_hook_yield(),
assert!(!state.is_disabled()); AutomaticCompactDecision::Start(_)
));
state.record_compact_success(); assert_eq!(
assert!(state.just_compacted()); state.claim_hook_yield(),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
state.record_compact_failure(); );
state.record_compact_failure();
assert!(!state.is_disabled());
}
#[test]
fn just_compacted_lifecycle() {
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
assert!(!state.just_compacted());
state.record_compact_success();
assert!(state.just_compacted());
state.set_just_compacted(false);
assert!(!state.just_compacted());
} }
} }
+441
View File
@@ -0,0 +1,441 @@
use std::collections::BTreeMap;
use std::time::Duration;
use agen::token_counter::EstimateSource;
use agen::usage_record::UsageRecord;
use session_metrics::Metric;
use session_store::{SegmentId, SessionId};
use super::usage_tracker::{PostRequestMetric, UsageSnapshot};
const MAX_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactMode {
Manual,
Automatic,
}
impl CompactMode {
fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Automatic => "automatic",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactThresholdPolicy {
Manual,
PreRun,
RequestThreshold,
}
impl CompactThresholdPolicy {
fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::PreRun => "pre_run",
Self::RequestThreshold => "request_threshold",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactFailureCategory {
Cancelled,
SummaryMissing,
SummaryTooLarge,
ResultContextTooLarge,
ActiveSegmentCommit,
Storage,
InternalWorker,
Preparation,
Other,
}
impl CompactFailureCategory {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Cancelled => "cancelled",
Self::SummaryMissing => "summary_missing",
Self::SummaryTooLarge => "summary_too_large",
Self::ResultContextTooLarge => "result_context_too_large",
Self::ActiveSegmentCommit => "active_segment_commit",
Self::Storage => "storage",
Self::InternalWorker => "internal_worker",
Self::Preparation => "preparation",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct CompactAttempt {
correlation_id: String,
session_id: SessionId,
source_segment_id: SegmentId,
mode: CompactMode,
threshold_policy: CompactThresholdPolicy,
pre_context_tokens: u64,
pre_context_source: EstimateSource,
retained_token_budget: u64,
}
impl CompactAttempt {
pub(crate) fn new(
correlation_id: String,
session_id: SessionId,
source_segment_id: SegmentId,
mode: CompactMode,
threshold_policy: CompactThresholdPolicy,
pre_context_tokens: u64,
pre_context_source: EstimateSource,
retained_token_budget: u64,
) -> Self {
debug_assert!(uuid::Uuid::parse_str(&correlation_id).is_ok());
Self {
correlation_id,
session_id,
source_segment_id,
mode,
threshold_policy,
pre_context_tokens,
pre_context_source,
retained_token_budget,
}
}
pub(crate) fn correlation_id(&self) -> &str {
&self.correlation_id
}
pub(crate) fn start_metric(&self) -> Metric {
self.metric("compact.start")
.with_value(safe_metric_number(self.pre_context_tokens))
.with_dimension("occupancy_source", estimate_source(self.pre_context_source))
.with_dimension(
"retained_token_budget",
self.retained_token_budget.to_string(),
)
}
pub(crate) fn success_metrics(
&self,
result_segment_id: SegmentId,
elapsed: Duration,
stats: &CompactSuccessStats,
) -> Vec<Metric> {
let dimensions = self.base_dimensions();
let correlation_id = self.correlation_id.clone();
let mut metrics = vec![
metric_with_context("compact.finish", 1, &dimensions, &correlation_id)
.with_dimension("outcome", "succeeded")
.with_dimension("result_segment_id", result_segment_id.to_string())
.with_dimension("retained_items", stats.retained_items.to_string())
.with_dimension("summarized_items", stats.summarized_items.to_string()),
metric_with_context(
"compact.retained_tokens",
stats.retained_tokens,
&dimensions,
&correlation_id,
)
.with_dimension("source", estimate_source(stats.retained_tokens_source)),
metric_with_context(
"compact.overview_tokens",
stats.overview_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.summary_tokens",
stats.summary_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.auto_read_tokens",
stats.auto_read_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.result_context_tokens",
stats.result_context_tokens,
&dimensions,
&correlation_id,
)
.with_dimension("source", estimate_source(stats.result_context_source)),
metric_with_context(
"compact.input_tokens",
stats.usage.input_total_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.output_tokens",
stats.usage.output_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.cache_read_tokens",
stats.usage.cache_read_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.cache_creation_tokens",
stats.usage.cache_write_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.requests",
stats.requests,
&dimensions,
&correlation_id,
),
metric_with_context("compact.turns", stats.turns, &dimensions, &correlation_id),
metric_with_context(
"compact.tool_calls",
stats.tool_calls,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.duration_ms",
elapsed.as_millis().min(u128::from(MAX_SAFE_INTEGER)) as u64,
&dimensions,
&correlation_id,
),
];
for metric in &mut metrics {
metric
.dimensions
.insert("result_segment_id".into(), result_segment_id.to_string());
}
// Provider UsageEvent currently carries tokens but no price or cost. Keep
// the field explicit and valueless rather than fabricating a zero cost.
metrics.push(
self.metric("compact.cost_usd")
.with_dimension("status", "unavailable")
.with_dimension("reason", "provider_usage_unpriced")
.with_dimension("result_segment_id", result_segment_id.to_string()),
);
metrics
}
pub(crate) fn failure_metrics(
&self,
observed_segment_id: SegmentId,
elapsed: Duration,
category: CompactFailureCategory,
) -> [Metric; 2] {
let outcome = if category == CompactFailureCategory::Cancelled {
"cancelled"
} else {
"failed"
};
let outcome_metric = self
.metric("compact.finish")
.with_value(1.0)
.with_dimension("outcome", outcome)
.with_dimension("failure_category", category.as_str())
.with_dimension("observed_segment_id", observed_segment_id.to_string());
let duration_metric = self
.metric("compact.duration_ms")
.with_value(elapsed.as_millis().min(u128::from(MAX_SAFE_INTEGER)) as f64)
.with_dimension("outcome", outcome)
.with_dimension("observed_segment_id", observed_segment_id.to_string());
[outcome_metric, duration_metric]
}
fn metric(&self, name: &'static str) -> Metric {
let mut metric = Metric::now(name).with_correlation_id(&self.correlation_id);
metric.dimensions = self.base_dimensions();
metric
}
fn base_dimensions(&self) -> BTreeMap<String, String> {
BTreeMap::from([
("session_id".into(), self.session_id.to_string()),
(
"source_segment_id".into(),
self.source_segment_id.to_string(),
),
("mode".into(), self.mode.as_str().into()),
("trigger".into(), self.threshold_policy.as_str().into()),
(
"threshold_policy".into(),
self.threshold_policy.as_str().into(),
),
])
}
}
#[derive(Debug, Clone)]
pub(crate) struct CompactSuccessStats {
pub(crate) retained_items: u64,
pub(crate) summarized_items: u64,
pub(crate) retained_tokens: u64,
pub(crate) retained_tokens_source: EstimateSource,
pub(crate) overview_tokens: u64,
pub(crate) summary_tokens: u64,
pub(crate) auto_read_tokens: u64,
pub(crate) result_context_tokens: u64,
pub(crate) result_context_source: EstimateSource,
pub(crate) usage: UsageSnapshot,
pub(crate) requests: u64,
pub(crate) turns: u64,
pub(crate) tool_calls: u64,
}
fn metric_with_context(
name: &'static str,
value: u64,
dimensions: &BTreeMap<String, String>,
correlation_id: &str,
) -> Metric {
let mut metric = Metric::now(name)
.with_value(safe_metric_number(value))
.with_correlation_id(correlation_id);
metric.dimensions = dimensions.clone();
metric
}
pub(crate) fn new_compact_metric_correlation_id(lifecycle_id: &str) -> String {
loop {
let correlation_id = uuid::Uuid::now_v7().to_string();
if correlation_id != lifecycle_id {
return correlation_id;
}
}
}
pub(crate) fn correlated_post_request_metric(
kind: PostRequestMetric,
correlation_id: &str,
record: &UsageRecord,
) -> Metric {
let value = match kind {
PostRequestMetric::Prune => record.cache_read_tokens,
PostRequestMetric::Compaction => record.input_total_tokens,
};
Metric::now(kind.name())
.with_correlation_id(correlation_id)
.with_value(safe_metric_number(value))
.with_dimension("history_len", record.history_len.to_string())
.with_dimension("input_total_tokens", record.input_total_tokens.to_string())
.with_dimension("cache_read_tokens", record.cache_read_tokens.to_string())
.with_dimension("cache_write_tokens", record.cache_write_tokens.to_string())
.with_dimension("output_tokens", record.output_tokens.to_string())
}
pub(crate) fn safe_metric_number(value: u64) -> f64 {
value.min(MAX_SAFE_INTEGER) as f64
}
fn estimate_source(source: EstimateSource) -> &'static str {
match source {
EstimateSource::Measured => "provider",
EstimateSource::Interpolated | EstimateSource::Extrapolated | EstimateSource::NoData => {
"fallback"
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compact_metrics_use_fixed_bounded_labels_and_safe_numbers() {
let attempt = CompactAttempt::new(
uuid::Uuid::now_v7().to_string(),
uuid::Uuid::now_v7(),
uuid::Uuid::now_v7(),
CompactMode::Automatic,
CompactThresholdPolicy::RequestThreshold,
u64::MAX,
EstimateSource::Measured,
500,
);
let start = attempt.start_metric();
assert_eq!(start.name, "compact.start");
assert_eq!(start.value, Some(MAX_SAFE_INTEGER as f64));
assert_eq!(start.dimensions["mode"], "automatic");
assert_eq!(start.dimensions["trigger"], "request_threshold");
assert_eq!(start.dimensions["threshold_policy"], "request_threshold");
assert_eq!(start.dimensions["occupancy_source"], "provider");
assert!(start.correlation_id.is_some());
assert!(start.dimensions.keys().all(|key| key.len() <= 32));
assert!(start.dimensions.values().all(|value| value.len() <= 64));
}
#[test]
fn occupancy_sources_match_the_public_provider_fallback_schema() {
assert_eq!(estimate_source(EstimateSource::Measured), "provider");
assert_eq!(estimate_source(EstimateSource::Interpolated), "fallback");
assert_eq!(estimate_source(EstimateSource::Extrapolated), "fallback");
assert_eq!(estimate_source(EstimateSource::NoData), "fallback");
}
#[test]
fn metric_correlation_id_is_distinct_from_lifecycle_identity() {
let lifecycle_id = uuid::Uuid::now_v7().to_string();
let correlation_id = new_compact_metric_correlation_id(&lifecycle_id);
assert_ne!(correlation_id, lifecycle_id);
assert!(uuid::Uuid::parse_str(&correlation_id).is_ok());
}
#[test]
fn post_request_metric_saturates_values_above_json_safe_integer() {
let record = UsageRecord {
history_len: 1,
input_total_tokens: u64::MAX,
cache_read_tokens: 0,
cache_write_tokens: 0,
output_tokens: 1,
};
let metric = correlated_post_request_metric(
PostRequestMetric::Compaction,
"018f6f8a-9822-7b11-8b35-706f30313700",
&record,
);
assert_eq!(metric.name, "compact.post_request");
assert_eq!(metric.value, Some(MAX_SAFE_INTEGER as f64));
assert_eq!(
metric.dimensions["input_total_tokens"],
u64::MAX.to_string()
);
}
#[test]
fn failure_metrics_never_include_error_text() {
let attempt = CompactAttempt::new(
uuid::Uuid::now_v7().to_string(),
uuid::Uuid::now_v7(),
uuid::Uuid::now_v7(),
CompactMode::Manual,
CompactThresholdPolicy::Manual,
1,
EstimateSource::NoData,
1,
);
let [metric, duration] = attempt.failure_metrics(
uuid::Uuid::now_v7(),
Duration::from_millis(7),
CompactFailureCategory::InternalWorker,
);
let encoded = serde_json::to_string(&metric).unwrap();
assert_eq!(metric.value, Some(1.0));
assert_eq!(metric.dimensions["outcome"], "failed");
assert_eq!(duration.name, "compact.duration_ms");
assert_eq!(duration.value, Some(7.0));
assert!(encoded.contains("internal_worker"));
assert!(!encoded.contains("error"));
assert!(!encoded.contains("path"));
}
}
+76 -25
View File
@@ -19,14 +19,41 @@ use std::sync::Mutex;
use agen::UsageRecord; use agen::UsageRecord;
use agen::timeline::event::UsageEvent; use agen::timeline::event::UsageEvent;
/// One drained measurement: the underlying `UsageRecord` plus an optional /// The metric emitted after the next measured provider request.
/// `correlation_id` stamped by the prune projection (or any other future #[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// upstream observer) so that downstream metrics emitted alongside this pub(crate) enum PostRequestMetric {
/// record can be joined to it after the fact. Prune,
Compaction,
}
impl PostRequestMetric {
pub(crate) fn name(self) -> &'static str {
match self {
Self::Prune => "prune.post_request",
Self::Compaction => "compact.post_request",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct PostRequestLink {
pub(crate) correlation_id: String,
pub(crate) metric: PostRequestMetric,
}
/// One drained measurement and its causal metric links.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct RecordedUsage { pub(crate) struct RecordedUsage {
pub(crate) record: UsageRecord, pub(crate) record: UsageRecord,
pub(crate) correlation_id: Option<String>, pub(crate) post_requests: Vec<PostRequestLink>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct UsageSnapshot {
pub(crate) input_total_tokens: u64,
pub(crate) cache_read_tokens: u64,
pub(crate) cache_write_tokens: u64,
pub(crate) output_tokens: u64,
} }
/// Shared between the pre-request hook, the `on_usage` callback, and Worker. /// Shared between the pre-request hook, the `on_usage` callback, and Worker.
@@ -34,11 +61,8 @@ pub(crate) struct UsageTracker {
/// `history.len()` captured at the most recent `pre_llm_request`. /// `history.len()` captured at the most recent `pre_llm_request`.
/// Cleared when paired with an incoming `on_usage` event. /// Cleared when paired with an incoming `on_usage` event.
pending_history_len: Mutex<Option<usize>>, pending_history_len: Mutex<Option<usize>>,
/// Optional `correlation_id` set by an upstream observer (currently /// Optional causal link consumed by the next measured request.
/// the prune projection on `Fired`). Paired into the next pending_correlations: Mutex<Vec<PostRequestLink>>,
/// `RecordedUsage` and cleared. Skips that don't fire leave this
/// `None`, so the resulting record carries no correlation.
pending_correlation_id: Mutex<Option<String>>,
/// Records accumulated during the current run; drained by Worker. /// Records accumulated during the current run; drained by Worker.
pending_records: Mutex<Vec<RecordedUsage>>, pending_records: Mutex<Vec<RecordedUsage>>,
} }
@@ -47,7 +71,7 @@ impl UsageTracker {
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
Self { Self {
pending_history_len: Mutex::new(None), pending_history_len: Mutex::new(None),
pending_correlation_id: Mutex::new(None), pending_correlations: Mutex::new(Vec::new()),
pending_records: Mutex::new(Vec::new()), pending_records: Mutex::new(Vec::new()),
} }
} }
@@ -57,16 +81,23 @@ impl UsageTracker {
*self.pending_history_len.lock().unwrap() = Some(history_len); *self.pending_history_len.lock().unwrap() = Some(history_len);
} }
/// Stash a `correlation_id` to be paired into the next `RecordedUsage`. /// Pair a prune event with the next provider request.
/// Currently invoked by the prune observer on `Fired` so that the
/// `prune.fire` metric and the `prune.post_request` metric (emitted
/// alongside the resulting `LlmUsage`) carry the same join key.
///
/// Overwrites any previous unconsumed value — by construction the
/// observer fires at most once per outgoing LLM request, immediately
/// before the pre-request hook captures `history_len`.
pub(crate) fn note_correlation_id(&self, id: String) { pub(crate) fn note_correlation_id(&self, id: String) {
*self.pending_correlation_id.lock().unwrap() = Some(id); self.note_post_request(id, PostRequestMetric::Prune);
}
/// Pair a completed compaction with the next normal provider request.
pub(crate) fn note_compaction_correlation_id(&self, id: String) {
self.note_post_request(id, PostRequestMetric::Compaction);
}
fn note_post_request(&self, id: String, metric: PostRequestMetric) {
let mut pending = self.pending_correlations.lock().unwrap();
pending.retain(|link| link.metric != metric);
pending.push(PostRequestLink {
correlation_id: id,
metric,
});
} }
/// Called from the `on_usage` callback with the aggregated final /// Called from the `on_usage` callback with the aggregated final
@@ -79,7 +110,7 @@ impl UsageTracker {
Some(n) => n, Some(n) => n,
None => return, None => return,
}; };
let correlation_id = self.pending_correlation_id.lock().unwrap().take(); let post_requests = std::mem::take(&mut *self.pending_correlations.lock().unwrap());
// UsageEvent.input_tokens は scheme 層で「占有量(プロンプト全長)」に // UsageEvent.input_tokens は scheme 層で「占有量(プロンプト全長)」に
// 正規化済みである前提(Anthropic は cache_read + cache_creation を // 正規化済みである前提(Anthropic は cache_read + cache_creation を
// 加算して emit する)。 // 加算して emit する)。
@@ -95,7 +126,7 @@ impl UsageTracker {
cache_write_tokens: cache_write, cache_write_tokens: cache_write,
output_tokens: output, output_tokens: output,
}, },
correlation_id, post_requests,
}); });
} }
@@ -145,7 +176,7 @@ mod tests {
assert_eq!(records[0].record.cache_read_tokens, 800); assert_eq!(records[0].record.cache_read_tokens, 800);
assert_eq!(records[0].record.cache_write_tokens, 100); assert_eq!(records[0].record.cache_write_tokens, 100);
assert_eq!(records[0].record.output_tokens, 42); assert_eq!(records[0].record.output_tokens, 42);
assert!(records[0].correlation_id.is_none()); assert!(records[0].post_requests.is_empty());
} }
#[test] #[test]
@@ -192,6 +223,24 @@ mod tests {
assert_eq!(records[1].record.cache_read_tokens, 50); assert_eq!(records[1].record.cache_read_tokens, 50);
} }
#[test]
fn prune_and_compaction_links_share_the_next_request() {
let tracker = UsageTracker::new();
tracker.note_compaction_correlation_id("compact-id".into());
tracker.note_correlation_id("prune-id".into());
tracker.note_request(5);
tracker.record_usage(&make_event(100, 10, 2, 20));
let records = tracker.drain();
assert_eq!(records[0].post_requests.len(), 2);
assert!(records[0].post_requests.iter().any(|link| {
link.correlation_id == "compact-id" && link.metric == PostRequestMetric::Compaction
}));
assert!(records[0].post_requests.iter().any(|link| {
link.correlation_id == "prune-id" && link.metric == PostRequestMetric::Prune
}));
}
#[test] #[test]
fn correlation_id_pairs_with_next_record_only() { fn correlation_id_pairs_with_next_record_only() {
let tracker = UsageTracker::new(); let tracker = UsageTracker::new();
@@ -205,7 +254,9 @@ mod tests {
let records = tracker.drain(); let records = tracker.drain();
assert_eq!(records.len(), 2); assert_eq!(records.len(), 2);
assert_eq!(records[0].correlation_id.as_deref(), Some("abc")); assert_eq!(records[0].post_requests.len(), 1);
assert!(records[1].correlation_id.is_none()); assert_eq!(records[0].post_requests[0].correlation_id, "abc");
assert_eq!(records[0].post_requests[0].metric, PostRequestMetric::Prune);
assert!(records[1].post_requests.is_empty());
} }
} }
+106 -101
View File
@@ -190,12 +190,6 @@ fn command_admission_disposition(
Err(WorkerCommandDisposition::StaleCommandId) Err(WorkerCommandDisposition::StaleCommandId)
} }
WorkerCommandAdmission::Conflict => Err(WorkerCommandDisposition::Conflict), WorkerCommandAdmission::Conflict => Err(WorkerCommandDisposition::Conflict),
WorkerCommandAdmission::ExecutionGenerationMismatch => {
Err(WorkerCommandDisposition::StaleExecutionGeneration)
}
WorkerCommandAdmission::StateRevisionMismatch => {
Err(WorkerCommandDisposition::StaleWorkerStateRevision)
}
} }
} }
@@ -204,14 +198,14 @@ fn validate_command(
kind: WorkerCommandKind, kind: WorkerCommandKind,
shared_state: &WorkerSharedState, shared_state: &WorkerSharedState,
) -> Result<(), WorkerCommandDisposition> { ) -> Result<(), WorkerCommandDisposition> {
command_admission_disposition(shared_state.admit_command(envelope, kind, true)) command_admission_disposition(shared_state.admit_command(envelope, kind))
} }
fn validate_shutdown_command( fn validate_shutdown_command(
envelope: WorkerCommandEnvelope, envelope: WorkerCommandEnvelope,
shared_state: &WorkerSharedState, shared_state: &WorkerSharedState,
) -> Result<(), WorkerCommandDisposition> { ) -> Result<(), WorkerCommandDisposition> {
match shared_state.admit_command(envelope, WorkerCommandKind::Shutdown, false) { match shared_state.admit_command(envelope, WorkerCommandKind::Shutdown) {
WorkerCommandAdmission::Accepted | WorkerCommandAdmission::Retry => Ok(()), WorkerCommandAdmission::Accepted | WorkerCommandAdmission::Retry => Ok(()),
admission => command_admission_disposition(admission), admission => command_admission_disposition(admission),
} }
@@ -486,7 +480,7 @@ impl WorkerController {
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{ {
Self::spawn_inner( let (handle, shutdown, _task) = Self::spawn_inner(
worker, worker,
runtime_base, runtime_base,
bash_output_dir, bash_output_dir,
@@ -494,7 +488,8 @@ impl WorkerController {
None, None,
WorkerControllerTransport::UnixSocket, WorkerControllerTransport::UnixSocket,
) )
.await .await?;
Ok((handle, shutdown))
} }
/// Spawn a direct Worker while letting an in-process host select the /// Spawn a direct Worker while letting an in-process host select the
@@ -505,6 +500,22 @@ impl WorkerController {
bash_output_dir: &Path, bash_output_dir: &Path,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let (handle, shutdown, _task) =
Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport)
.await?;
Ok((handle, shutdown))
}
pub(crate) async fn spawn_with_transport_owned<C, St>(
worker: Worker<C, St>,
runtime_base: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
@@ -535,7 +546,7 @@ impl WorkerController {
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{ {
Self::spawn_inner( let (handle, shutdown, _task) = Self::spawn_inner(
worker, worker,
runtime_base, runtime_base,
bash_output_dir, bash_output_dir,
@@ -543,7 +554,8 @@ impl WorkerController {
None, None,
WorkerControllerTransport::UnixSocket, WorkerControllerTransport::UnixSocket,
) )
.await .await?;
Ok((handle, shutdown))
} }
/// Spawn into an exact persistent `runs/<generation>` directory. /// Spawn into an exact persistent `runs/<generation>` directory.
@@ -573,6 +585,26 @@ impl WorkerController {
bash_output_dir: &Path, bash_output_dir: &Path,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned(
worker,
run_dir,
bash_output_dir,
transport,
)
.await?;
Ok((handle, shutdown))
}
pub(crate) async fn spawn_runtime_managed_run_with_transport_owned<C, St>(
worker: Worker<C, St>,
run_dir: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
@@ -598,7 +630,7 @@ impl WorkerController {
runtime_managed: bool, runtime_managed: bool,
runtime_run: Option<&Path>, runtime_run: Option<&Path>,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
@@ -629,7 +661,7 @@ impl WorkerController {
runtime_managed: bool, runtime_managed: bool,
runtime_run: Option<&Path>, runtime_run: Option<&Path>,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
@@ -699,6 +731,7 @@ impl WorkerController {
target: bash_output_dir.clone(), target: bash_output_dir.clone(),
permission: manifest::Permission::Read, permission: manifest::Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
.map_err(std::io::Error::other)?; .map_err(std::io::Error::other)?;
@@ -728,9 +761,9 @@ impl WorkerController {
None, None,
) )
.await?; .await?;
if let Some(session) = fs_for_view.as_ref() { let command_observer = fs_for_view
wire_workdir_command_events(session, &in_flight); .as_ref()
} .and_then(|session| wire_workdir_command_events(session, &in_flight));
// Intake role Workers self-terminate only after a successful // Intake role Workers self-terminate only after a successful
// TicketIntakeReady turn has fully settled back to Idle. The request // TicketIntakeReady turn has fully settled back to Idle. The request
@@ -754,19 +787,11 @@ impl WorkerController {
.await .await
.map_err(|error| std::io::Error::other(error.to_string()))?; .map_err(|error| std::io::Error::other(error.to_string()))?;
let greeting = build_greeting(&worker); let greeting = build_greeting(&worker);
let execution_generation = runtime_dir let shared_state = Arc::new(WorkerSharedState::new(
.path()
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.parse::<u64>().ok())
.filter(|generation| *generation > 0)
.unwrap_or(1);
let shared_state = Arc::new(WorkerSharedState::new_with_generation(
worker.manifest().worker.name.clone(), worker.manifest().worker.name.clone(),
worker.segment_id(), worker.segment_id(),
manifest_toml.clone(), manifest_toml.clone(),
greeting, greeting,
execution_generation,
)); ));
if let Some(fs_for_view) = fs_for_view { if let Some(fs_for_view) = fs_for_view {
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view)); shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
@@ -804,7 +829,7 @@ impl WorkerController {
let pause_tx = worker.engine_mut().pause_sender(); let pause_tx = worker.engine_mut().pause_sender();
let notify_buffer = worker.notify_buffer_handle(); let notify_buffer = worker.notify_buffer_handle();
tokio::spawn(controller_loop( let controller_task = tokio::spawn(controller_loop(
worker, worker,
method_rx, method_rx,
working_event_tx, working_event_tx,
@@ -819,26 +844,27 @@ impl WorkerController {
shutdown_tx, shutdown_tx,
socket_server, socket_server,
shutdown_after_idle, shutdown_after_idle,
command_observer,
)); ));
Ok((handle, shutdown_rx)) Ok((handle, shutdown_rx, controller_task))
} }
} }
pub(crate) fn wire_workdir_command_events( pub(crate) fn wire_workdir_command_events(
session: &Arc<dyn WorkdirSession>, session: &Arc<dyn WorkdirSession>,
in_flight: &InFlightEvents, in_flight: &InFlightEvents,
) { ) -> Option<tokio::task::JoinHandle<()>> {
in_flight.replace_command_snapshot(protocol_command_snapshots(session.as_ref())); in_flight.replace_command_snapshot(protocol_command_snapshots(session.as_ref()));
let Some(mut events) = session.subscribe_command_events() else { let Some(mut events) = session.subscribe_command_events() else {
return; return None;
}; };
// Keep only a weak reference in the observer task. Holding the session // Keep only a weak reference in the observer task. Holding the session
// strongly here would keep its broadcast sender alive forever and prevent // strongly here would keep its broadcast sender alive forever and prevent
// the receiver from observing closure during Worker teardown. // the receiver from observing closure during Worker teardown.
let session = Arc::downgrade(session); let session = Arc::downgrade(session);
let in_flight = in_flight.clone(); let in_flight = in_flight.clone();
tokio::spawn(async move { Some(tokio::spawn(async move {
loop { loop {
match events.recv().await { match events.recv().await {
Ok(event) => in_flight.publish_command_event(protocol_command_event(event)), Ok(event) => in_flight.publish_command_event(protocol_command_event(event)),
@@ -852,7 +878,7 @@ pub(crate) fn wire_workdir_command_events(
Err(broadcast::error::RecvError::Closed) => break, Err(broadcast::error::RecvError::Closed) => break,
} }
} }
}); }))
} }
fn protocol_command_snapshots(session: &dyn WorkdirSession) -> Vec<ProtocolCommandSnapshot> { fn protocol_command_snapshots(session: &dyn WorkdirSession) -> Vec<ProtocolCommandSnapshot> {
@@ -1385,12 +1411,6 @@ where
feature_registry feature_registry
.add_module(crate::feature::builtin::orchestration::orchestration_feature()); .add_module(crate::feature::builtin::orchestration::orchestration_feature());
} }
for module in crate::feature::plugin::plugin_tool_features_if_enabled(
feature_config.plugins.enabled,
&worker.manifest().plugins,
) {
feature_registry = feature_registry.with_module(module);
}
if let Some(workspace_root) = local_workspace_root.as_ref() { if let Some(workspace_root) = local_workspace_root.as_ref() {
if let Some(module) = if let Some(module) =
crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, workspace_root).await crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, workspace_root).await
@@ -1512,6 +1532,7 @@ async fn controller_loop<C, St>(
shutdown_tx: oneshot::Sender<()>, shutdown_tx: oneshot::Sender<()>,
socket_server: Option<SocketServer>, socket_server: Option<SocketServer>,
shutdown_after_idle: ShutdownAfterIdleRequest, shutdown_after_idle: ShutdownAfterIdleRequest,
mut command_observer: Option<tokio::task::JoinHandle<()>>,
) where ) where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + 'static, St: Store + WorkerMetadataStore + Clone + 'static,
@@ -2348,28 +2369,53 @@ async fn controller_loop<C, St>(
} }
} }
drop(_socket_server); // Close method admission before any fallible child/session cleanup. Existing
if let Err(error) = runtime_dir.close_socket().await { // senders then fail instead of accepting work that this execution can no
tracing::warn!(%error, "Worker runtime socket cleanup failed"); // longer process.
drop(method_rx);
let had_socket_server = _socket_server.is_some();
if let Some(socket_server) = _socket_server {
socket_server.shutdown().await;
}
while had_socket_server {
match runtime_dir.close_socket().await {
Ok(()) => break,
Err(error) => {
tracing::warn!(%error, "Worker runtime socket cleanup failed; retrying");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
} }
// Feature callbacks and tasks share the Worker scope. Stop them before // Feature callbacks and tasks share the Worker scope. Stop them before
// Memory/Workdir teardown so they cannot observe a partially closed Worker. // Memory/Workdir teardown so they cannot observe a partially closed Worker.
worker.stop_feature_runtime("controller shutdown").await; worker.stop_feature_runtime("controller shutdown").await;
let child_cleanup_succeeded = match spawned_registry.shutdown_internal().await { loop {
Ok(()) => true, match spawned_registry.shutdown_internal().await {
Err(error) => { Ok(()) => break,
tracing::warn!(%error, "Internal SubWorker cleanup failed before Workdir shutdown"); Err(error) => {
false tracing::warn!(%error, "Internal SubWorker cleanup failed; retrying");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
} }
}; }
if child_cleanup_succeeded if let Some(session) = worker.workdir_session() {
&& let Some(session) = worker.workdir_session() loop {
&& let Err(error) = session.close().await match session.close().await {
{ Ok(()) => break,
tracing::warn!(%error, "Workdir session close failed"); Err(error) => {
tracing::warn!(%error, "Workdir session close failed; retrying");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
}
}
if let Some(observer) = command_observer.take() {
observer.abort();
let _ = observer.await;
} }
// Report upward that this Worker is stopping before the controller // Report upward that this Worker is stopping before the controller
@@ -3376,7 +3422,7 @@ mod tests {
.transition(WorkerState::Busy(WorkerBusyState::Run( .transition(WorkerState::Busy(WorkerBusyState::Run(
WorkerRunState::Running, WorkerRunState::Running,
))); )));
let command = WorkerCommandEnvelope::for_snapshot(1, &env.shared_state.snapshot()); let command = WorkerCommandEnvelope::new(1);
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await; tokio::time::sleep(Duration::from_millis(10)).await;
method_tx method_tx
@@ -3659,7 +3705,7 @@ mod tests {
.transition(WorkerState::Busy(WorkerBusyState::Run( .transition(WorkerState::Busy(WorkerBusyState::Run(
WorkerRunState::Running, WorkerRunState::Running,
))); )));
let command = WorkerCommandEnvelope::for_snapshot(1, &env.shared_state.snapshot()); let command = WorkerCommandEnvelope::new(1);
env._method_tx env._method_tx
.send(Method::Compact { command }) .send(Method::Compact { command })
.await .await
@@ -3710,8 +3756,8 @@ mod tests {
} }
#[test] #[test]
fn command_admission_rejects_stale_generation_revision_and_order() { fn command_admission_rejects_stale_ids_and_reuse_conflicts() {
let shared = WorkerSharedState::new_with_generation( let shared = WorkerSharedState::new(
"worker".into(), "worker".into(),
session_store::new_segment_id(), session_store::new_segment_id(),
String::new(), String::new(),
@@ -3725,39 +3771,10 @@ mod tests {
context_window: 1, context_window: 1,
context_tokens: 0, context_tokens: 0,
}, },
9,
);
assert_eq!(
validate_command(
WorkerCommandEnvelope {
command_id: 1,
expected_execution_generation: 8,
expected_worker_state_revision: 0,
},
WorkerCommandKind::Pause,
&shared,
),
Err(WorkerCommandDisposition::StaleExecutionGeneration)
);
assert_eq!(
validate_command(
WorkerCommandEnvelope {
command_id: 2,
expected_execution_generation: 9,
expected_worker_state_revision: 1,
},
WorkerCommandKind::Pause,
&shared,
),
Err(WorkerCommandDisposition::StaleWorkerStateRevision)
); );
assert!( assert!(
validate_command( validate_command(
WorkerCommandEnvelope { WorkerCommandEnvelope { command_id: 1 },
command_id: 1,
expected_execution_generation: 9,
expected_worker_state_revision: 0,
},
WorkerCommandKind::Pause, WorkerCommandKind::Pause,
&shared, &shared,
) )
@@ -3765,11 +3782,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
validate_command( validate_command(
WorkerCommandEnvelope { WorkerCommandEnvelope { command_id: 1 },
command_id: 1,
expected_execution_generation: 9,
expected_worker_state_revision: 0,
},
WorkerCommandKind::Pause, WorkerCommandKind::Pause,
&shared, &shared,
), ),
@@ -3777,11 +3790,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
validate_command( validate_command(
WorkerCommandEnvelope { WorkerCommandEnvelope { command_id: 1 },
command_id: 1,
expected_execution_generation: 9,
expected_worker_state_revision: 0,
},
WorkerCommandKind::Cancel, WorkerCommandKind::Cancel,
&shared, &shared,
), ),
@@ -3789,11 +3798,7 @@ mod tests {
); );
assert!( assert!(
validate_command( validate_command(
WorkerCommandEnvelope { WorkerCommandEnvelope { command_id: 2 },
command_id: 2,
expected_execution_generation: 9,
expected_worker_state_revision: 1,
},
WorkerCommandKind::Pause, WorkerCommandKind::Pause,
&shared, &shared,
) )
+3
View File
@@ -743,6 +743,7 @@ fn comm_info_from_spawned_child(child: &session_store::WorkerSpawnedChild) -> Co
target: rule.target.clone(), target: rule.target.clone(),
permission, permission,
recursive: rule.recursive, recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
}) })
}) })
.collect(); .collect();
@@ -1324,6 +1325,7 @@ mod tests {
target: root.path().to_path_buf(), target: root.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
active_child_segment, active_child_segment,
) )
@@ -1795,6 +1797,7 @@ mod tests {
target: PathBuf::from("/tmp"), target: PathBuf::from("/tmp"),
permission: "read".into(), permission: "read".into(),
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
callback_address: PathBuf::from("/tmp/parent.sock"), callback_address: PathBuf::from("/tmp/parent.sock"),
} }
+4
View File
@@ -286,6 +286,7 @@ fn read_rule(target: PathBuf) -> ScopeRule {
target, target,
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
@@ -294,6 +295,7 @@ fn write_rule(target: PathBuf) -> ScopeRule {
target, target,
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
@@ -306,6 +308,7 @@ fn workspace_scope(
target: workspace_root.to_path_buf(), target: workspace_root.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}; };
let deny = deny_write let deny = deny_write
.iter() .iter()
@@ -711,6 +714,7 @@ permission = "write"
target: target.to_path_buf(), target: target.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
+25 -1
View File
@@ -2211,7 +2211,6 @@ pub enum FeatureInstallError {
pub mod background; pub mod background;
pub mod builtin; pub mod builtin;
pub mod mcp; pub mod mcp;
pub mod plugin;
pub(crate) mod session; pub(crate) mod session;
#[cfg(test)] #[cfg(test)]
@@ -2224,6 +2223,31 @@ mod tests {
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn worker_feature_composition_has_no_dynamic_plugin_install_path() {
let feature_source = include_str!("feature.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
let controller_source = include_str!("controller.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
for forbidden in [
"pub mod plugin",
"plugin_tool_features_if_enabled",
"ResolvedPluginRecord",
"read_resolved_plugin_runtime_component",
"feature.plugins",
] {
assert!(
!feature_source.contains(forbidden) && !controller_source.contains(forbidden),
"dynamic Plugin install path returned through {forbidden}"
);
}
assert_eq!(FeatureId::builtin("task").as_str(), "builtin:task");
}
#[derive(Clone)] #[derive(Clone)]
struct DummyClient; struct DummyClient;
@@ -18,7 +18,8 @@ use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
use workdir::{ use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirSession, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
}; };
@@ -283,6 +284,26 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
WorkdirSessionCapabilities::ALL WorkdirSessionCapabilities::ALL
} }
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
match self.operate(WorkdirSessionOperation::AuthorizeScope(request))? {
WorkdirSessionOperationResult::AuthorizeScope => Ok(()),
_ => Err(Self::mismatch("authorize_scope")),
}
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
match self.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))? {
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
_ => Err(Self::mismatch("scope_rules_overlap")),
}
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Stat(request))? { match self.operate(WorkdirSessionOperation::Stat(request))? {
WorkdirSessionOperationResult::Stat(result) => Ok(result), WorkdirSessionOperationResult::Stat(result) => Ok(result),
@@ -1242,10 +1263,14 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn scoped_broker_operations_carry_no_child_context() { async fn scoped_broker_operations_carry_no_child_context() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({ let client = Arc::new(RecordingWorkspaceClient::new(vec![
"operation": "stat", response(json!({ "operation": "authorize_scope" })),
"result": {"path": "visible.txt", "kind": "file", "size": 8} response(json!({ "operation": "authorize_scope" })),
}))])); response(json!({
"operation": "stat",
"result": {"path": "visible.txt", "kind": "file", "size": 8}
})),
]));
let broker = workdir::WorkdirToolBroker::new(WorkspaceAttachedWorkdirSession::handle( let broker = workdir::WorkdirToolBroker::new(WorkspaceAttachedWorkdirSession::handle(
client.clone(), client.clone(),
)); ));
@@ -1255,6 +1280,7 @@ mod tests {
target: workdir::WorkdirPath::new("").unwrap(), target: workdir::WorkdirPath::new("").unwrap(),
permission: workdir::WorkdirToolScopePermission::Read, permission: workdir::WorkdirToolScopePermission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
cwd: workdir::WorkdirPath::new("").unwrap(), cwd: workdir::WorkdirPath::new("").unwrap(),
command: false, command: false,
@@ -1269,7 +1295,7 @@ mod tests {
.unwrap(); .unwrap();
let requests = client.requests(); let requests = client.requests();
assert_eq!(requests.len(), 1); assert_eq!(requests.len(), 3);
for request in requests { for request in requests {
assert_eq!( assert_eq!(
request.path, request.path,
File diff suppressed because it is too large Load Diff
+4
View File
@@ -411,11 +411,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: vec![ScopeRule { deny: vec![ScopeRule {
target: secret.clone(), target: secret.clone(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}; };
let scope = Scope::from_config(&cfg).unwrap(); let scope = Scope::from_config(&cfg).unwrap();
@@ -574,11 +576,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: vec![ScopeRule { deny: vec![ScopeRule {
target: secret.clone(), target: secret.clone(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}; };
let scope = Scope::from_config(&cfg).unwrap(); let scope = Scope::from_config(&cfg).unwrap();
+51
View File
@@ -23,6 +23,7 @@ pub(crate) struct InFlightInner {
next_block_id: u64, next_block_id: u64,
blocks: Vec<TrackedBlock>, blocks: Vec<TrackedBlock>,
commands: Vec<CommandSnapshot>, commands: Vec<CommandSnapshot>,
compaction: Option<protocol::InFlightCompaction>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -53,6 +54,7 @@ impl InFlightEvents {
next_block_id: 1, next_block_id: 1,
blocks: Vec::new(), blocks: Vec::new(),
commands: Vec::new(), commands: Vec::new(),
compaction: None,
})), })),
working_event_tx, working_event_tx,
} }
@@ -219,6 +221,15 @@ impl InFlightEvents {
self.lock().commands = commands; self.lock().commands = commands;
} }
/// Atomically update reconnect state and publish the matching live progress event.
pub(crate) fn set_compaction(&self, compaction: Option<protocol::InFlightCompaction>) {
let mut inner = self.lock();
inner.compaction = compaction.clone();
let _ = self
.working_event_tx
.send(Event::CompactionProgress { compaction });
}
pub(crate) fn clear(&self) { pub(crate) fn clear(&self) {
let mut inner = self.lock(); let mut inner = self.lock();
inner.clear(); inner.clear();
@@ -378,6 +389,7 @@ impl InFlightInner {
.filter_map(TrackedBlock::to_snapshot_block) .filter_map(TrackedBlock::to_snapshot_block)
.collect(), .collect(),
commands: self.commands.clone(), commands: self.commands.clone(),
compaction: self.compaction.clone(),
} }
} }
@@ -740,6 +752,45 @@ mod tests {
assert!(snapshot_from_guard(&guard).commands.is_empty()); assert!(snapshot_from_guard(&guard).commands.is_empty());
} }
#[test]
fn compaction_progress_updates_snapshot_and_live_event_atomically() {
let (working_event_tx, _) = broadcast::channel(16);
let mut rx = working_event_tx.subscribe();
let in_flight = InFlightEvents::new(working_event_tx);
let progress = protocol::InFlightCompaction {
phase: protocol::CompactionPhase::Preparing,
started_at_ms: 100,
trigger: protocol::CompactionTrigger::Manual,
};
in_flight.set_compaction(Some(progress.clone()));
let guard = in_flight.snapshot_guard();
assert_eq!(
snapshot_from_guard(&guard).compaction,
Some(progress.clone())
);
assert!(!snapshot_from_guard(&guard).is_empty());
drop(guard);
assert!(matches!(
rx.try_recv().unwrap(),
Event::CompactionProgress { compaction: Some(item) } if item == progress
));
in_flight.clear();
let guard = in_flight.snapshot_guard();
assert!(snapshot_from_guard(&guard).compaction.is_some());
drop(guard);
in_flight.set_compaction(None);
let guard = in_flight.snapshot_guard();
assert!(snapshot_from_guard(&guard).compaction.is_none());
drop(guard);
assert!(matches!(
rx.try_recv().unwrap(),
Event::CompactionProgress { compaction: None }
));
}
#[test] #[test]
fn clear_discards_uncommitted_blocks_without_protocol_event() { fn clear_discards_uncommitted_blocks_without_protocol_event() {
let (working_event_tx, _) = broadcast::channel(16); let (working_event_tx, _) = broadcast::channel(16);
+3 -23
View File
@@ -127,7 +127,6 @@ where
// parent manifest cannot accidentally grant its normal public tool surface // parent manifest cannot accidentally grant its normal public tool surface
// or recursively schedule Feature-owned background work. // or recursively schedule Feature-owned background work.
manifest.feature = Default::default(); manifest.feature = Default::default();
manifest.plugins = Default::default();
manifest.mcp = Default::default(); manifest.mcp = Default::default();
manifest.skills = None; manifest.skills = None;
manifest.compaction = None; manifest.compaction = None;
@@ -297,7 +296,6 @@ impl InternalWorkerSessionStatus {
fn send_internal_worker_state( fn send_internal_worker_state(
event_tx: &broadcast::Sender<Event>, event_tx: &broadcast::Sender<Event>,
state_revision: &std::sync::atomic::AtomicU64,
status: InternalWorkerSessionStatus, status: InternalWorkerSessionStatus,
) { ) {
let state = match status { let state = match status {
@@ -314,13 +312,8 @@ fn send_internal_worker_state(
protocol::WorkerBusyState::Run(protocol::WorkerRunState::Cancelling), protocol::WorkerBusyState::Run(protocol::WorkerRunState::Cancelling),
), ),
}; };
let revision = state_revision
.fetch_add(1, std::sync::atomic::Ordering::AcqRel)
.saturating_add(1);
let _ = event_tx.send(Event::WorkerState { let _ = event_tx.send(Event::WorkerState {
snapshot: protocol::WorkerStateSnapshot { snapshot: protocol::WorkerStateSnapshot {
execution_generation: 1,
revision,
last_command_id: 0, last_command_id: 0,
state, state,
}, },
@@ -383,7 +376,6 @@ pub(crate) struct InternalWorkerSessionSnapshot {
pub(crate) struct InternalWorkerSessionHandle { pub(crate) struct InternalWorkerSessionHandle {
command_tx: tokio::sync::mpsc::Sender<InternalWorkerSessionCommand>, command_tx: tokio::sync::mpsc::Sender<InternalWorkerSessionCommand>,
status: Arc<std::sync::atomic::AtomicU8>, status: Arc<std::sync::atomic::AtomicU8>,
state_revision: Arc<std::sync::atomic::AtomicU64>,
store: EphemeralSessionStore, store: EphemeralSessionStore,
session_id: SessionId, session_id: SessionId,
segment_id: SegmentId, segment_id: SegmentId,
@@ -434,7 +426,7 @@ impl InternalWorkerSessionHandle {
} }
fn emit_worker_state(&self, status: InternalWorkerSessionStatus) { fn emit_worker_state(&self, status: InternalWorkerSessionStatus) {
send_internal_worker_state(&self.event_tx, &self.state_revision, status); send_internal_worker_state(&self.event_tx, status);
} }
pub(crate) fn protocol_snapshot(&self) -> InternalWorkerSessionSnapshot { pub(crate) fn protocol_snapshot(&self) -> InternalWorkerSessionSnapshot {
@@ -681,7 +673,6 @@ pub(crate) fn prepare_internal_worker_from_spec(
} = spec; } = spec;
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id); manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
manifest.feature = Default::default(); manifest.feature = Default::default();
manifest.plugins = Default::default();
manifest.mcp = Default::default(); manifest.mcp = Default::default();
manifest.skills = None; manifest.skills = None;
manifest.compaction = None; manifest.compaction = None;
@@ -805,13 +796,11 @@ pub(crate) async fn prepare_internal_worker_session(
let status = Arc::new(std::sync::atomic::AtomicU8::new( let status = Arc::new(std::sync::atomic::AtomicU8::new(
InternalWorkerSessionStatus::Idle.encode(), InternalWorkerSessionStatus::Idle.encode(),
)); ));
let state_revision = Arc::new(std::sync::atomic::AtomicU64::new(0));
let state_changed = Arc::new(tokio::sync::Notify::new()); let state_changed = Arc::new(tokio::sync::Notify::new());
let last_error = Arc::new(Mutex::new(None)); let last_error = Arc::new(Mutex::new(None));
let handle = InternalWorkerSessionHandle { let handle = InternalWorkerSessionHandle {
command_tx, command_tx,
status: status.clone(), status: status.clone(),
state_revision: state_revision.clone(),
store, store,
session_id, session_id,
segment_id, segment_id,
@@ -847,11 +836,7 @@ pub(crate) async fn prepare_internal_worker_session(
message, message,
}); });
} }
send_internal_worker_state( send_internal_worker_state(&event_tx, turn_status);
&event_tx,
&state_revision,
turn_status,
);
if let Some(callback) = &on_turn_end { if let Some(callback) = &on_turn_end {
callback(turn_status); callback(turn_status);
} }
@@ -893,11 +878,7 @@ pub(crate) async fn prepare_internal_worker_session(
InternalWorkerSessionStatus::Stopped.encode(), InternalWorkerSessionStatus::Stopped.encode(),
std::sync::atomic::Ordering::Release, std::sync::atomic::Ordering::Release,
); );
send_internal_worker_state( send_internal_worker_state(&event_tx, InternalWorkerSessionStatus::Stopped);
&event_tx,
&state_revision,
InternalWorkerSessionStatus::Stopped,
);
let _ = event_tx.send(Event::Shutdown); let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters(); state_changed.notify_waiters();
if let Some(done) = stop_done { if let Some(done) = stop_done {
@@ -1149,7 +1130,6 @@ pub(crate) fn test_internal_worker_session(
status: Arc::new(std::sync::atomic::AtomicU8::new( status: Arc::new(std::sync::atomic::AtomicU8::new(
InternalWorkerSessionStatus::Idle.encode(), InternalWorkerSessionStatus::Idle.encode(),
)), )),
state_revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
store, store,
session_id, session_id,
segment_id, segment_id,
+162 -32
View File
@@ -25,7 +25,7 @@ use arc_swap::ArcSwap;
use async_trait::async_trait; use async_trait::async_trait;
use tracing::info; use tracing::info;
use crate::compact::state::CompactState; use crate::compact::state::{AutomaticCompactDecision, CompactState};
use crate::compact::usage_tracker::UsageTracker; use crate::compact::usage_tracker::UsageTracker;
use session_store::SystemItem; use session_store::SystemItem;
@@ -111,6 +111,9 @@ pub(crate) struct WorkerInterceptor {
tool_calls_this_turn: AtomicUsize, tool_calls_this_turn: AtomicUsize,
} }
const THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC: &str =
"automatic compaction could not make the provider request context safe";
impl WorkerInterceptor { impl WorkerInterceptor {
#[cfg(test)] #[cfg(test)]
pub(crate) fn new( pub(crate) fn new(
@@ -229,27 +232,45 @@ impl WorkerInterceptor {
Some(total_tokens(context, &records).tokens) Some(total_tokens(context, &records).tokens)
} }
fn request_threshold_exceeded(&self, current_tokens: Option<u64>, context: &[Item]) -> bool { fn request_compact_decision(
if let Some(state) = self.compact_state.as_ref() { &self,
if !state.is_disabled() && !state.just_compacted() { current_tokens: Option<u64>,
let current = current_tokens.unwrap_or(0); context: &[Item],
if state.exceeds_request(current) { ) -> AutomaticCompactDecision {
let shape = context_shape(context); let Some(state) = self.compact_state.as_ref() else {
info!( return AutomaticCompactDecision::Continue;
input_tokens = current, };
threshold = state.request_threshold().unwrap_or(0), let current = current_tokens.unwrap_or(0);
items_len = shape.items_len, let decision = state.evaluate_request(current);
items_json_bytes = shape.items_json_bytes, if !matches!(decision, AutomaticCompactDecision::Continue) {
reasoning_items = shape.reasoning_items, let shape = context_shape(context);
reasoning_encrypted_content_count = shape.reasoning_encrypted_content_count, info!(
reasoning_encrypted_content_bytes = shape.reasoning_encrypted_content_bytes, input_tokens = current,
"Between-requests compaction threshold exceeded, yielding" ?decision,
); items_len = shape.items_len,
return true; items_json_bytes = shape.items_json_bytes,
reasoning_items = shape.reasoning_items,
reasoning_encrypted_content_count = shape.reasoning_encrypted_content_count,
reasoning_encrypted_content_bytes = shape.reasoning_encrypted_content_bytes,
"Between-requests automatic compaction decision"
);
}
decision
}
fn decision_action(&self, decision: AutomaticCompactDecision) -> Option<PreRequestAction> {
match decision {
AutomaticCompactDecision::Continue => None,
AutomaticCompactDecision::Start(_) => Some(PreRequestAction::Yield),
AutomaticCompactDecision::Block(block) => {
if let Some(state) = &self.compact_state {
state.record_request_block(block);
} }
Some(PreRequestAction::Cancel(
THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC.to_string(),
))
} }
} }
false
} }
fn attach_prompt_provenance(&self, items: &mut [SystemItem]) { fn attach_prompt_provenance(&self, items: &mut [SystemItem]) {
let prompts = self.prompts.load(); let prompts = self.prompts.load();
@@ -388,8 +409,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
) -> InterceptorResult<PreRequestAction> { ) -> InterceptorResult<PreRequestAction> {
let context = context.items; let context = context.items;
let initial_tokens = self.estimated_tokens(context); let initial_tokens = self.estimated_tokens(context);
if self.request_threshold_exceeded(initial_tokens, context) { if let Some(action) =
return Ok(PreRequestAction::Yield); self.decision_action(self.request_compact_decision(initial_tokens, context))
{
return Ok(action);
} }
let info = PreRequestInfo { let info = PreRequestInfo {
item_count: context.len(), item_count: context.len(),
@@ -423,6 +446,22 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
return Ok(PreRequestAction::Cancel(reason)); return Ok(PreRequestAction::Cancel(reason));
} }
if should_yield { if should_yield {
if let Some(state) = &self.compact_state {
match state.claim_hook_yield() {
AutomaticCompactDecision::Start(_) => {}
AutomaticCompactDecision::Block(block) => {
state.record_request_block(block);
return Ok(PreRequestAction::Cancel(
THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC.to_string(),
));
}
AutomaticCompactDecision::Continue => {
return Ok(PreRequestAction::Cancel(
THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC.to_string(),
));
}
}
}
return Ok(PreRequestAction::Yield); return Ok(PreRequestAction::Yield);
} }
@@ -445,16 +484,26 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
}; };
let current_tokens = self.estimated_tokens(effective_context.as_ref()); let current_tokens = self.estimated_tokens(effective_context.as_ref());
if self.request_threshold_exceeded(current_tokens, effective_context.as_ref()) { let compact_decision =
self.request_compact_decision(current_tokens, effective_context.as_ref());
if !matches!(compact_decision, AutomaticCompactDecision::Continue) {
if let Err(error) = self.commit_system_items(&system_items) { if let Err(error) = self.commit_system_items(&system_items) {
return Ok(PreRequestAction::Cancel(format!( return Ok(PreRequestAction::Cancel(format!(
"session persistence failed: {error}" "session persistence failed: {error}"
))); )));
} }
return Ok(if appended_items.is_empty() { return Ok(match compact_decision {
PreRequestAction::Yield AutomaticCompactDecision::Start(_) if !appended_items.is_empty() => {
} else { PreRequestAction::YieldWith(appended_items)
PreRequestAction::YieldWith(appended_items) }
AutomaticCompactDecision::Start(_) => PreRequestAction::Yield,
AutomaticCompactDecision::Block(block) => {
if let Some(state) = &self.compact_state {
state.record_request_block(block);
}
PreRequestAction::Cancel(THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC.to_string())
}
AutomaticCompactDecision::Continue => unreachable!(),
}); });
} }
@@ -668,6 +717,18 @@ mod tests {
Arc::new(builder.build()) Arc::new(builder.build())
} }
struct YieldingPreRequestHook;
#[async_trait]
impl Hook<PreLlmRequest> for YieldingPreRequestHook {
async fn call(
&self,
_info: &PreRequestContext,
) -> Result<HookPreRequestAction, crate::hook::HookError> {
Ok(HookPreRequestAction::Yield)
}
}
struct RecordingSystemItemCommitter { struct RecordingSystemItemCommitter {
committed: Arc<Mutex<Vec<SystemItem>>>, committed: Arc<Mutex<Vec<SystemItem>>>,
} }
@@ -750,6 +811,36 @@ mod tests {
}])) }]))
} }
#[tokio::test]
async fn hook_yield_claims_attempt_before_returning_to_compaction() {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(YieldingPreRequestHook);
let registry = Arc::new(builder.build());
let state = Arc::new(CompactState::new(None, Some(u64::MAX), 0));
let interceptor = WorkerInterceptor::new(
registry,
Some(Arc::clone(&state)),
Some(usage_handle_with(1, 1)),
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
test_prompts(),
None,
);
let mut ctx = vec![Item::user_message("hello")];
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Yield));
assert!(state.has_claimed_attempt());
}
#[tokio::test] #[tokio::test]
async fn pre_llm_request_yields_and_skips_hooks_when_request_threshold_exceeded() { async fn pre_llm_request_yields_and_skips_hooks_when_request_threshold_exceeded() {
let count = Arc::new(AtomicUsize::new(0)); let count = Arc::new(AtomicUsize::new(0));
@@ -761,7 +852,7 @@ mod tests {
let interceptor = WorkerInterceptor::new( let interceptor = WorkerInterceptor::new(
registry, registry,
Some(state), Some(Arc::clone(&state)),
Some(history), Some(history),
NotifyBuffer::new(), NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())), Arc::new(Mutex::new(Vec::new())),
@@ -779,6 +870,7 @@ mod tests {
.unwrap(); .unwrap();
assert!(matches!(action, PreRequestAction::Yield)); assert!(matches!(action, PreRequestAction::Yield));
assert!(state.has_claimed_attempt());
// Hook must not run when an internal mechanism short-circuits first. // Hook must not run when an internal mechanism short-circuits first.
assert_eq!(count.load(Ordering::Relaxed), 0); assert_eq!(count.load(Ordering::Relaxed), 0);
} }
@@ -798,7 +890,7 @@ mod tests {
let interceptor = WorkerInterceptor::new( let interceptor = WorkerInterceptor::new(
registry, registry,
Some(state), Some(Arc::clone(&state)),
Some(history), Some(history),
NotifyBuffer::new(), NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())), Arc::new(Mutex::new(Vec::new())),
@@ -821,10 +913,48 @@ mod tests {
PreRequestAction::YieldWith(items) => assert_eq!(items.len(), 1), PreRequestAction::YieldWith(items) => assert_eq!(items.len(), 1),
other => panic!("expected YieldWith queued system item, got {other:?}"), other => panic!("expected YieldWith queued system item, got {other:?}"),
} }
assert!(state.has_claimed_attempt());
assert!(saw_handle.load(Ordering::Relaxed)); assert!(saw_handle.load(Ordering::Relaxed));
assert_eq!(committed.lock().expect("committed system items").len(), 1); assert_eq!(committed.lock().expect("committed system items").len(), 1);
} }
#[tokio::test]
async fn successful_compaction_blocks_unsafe_request_until_usage_commit() {
let registry = Arc::new(HookRegistryBuilder::new().build());
let state = Arc::new(CompactState::new(None, Some(10), 0));
assert!(matches!(
state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
assert!(state.complete_automatic(crate::compact::state::CompactionOutcome::Succeeded));
let history = usage_handle_with(1, 11);
let interceptor = WorkerInterceptor::new(
registry,
Some(Arc::clone(&state)),
Some(history),
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
test_prompts(),
None,
);
let mut ctx = vec![Item::user_message("still too large")];
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.expect("pre-request interception should succeed");
assert!(matches!(action, PreRequestAction::Cancel(_)));
assert_eq!(
state.take_pending_request_block(),
Some(crate::compact::state::AutomaticCompactBlock::Thrash)
);
}
#[tokio::test] #[tokio::test]
async fn pre_llm_request_counts_in_flight_usage_records() { async fn pre_llm_request_counts_in_flight_usage_records() {
let registry = Arc::new(HookRegistryBuilder::new().build()); let registry = Arc::new(HookRegistryBuilder::new().build());
@@ -843,7 +973,7 @@ mod tests {
let interceptor = WorkerInterceptor::new( let interceptor = WorkerInterceptor::new(
registry, registry,
Some(state), Some(Arc::clone(&state)),
Some(history), Some(history),
NotifyBuffer::new(), NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())), Arc::new(Mutex::new(Vec::new())),
@@ -875,7 +1005,7 @@ mod tests {
let interceptor = WorkerInterceptor::new( let interceptor = WorkerInterceptor::new(
registry, registry,
Some(state), Some(Arc::clone(&state)),
Some(history), Some(history),
NotifyBuffer::new(), NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())), Arc::new(Mutex::new(Vec::new())),
@@ -923,7 +1053,7 @@ mod tests {
let history = Arc::new(Mutex::new(vec![record])); let history = Arc::new(Mutex::new(vec![record]));
let interceptor = WorkerInterceptor::new( let interceptor = WorkerInterceptor::new(
registry, registry,
Some(state), Some(Arc::clone(&state)),
Some(history), Some(history),
NotifyBuffer::new(), NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())), Arc::new(Mutex::new(Vec::new())),
@@ -957,7 +1087,7 @@ mod tests {
let interceptor = WorkerInterceptor::new( let interceptor = WorkerInterceptor::new(
registry, registry,
Some(state), Some(Arc::clone(&state)),
Some(history), Some(history),
NotifyBuffer::new(), NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())), Arc::new(Mutex::new(Vec::new())),
+40 -9
View File
@@ -4,7 +4,8 @@ use std::path::PathBuf;
use protocol::stream::{JsonLineReader, JsonLineWriter}; use protocol::stream::{JsonLineReader, JsonLineWriter};
use tokio::net::UnixListener; use tokio::net::UnixListener;
use tokio::task::JoinHandle; use tokio::sync::oneshot;
use tokio::task::{JoinHandle, JoinSet};
use crate::controller::WorkerHandle; use crate::controller::WorkerHandle;
use crate::ipc::protocol_session::{ use crate::ipc::protocol_session::{
@@ -19,7 +20,8 @@ use protocol::{ErrorCode, Event};
/// - Client writes Method lines → forwarded to WorkerController /// - Client writes Method lines → forwarded to WorkerController
/// - Worker events → written as Event lines to all connected clients /// - Worker events → written as Event lines to all connected clients
pub struct SocketServer { pub struct SocketServer {
_accept_task: JoinHandle<()>, accept_task: Option<JoinHandle<()>>,
shutdown: Option<oneshot::Sender<()>>,
path: PathBuf, path: PathBuf,
} }
@@ -33,20 +35,45 @@ impl SocketServer {
let listener = UnixListener::bind(&path)?; let listener = UnixListener::bind(&path)?;
let handle = handle.clone(); let handle = handle.clone();
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
let _accept_task = tokio::spawn(async move { let accept_task = tokio::spawn(async move {
let mut connections = JoinSet::new();
loop { loop {
match listener.accept().await { tokio::select! {
Ok((stream, _)) => { _ = &mut shutdown_rx => break,
let handle = handle.clone(); accepted = listener.accept() => match accepted {
tokio::spawn(handle_connection(stream, handle)); Ok((stream, _)) => {
let handle = handle.clone();
connections.spawn(handle_connection(stream, handle));
}
Err(_) => break,
},
completed = connections.join_next(), if !connections.is_empty() => {
let _ = completed;
} }
Err(_) => break,
} }
} }
connections.shutdown().await;
}); });
Ok(Self { _accept_task, path }) Ok(Self {
accept_task: Some(accept_task),
shutdown: Some(shutdown_tx),
path,
})
}
/// Stop accepting connections and join the server task. Dropping its
/// `JoinSet` cancels every active connection task before this returns.
pub async fn shutdown(mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(task) = self.accept_task.take() {
let _ = task.await;
}
let _ = tokio::fs::remove_file(&self.path).await;
} }
/// The socket file path. /// The socket file path.
@@ -57,6 +84,10 @@ impl SocketServer {
impl Drop for SocketServer { impl Drop for SocketServer {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
let _ = self.accept_task.take();
let _ = std::fs::remove_file(&self.path); let _ = std::fs::remove_file(&self.path);
} }
} }
+1
View File
@@ -299,6 +299,7 @@ mod tests {
target: "/tmp/work".into(), target: "/tmp/work".into(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
callback_address: "/run/yoi/my-worker/sock".into(), callback_address: "/run/yoi/my-worker/sock".into(),
}]; }];
@@ -77,6 +77,7 @@ pub(crate) fn write_rule(path: &str, recursive: bool) -> ScopeRule {
target: PathBuf::from(path), target: PathBuf::from(path),
permission: Permission::Write, permission: Permission::Write,
recursive, recursive,
symlink_policy: Default::default(),
} }
} }
@@ -85,6 +86,7 @@ pub(crate) fn read_rule(path: &str, recursive: bool) -> ScopeRule {
target: PathBuf::from(path), target: PathBuf::from(path),
permission: Permission::Read, permission: Permission::Read,
recursive, recursive,
symlink_policy: Default::default(),
} }
} }
+14 -45
View File
@@ -28,8 +28,6 @@ pub(crate) enum WorkerCommandAdmission {
Retry, Retry,
Conflict, Conflict,
StaleCommandId, StaleCommandId,
ExecutionGenerationMismatch,
StateRevisionMismatch,
} }
/// Shared state between WorkerController and runtime directory. /// Shared state between WorkerController and runtime directory.
@@ -59,23 +57,13 @@ impl WorkerSharedState {
segment_id: SegmentId, segment_id: SegmentId,
manifest_toml: String, manifest_toml: String,
greeting: protocol::Greeting, greeting: protocol::Greeting,
) -> Self {
Self::new_with_generation(worker_name, segment_id, manifest_toml, greeting, 1)
}
pub fn new_with_generation(
worker_name: String,
segment_id: SegmentId,
manifest_toml: String,
greeting: protocol::Greeting,
execution_generation: u64,
) -> Self { ) -> Self {
Self { Self {
worker_name, worker_name,
segment_id, segment_id,
manifest_toml, manifest_toml,
greeting, greeting,
state: RwLock::new(WorkerStateSnapshot::initial(execution_generation)), state: RwLock::new(WorkerStateSnapshot::initial()),
accepted_commands: RwLock::new(VecDeque::new()), accepted_commands: RwLock::new(VecDeque::new()),
fs_view: OnceLock::new(), fs_view: OnceLock::new(),
flow_transition_enabled: AtomicBool::new(false), flow_transition_enabled: AtomicBool::new(false),
@@ -108,7 +96,6 @@ impl WorkerSharedState {
.write() .write()
.expect("worker state lock poisoned; refusing an inferred fallback state"); .expect("worker state lock poisoned; refusing an inferred fallback state");
if snapshot.state != state { if snapshot.state != state {
snapshot.revision = snapshot.revision.saturating_add(1);
snapshot.state = state; snapshot.state = state;
} }
snapshot.clone() snapshot.clone()
@@ -118,7 +105,6 @@ impl WorkerSharedState {
&self, &self,
envelope: WorkerCommandEnvelope, envelope: WorkerCommandEnvelope,
kind: WorkerCommandKind, kind: WorkerCommandKind,
require_state_revision: bool,
) -> WorkerCommandAdmission { ) -> WorkerCommandAdmission {
let mut snapshot = self let mut snapshot = self
.state .state
@@ -138,18 +124,11 @@ impl WorkerSharedState {
WorkerCommandAdmission::Conflict WorkerCommandAdmission::Conflict
}; };
} }
if envelope.expected_execution_generation != snapshot.execution_generation {
return WorkerCommandAdmission::ExecutionGenerationMismatch;
}
if require_state_revision && envelope.expected_worker_state_revision != snapshot.revision {
return WorkerCommandAdmission::StateRevisionMismatch;
}
if envelope.command_id <= snapshot.last_command_id { if envelope.command_id <= snapshot.last_command_id {
return WorkerCommandAdmission::StaleCommandId; return WorkerCommandAdmission::StaleCommandId;
} }
snapshot.last_command_id = envelope.command_id; snapshot.last_command_id = envelope.command_id;
snapshot.revision = snapshot.revision.saturating_add(1);
accepted.push_back(AcceptedWorkerCommand { accepted.push_back(AcceptedWorkerCommand {
envelope, envelope,
kind, kind,
@@ -248,12 +227,11 @@ mod tests {
use super::*; use super::*;
fn test_state() -> WorkerSharedState { fn test_state() -> WorkerSharedState {
WorkerSharedState::new_with_generation( WorkerSharedState::new(
"test-worker".into(), "test-worker".into(),
session_store::new_segment_id(), session_store::new_segment_id(),
"[engine]\nname = \"test-worker\"".into(), "[engine]\nname = \"test-worker\"".into(),
test_greeting(), test_greeting(),
7,
) )
} }
@@ -273,43 +251,34 @@ mod tests {
#[test] #[test]
fn initial_snapshot_is_idle() { fn initial_snapshot_is_idle() {
let state = test_state(); let state = test_state();
assert_eq!(state.snapshot(), WorkerStateSnapshot::initial(7)); assert_eq!(state.snapshot(), WorkerStateSnapshot::initial());
assert_eq!(state.catalog_status(), WorkerStatus::Idle); assert_eq!(state.catalog_status(), WorkerStatus::Idle);
} }
#[test] #[test]
fn transitions_increment_revision_only_when_state_changes() { fn transitions_publish_full_state() {
let state = test_state(); let state = test_state();
let running = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)); let running = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running));
let snapshot = state.transition(running.clone()); assert_eq!(state.transition(running.clone()).state, running);
assert_eq!(snapshot.revision, 1);
assert_eq!(snapshot.state, running);
assert_eq!(state.transition(running).revision, 1);
let paused = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)); let paused = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused));
let snapshot = state.transition(paused.clone()); let snapshot = state.transition(paused.clone());
assert_eq!(snapshot.revision, 2);
assert_eq!(snapshot.state, paused); assert_eq!(snapshot.state, paused);
assert_eq!(snapshot.last_command_id, 0);
assert_eq!(state.catalog_status(), WorkerStatus::Paused); assert_eq!(state.catalog_status(), WorkerStatus::Paused);
} }
#[test] #[test]
fn accepted_command_identity_advances_revision_and_detects_reuse_conflicts() { fn accepted_command_identity_advances_last_id_and_detects_reuse_conflicts() {
let state = test_state(); let state = test_state();
let envelope = WorkerCommandEnvelope { let envelope = WorkerCommandEnvelope { command_id: 9 };
command_id: 9,
expected_execution_generation: 7,
expected_worker_state_revision: 0,
};
assert_eq!( assert_eq!(
state.admit_command(envelope, WorkerCommandKind::Pause, true), state.admit_command(envelope, WorkerCommandKind::Pause),
WorkerCommandAdmission::Accepted WorkerCommandAdmission::Accepted
); );
assert_eq!( assert_eq!(
state.snapshot(), state.snapshot(),
WorkerStateSnapshot { WorkerStateSnapshot {
execution_generation: 7,
revision: 1,
last_command_id: 9, last_command_id: 9,
state: WorkerState::Idle, state: WorkerState::Idle,
} }
@@ -325,14 +294,14 @@ mod tests {
Some(Some(WorkerCommandDisposition::Accepted)) Some(Some(WorkerCommandDisposition::Accepted))
); );
assert_eq!( assert_eq!(
state.admit_command(envelope, WorkerCommandKind::Pause, true), state.admit_command(envelope, WorkerCommandKind::Pause),
WorkerCommandAdmission::Retry WorkerCommandAdmission::Retry
); );
assert_eq!( assert_eq!(
state.admit_command(envelope, WorkerCommandKind::Cancel, true), state.admit_command(envelope, WorkerCommandKind::Cancel),
WorkerCommandAdmission::Conflict WorkerCommandAdmission::Conflict
); );
assert_eq!(state.snapshot().revision, 1); assert_eq!(state.snapshot().last_command_id, 9);
} }
#[test] #[test]
@@ -343,8 +312,8 @@ mod tests {
))); )));
let parsed: serde_json::Value = serde_json::from_str(&state.status_json()).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&state.status_json()).unwrap();
assert_eq!(parsed["state"], "running"); assert_eq!(parsed["state"], "running");
assert_eq!(parsed["worker_state"]["execution_generation"], 7); assert!(parsed["worker_state"].get("execution_generation").is_none());
assert_eq!(parsed["worker_state"]["revision"], 1); assert!(parsed["worker_state"].get("revision").is_none());
assert_eq!(parsed["worker_state"]["state"]["kind"], "busy"); assert_eq!(parsed["worker_state"]["state"]["kind"], "busy");
assert_eq!(parsed["worker_name"], "test-worker"); assert_eq!(parsed["worker_name"], "test-worker");
assert!(parsed["segment_id"].is_string()); assert!(parsed["segment_id"].is_string());
+1 -4
View File
@@ -138,10 +138,7 @@ mod tests {
); );
let method = Method::Shutdown { let method = Method::Shutdown {
command: protocol::WorkerCommandEnvelope::for_snapshot( command: protocol::WorkerCommandEnvelope::new(1),
1,
&protocol::WorkerStateSnapshot::initial(1),
),
}; };
connect_and_send(&socket, &method).await.unwrap(); connect_and_send(&socket, &method).await.unwrap();
+4
View File
@@ -1030,6 +1030,7 @@ fn record_from_worker_state(child: &WorkerSpawnedChild) -> io::Result<SpawnedWor
target: rule.target.clone(), target: rule.target.clone(),
permission, permission,
recursive: rule.recursive, recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
}) })
}) })
.collect::<io::Result<Vec<_>>>()?; .collect::<io::Result<Vec<_>>>()?;
@@ -1072,6 +1073,7 @@ mod tests {
target: std::path::PathBuf::from("/tmp"), target: std::path::PathBuf::from("/tmp"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}) })
@@ -1090,6 +1092,7 @@ mod tests {
target: root.clone(), target: root.clone(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}) })
@@ -1109,6 +1112,7 @@ mod tests {
target: workdir::WorkdirPath::new("").unwrap(), target: workdir::WorkdirPath::new("").unwrap(),
permission: workdir::WorkdirToolScopePermission::Read, permission: workdir::WorkdirToolScopePermission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
cwd: workdir::WorkdirPath::new("").unwrap(), cwd: workdir::WorkdirPath::new("").unwrap(),
command: false, command: false,
+69 -12
View File
@@ -16,8 +16,8 @@ use manifest::{
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial, CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
PermissionConfigPartial, ProfileDiscovery, ProfileError, ProfileRegistry, PermissionConfigPartial, ProfileDiscovery, ProfileError, ProfileRegistry,
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
ScopeRule, SessionConfigPartial, ToolOutputLimitsPartial, WorkerManifest, WorkerManifestConfig, ScopeRule, SessionConfigPartial, SymlinkPolicy, ToolOutputLimitsPartial, WorkerManifest,
WorkerMetaConfig, WorkerManifestConfig, WorkerMetaConfig,
}; };
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -61,7 +61,9 @@ struct SubWorkerSpawnInput {
task: String, task: String,
/// Allow rules delegated to the spawned SubWorker. Must be a subset of the /// Allow rules delegated to the spawned SubWorker. Must be a subset of the
/// spawner's explicit delegation authority; direct tool scope alone is not /// spawner's explicit delegation authority; direct tool scope alone is not
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true. /// sufficient. Omit `recursive` for normal workspace/worktree delegation;
/// it defaults to true. Omit `symlink_policy` for the least-authority
/// `resolved` policy; `logical` requires matching parent authority.
scope: Vec<ScopeRuleInput>, scope: Vec<ScopeRuleInput>,
/// Explicitly grant command execution through the parent-owned Workdir tool broker. /// Explicitly grant command execution through the parent-owned Workdir tool broker.
#[serde(default)] #[serde(default)]
@@ -88,6 +90,27 @@ struct ScopeRuleInput {
/// children only. Defaults to `true`. /// children only. Defaults to `true`.
#[serde(default = "default_true")] #[serde(default = "default_true")]
recursive: bool, recursive: bool,
/// Symbolic-link identity used by this rule. `resolved` is the default
/// and least authority; `logical` requires matching parent authority.
#[serde(default)]
symlink_policy: SymlinkPolicyInput,
}
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum SymlinkPolicyInput {
#[default]
Resolved,
Logical,
}
impl From<SymlinkPolicyInput> for SymlinkPolicy {
fn from(value: SymlinkPolicyInput) -> Self {
match value {
SymlinkPolicyInput::Resolved => Self::Resolved,
SymlinkPolicyInput::Logical => Self::Logical,
}
}
} }
#[derive(Debug, Deserialize, schemars::JsonSchema, Clone, Copy)] #[derive(Debug, Deserialize, schemars::JsonSchema, Clone, Copy)]
@@ -506,6 +529,7 @@ impl Tool for SubWorkerSpawnTool {
target: child_bash_output_dir.clone(), target: child_bash_output_dir.clone(),
permission: manifest::Permission::Read, permission: manifest::Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
.map_err(|error| { .map_err(|error| {
ToolError::ExecutionFailed(format!( ToolError::ExecutionFailed(format!(
@@ -707,6 +731,7 @@ fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirToolScopeR
PermissionInput::Write => WorkdirToolScopePermission::Write, PermissionInput::Write => WorkdirToolScopePermission::Write,
}, },
recursive: rule.recursive, recursive: rule.recursive,
symlink_policy: rule.symlink_policy.into(),
}) })
}) })
.collect() .collect()
@@ -926,7 +951,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
rules: p.rules.clone(), rules: p.rules.clone(),
}), }),
feature: manifest.feature.clone().into(), feature: manifest.feature.clone().into(),
plugins: manifest.plugins.clone(),
mcp: manifest.mcp.clone(), mcp: manifest.mcp.clone(),
compaction: manifest compaction: manifest
.compaction .compaction
@@ -1074,21 +1098,26 @@ mod tests {
target: ".".to_string(), target: ".".to_string(),
permission: PermissionInput::Read, permission: PermissionInput::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRuleInput { ScopeRuleInput {
target: "src".to_string(), target: "src".to_string(),
permission: PermissionInput::Write, permission: PermissionInput::Write,
recursive: false, recursive: false,
symlink_policy: SymlinkPolicyInput::Logical,
}, },
]) ])
.unwrap(); .unwrap();
assert_eq!(rules[0].target.as_str(), ""); assert_eq!(rules[0].target.as_str(), "");
assert_eq!(rules[1].target.as_str(), "src"); assert_eq!(rules[1].target.as_str(), "src");
assert_eq!(rules[0].symlink_policy, SymlinkPolicy::Resolved);
assert_eq!(rules[1].symlink_policy, SymlinkPolicy::Logical);
for target in ["/host/path", "../escape"] { for target in ["/host/path", "../escape"] {
let error = parse_workdir_scope(&[ScopeRuleInput { let error = parse_workdir_scope(&[ScopeRuleInput {
target: target.to_string(), target: target.to_string(),
permission: PermissionInput::Read, permission: PermissionInput::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
.unwrap_err(); .unwrap_err();
assert!(matches!(error, ToolError::InvalidArgument(_))); assert!(matches!(error, ToolError::InvalidArgument(_)));
@@ -1126,6 +1155,7 @@ mod tests {
target: path.to_path_buf(), target: path.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
@@ -1533,10 +1563,14 @@ enabled = false
assert!(record.installed_tools.iter().any(|tool| tool == "Write")); assert!(record.installed_tools.iter().any(|tool| tool == "Write"));
assert!(!record.installed_tools.iter().any(|tool| tool == "Bash")); assert!(!record.installed_tools.iter().any(|tool| tool == "Bash"));
assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!( let requests = remote_client.requests();
remote_client.requests().is_empty(), assert!(!requests.is_empty());
"spawning a child must not open or delegate a provider Workdir session" assert!(requests.iter().all(|request| {
); let body = request.body.as_deref().unwrap_or_default();
body.contains("authorize_scope")
&& !body.contains(&bash_output_dir.display().to_string())
&& !body.contains(&workspace_root.display().to_string())
}));
} }
#[test] #[test]
@@ -1548,6 +1582,9 @@ enabled = false
.expect("schema properties"); .expect("schema properties");
assert!(properties.contains_key("cwd"), "schema: {schema}"); assert!(properties.contains_key("cwd"), "schema: {schema}");
assert!(properties.contains_key("command"), "schema: {schema}"); assert!(properties.contains_key("command"), "schema: {schema}");
let schema_text = serde_json::to_string(&schema).unwrap();
assert!(schema_text.contains("symlink_policy"), "schema: {schema}");
assert!(schema_text.contains("logical"), "schema: {schema}");
let required = schema let required = schema
.get("required") .get("required")
.and_then(serde_json::Value::as_array) .and_then(serde_json::Value::as_array)
@@ -1708,10 +1745,29 @@ enabled = false
self.requests self.requests
.lock() .lock()
.expect("remote Workdir request lock") .expect("remote Workdir request lock")
.push(request); .push(request.clone());
Err(WorkspaceClientError::Request( let operation: workdir::workspace::WorkspaceWorkdirSessionOperationRequest =
"SubWorker spawn must not call the remote Workdir provider".into(), serde_json::from_str(request.body.as_deref().unwrap_or_default()).map_err(
)) |error| {
WorkspaceClientError::Request(format!(
"invalid remote Workdir operation: {error}"
))
},
)?;
match operation.operation {
workdir::http::WorkdirSessionOperation::AuthorizeScope(_) => {
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(
&workdir::http::WorkdirSessionOperationResult::AuthorizeScope,
)
.unwrap(),
})
}
_ => Err(WorkspaceClientError::Request(
"SubWorker spawn may only authorize its provider-side scope".into(),
)),
}
} }
} }
@@ -1905,6 +1961,7 @@ max_tokens = 3333
target: PathBuf::from("/tmp/child"), target: PathBuf::from("/tmp/child"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]; }];
let config_json = let config_json =
File diff suppressed because it is too large Load Diff
+591 -141
View File
@@ -8,23 +8,112 @@
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
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, UsageEvent};
use agen::llm_client::types::Item; use agen::llm_client::types::Item;
use agen::llm_client::{ClientError, LlmClient, Request}; use agen::llm_client::{ClientError, LlmClient, Request};
use async_trait::async_trait; use async_trait::async_trait;
use futures::Stream; use futures::Stream;
use protocol::{Event, Method, RunResult}; use protocol::{Event, Method, RunResult};
use session_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore}; use session_store::{
use session_store::{FsStore, LogEntry, Store}; CombinedStore, FsStore, FsWorkerStore, LogEntry, Store, WorkerMetadata, WorkerMetadataStore,
WorkerStoreError,
};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use worker::{Worker, WorkerController}; use worker::{Worker, WorkerController, WorkerError};
type TestStore = CombinedStore<FsStore, FsWorkerStore>; type TestStore = CombinedStore<FsStore, FsWorkerStore>;
#[derive(Clone)]
struct FaultingWorkerMetadataStore {
inner: FsWorkerStore,
fail_next_update: Arc<AtomicBool>,
}
impl FaultingWorkerMetadataStore {
fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
inner: FsWorkerStore::new(root).unwrap(),
fail_next_update: Arc::new(AtomicBool::new(false)),
}
}
fn arm_update_failure(&self) {
self.fail_next_update.store(true, Ordering::SeqCst);
}
}
impl WorkerMetadataStore for FaultingWorkerMetadataStore {
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
let old_segment_id = self
.inner
.read_by_name(&metadata.worker_name)?
.and_then(|current| current.active)
.and_then(|active| active.segment_id);
let new_segment_id = metadata
.active
.as_ref()
.and_then(|active| active.segment_id);
if old_segment_id != new_segment_id && self.fail_next_update.swap(false, Ordering::SeqCst) {
return Err(WorkerStoreError::Io(std::io::Error::other(
"injected active Segment commit failure",
)));
}
self.inner.write(metadata)
}
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
self.inner.read_by_name(worker_name)
}
fn update_by_name<F>(
&self,
worker_name: &str,
mutate: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
let mut metadata = self
.inner
.read_by_name(worker_name)?
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
let old_segment_id = metadata
.active
.as_ref()
.and_then(|active| active.segment_id);
mutate(&mut metadata);
let new_segment_id = metadata
.active
.as_ref()
.and_then(|active| active.segment_id);
if old_segment_id != new_segment_id && self.fail_next_update.swap(false, Ordering::SeqCst) {
return Err(WorkerStoreError::Io(std::io::Error::other(
"injected active Segment commit failure",
)));
}
self.inner.write(&metadata)?;
Ok(metadata)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
self.inner.list_names()
}
fn root_dir(&self) -> Option<std::path::PathBuf> {
self.inner.root_dir()
}
fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
self.inner.delete_by_name(worker_name)
}
}
type FaultingTestStore = CombinedStore<FsStore, FaultingWorkerMetadataStore>;
fn annotated(item: Item) -> session_store::LoggedHistoryEntry { fn annotated(item: Item) -> session_store::LoggedHistoryEntry {
session_store::LoggedHistoryEntry { session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item), item: session_store::LoggedItem::from(item),
@@ -145,6 +234,49 @@ fn write_summary_tool_use_events(call_id: &str, text: &str) -> Vec<LlmEvent> {
] ]
} }
fn write_summary_tool_use_events_with_usage(
call_id: &str,
text: &str,
input_total: u64,
cache_read: u64,
cache_write: u64,
output: u64,
) -> Vec<LlmEvent> {
let mut events = write_summary_tool_use_events(call_id, text);
events.insert(
events.len() - 1,
LlmEvent::Usage(UsageEvent {
input_tokens: Some(input_total),
output_tokens: Some(output),
total_tokens: Some(input_total.saturating_add(output)),
cache_read_input_tokens: Some(cache_read),
cache_creation_input_tokens: Some(cache_write),
}),
);
events
}
fn text_events_with_full_usage(
text: &str,
input_total: u64,
cache_read: u64,
cache_write: u64,
output: u64,
) -> Vec<LlmEvent> {
let mut events = single_text_events(text);
events.insert(
events.len() - 1,
LlmEvent::Usage(UsageEvent {
input_tokens: Some(input_total),
output_tokens: Some(output),
total_tokens: Some(input_total.saturating_add(output)),
cache_read_input_tokens: Some(cache_read),
cache_creation_input_tokens: Some(cache_write),
}),
);
events
}
// A low compact_threshold guarantees `try_pre_run_compact` will fire // A low compact_threshold guarantees `try_pre_run_compact` will fire
// the first time we check after a run. // the first time we check after a run.
const POST_RUN_MANIFEST_TOML: &str = r#" const POST_RUN_MANIFEST_TOML: &str = r#"
@@ -168,6 +300,27 @@ target = "./"
permission = "write" permission = "write"
"#; "#;
const MANUAL_ONLY_MANIFEST_TOML: &str = r#"
[worker]
name = "test-worker"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
max_tokens = 100
[compaction]
compact_threshold = 1000000000
compact_retained_tokens = 0
[[scope.allow]]
target = "./"
permission = "write"
"#;
// `compact_request_threshold` drives the WorkerInterceptor's mid-turn yield // `compact_request_threshold` drives the WorkerInterceptor's mid-turn yield
// path. `compact_threshold` is left unset so the post-run check stays inert. // path. `compact_threshold` is left unset so the post-run check stays inert.
const MID_TURN_MANIFEST_TOML: &str = r#" const MID_TURN_MANIFEST_TOML: &str = r#"
@@ -229,6 +382,41 @@ async fn make_worker(client: MockClient) -> Worker<MockClient, TestStore> {
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await
} }
async fn make_faulting_worker(
client: MockClient,
) -> (
Worker<MockClient, FaultingTestStore>,
FaultingWorkerMetadataStore,
FsStore,
) {
let manifest = worker::WorkerManifest::from_toml(MID_TURN_MANIFEST_TOML).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let segment_store = FsStore::new(store_tmp.path()).unwrap();
let metadata_store = FaultingWorkerMetadataStore::new(store_tmp.path().join("pods"));
let store = CombinedStore::new(segment_store.clone(), metadata_store.clone());
std::mem::forget(store_tmp);
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp);
let engine =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
engine,
store,
worker::WorkerWorkspaceContext::local_filesystem(None),
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
scope,
)
.await
.unwrap();
worker.enable_worker_metadata_write_through().unwrap();
(worker, metadata_store, segment_store)
}
/// Drain whatever events are already queued on `rx`. Non-blocking. /// Drain whatever events are already queued on `rx`. Non-blocking.
fn drain(rx: &mut broadcast::Receiver<Event>) -> Vec<Event> { fn drain(rx: &mut broadcast::Receiver<Event>) -> Vec<Event> {
let mut out = Vec::new(); let mut out = Vec::new();
@@ -282,6 +470,118 @@ fn system_texts_in_sink_session_start(
Vec::new() Vec::new()
} }
#[tokio::test]
async fn active_segment_cas_rejects_stale_compaction_writer() {
let client = MockClient::new(vec![
single_text_events("seed response"),
write_summary_tool_use_events("summary-1", "replacement summary"),
single_text_events("done"),
]);
let (mut worker, metadata_store, segment_store) = make_faulting_worker(client).await;
worker.run_text("seed input").await.unwrap();
let old_segment_id = worker.segment_id();
let session_id = worker.session_id();
let competing_segment_id = uuid::Uuid::now_v7();
metadata_store
.update_by_name("test-worker", |metadata| {
metadata.active.as_mut().unwrap().segment_id = Some(competing_segment_id);
})
.unwrap();
let error = worker.compact(0).await.unwrap_err();
assert!(
matches!(error, worker::WorkerError::CompactActiveSegmentChanged),
"unexpected stale CAS error: {error:?}"
);
assert_eq!(worker.segment_id(), old_segment_id);
let failure_metrics =
session_metrics::read_segment_metrics(&segment_store, session_id, old_segment_id).unwrap();
let finish = failure_metrics
.iter()
.find(|record| record.metric.name == "compact.finish")
.unwrap();
assert_eq!(finish.metric.dimensions["outcome"], "failed");
assert_eq!(finish.metric.value, Some(1.0));
assert_eq!(
finish.metric.dimensions["failure_category"],
"active_segment_commit"
);
assert!(!finish.metric.dimensions.contains_key("error"));
assert_eq!(
metadata_store
.read_by_name("test-worker")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(competing_segment_id)
);
}
#[tokio::test]
async fn failed_active_segment_commit_keeps_live_and_durable_history_on_old_segment() {
let client = MockClient::new(vec![
single_text_events("seed response"),
write_summary_tool_use_events("summary-1", "replacement summary"),
single_text_events("continued on old segment"),
]);
let (mut worker, metadata_store, segment_store) = make_faulting_worker(client).await;
worker.run_text("seed input").await.unwrap();
let old_segment_id = worker.segment_id();
metadata_store.arm_update_failure();
let error = worker.compact(0).await.unwrap_err();
assert!(
error
.to_string()
.contains("injected active Segment commit failure")
);
assert_eq!(worker.segment_id(), old_segment_id);
let failure_metrics =
session_metrics::read_segment_metrics(&segment_store, worker.session_id(), old_segment_id)
.unwrap();
let start = failure_metrics
.iter()
.find(|record| record.metric.name == "compact.start")
.unwrap();
let finish = failure_metrics
.iter()
.find(|record| record.metric.name == "compact.finish")
.unwrap();
assert_eq!(finish.metric.dimensions["outcome"], "failed");
assert_eq!(finish.metric.value, Some(1.0));
assert_eq!(
finish.metric.dimensions["failure_category"],
"active_segment_commit"
);
assert_eq!(finish.metric.correlation_id, start.metric.correlation_id);
assert!(
!serde_json::to_string(&finish.metric)
.unwrap()
.contains("injected active Segment commit failure")
);
let metadata = metadata_store
.read_by_name("test-worker")
.unwrap()
.expect("active Worker metadata should remain present");
assert_eq!(
metadata.active.and_then(|active| active.segment_id),
Some(old_segment_id)
);
worker.run_text("continue input").await.unwrap();
let active_records = segment_store
.read_all(worker.session_id(), old_segment_id)
.unwrap();
assert!(
format!("{active_records:?}").contains("continue input"),
"the live Worker must continue appending to the old active Segment"
);
}
/// Worker metadata starts with a reserved Session and no Segment, then becomes /// Worker metadata starts with a reserved Session and no Segment, then becomes
/// active once the first SegmentStart is materialized by `run`. /// active once the first SegmentStart is materialized by `run`.
#[tokio::test] #[tokio::test]
@@ -411,16 +711,18 @@ permission = "write"
async fn compact_emits_session_start_carrying_summary_and_task_snapshot() { async fn compact_emits_session_start_carrying_summary_and_task_snapshot() {
let client = MockClient::new(vec![ let client = MockClient::new(vec![
single_text_events("hi"), single_text_events("hi"),
write_summary_tool_use_events("call-1", "summary"), write_summary_tool_use_events_with_usage("call-1", "summary", 100, 10, 5, 20),
single_text_events("done"), text_events_with_full_usage("done", 50, 3, 2, 10),
text_events_with_full_usage("after", 44, 4, 1, 6),
]); ]);
let mut worker = make_worker(client).await; let mut worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
let (tx, _rx_keep) = broadcast::channel::<Event>(64); let (tx, _rx_keep) = broadcast::channel::<Event>(64);
worker.attach_working_event_tx(tx); worker.attach_working_event_tx(tx);
worker.run_text("first").await.unwrap(); worker.run_text("first").await.unwrap();
let session_id = worker.session_id(); let session_id = worker.session_id();
let source_segment_id = worker.segment_id();
worker.compact(10_000).await.unwrap(); worker.compact(10_000).await.unwrap();
let compacted_segment_id = worker.segment_id(); let compacted_segment_id = worker.segment_id();
let metadata = worker let metadata = worker
@@ -448,10 +750,158 @@ async fn compact_emits_session_start_carrying_summary_and_task_snapshot() {
.any(|text| text.starts_with("[Session TaskStore snapshot]")), .any(|text| text.starts_with("[Session TaskStore snapshot]")),
"task snapshot system message missing from {system_texts:?}" "task snapshot system message missing from {system_texts:?}"
); );
worker.run_text("after compaction").await.unwrap();
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
let starts = metrics
.iter()
.filter(|record| record.metric.name == "compact.start")
.collect::<Vec<_>>();
assert_eq!(starts.len(), 1);
assert_eq!(starts[0].segment_id, source_segment_id);
assert_eq!(starts[0].metric.dimensions["mode"], "automatic");
assert_eq!(starts[0].metric.dimensions["trigger"], "request_threshold");
assert_eq!(
starts[0].metric.dimensions["threshold_policy"],
"request_threshold"
);
let correlation_id = starts[0]
.metric
.correlation_id
.as_deref()
.expect("compact start must carry a correlation id");
let finish = metrics
.iter()
.find(|record| record.metric.name == "compact.finish")
.unwrap();
assert_eq!(finish.segment_id, compacted_segment_id);
assert_eq!(finish.metric.dimensions["outcome"], "succeeded");
assert_eq!(finish.metric.value, Some(1.0));
assert_eq!(
finish.metric.correlation_id.as_deref(),
Some(correlation_id)
);
assert_eq!(
finish.compacted_from.as_ref().unwrap().segment_id,
starts[0].segment_id
);
let value = |name: &str| {
metrics
.iter()
.find(|record| record.metric.name == name)
.and_then(|record| record.metric.value)
.unwrap() as u64
};
assert_eq!(value("compact.input_tokens"), 150);
assert_eq!(value("compact.cache_read_tokens"), 13);
assert_eq!(value("compact.cache_creation_tokens"), 7);
assert_eq!(value("compact.output_tokens"), 30);
assert_eq!(value("compact.requests"), 2);
assert!(value("compact.tool_calls") >= 1);
assert!(value("compact.turns") >= 2);
assert!(value("compact.duration_ms") <= u64::MAX);
let cost = metrics
.iter()
.find(|record| record.metric.name == "compact.cost_usd")
.unwrap();
assert_eq!(cost.metric.value, None);
assert_eq!(cost.metric.dimensions["status"], "unavailable");
let post = metrics
.iter()
.find(|record| record.metric.name == "compact.post_request")
.unwrap();
assert_eq!(post.segment_id, compacted_segment_id);
assert_eq!(post.metric.correlation_id.as_deref(), Some(correlation_id));
assert_eq!(post.metric.dimensions["input_total_tokens"], "44");
assert_eq!(post.metric.dimensions["cache_read_tokens"], "4");
assert_eq!(post.metric.dimensions["cache_write_tokens"], "1");
assert_eq!(post.metric.dimensions["output_tokens"], "6");
} }
#[tokio::test] #[tokio::test]
async fn pre_run_compact_success_broadcasts_start_and_done() { async fn manual_compact_metrics_identify_manual_mode() {
let client = MockClient::new(vec![
single_text_events("seed response"),
write_summary_tool_use_events("summary", "replacement summary"),
single_text_events("done"),
]);
let mut worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
worker.run_text("seed input").await.unwrap();
let session_id = worker.session_id();
worker.manual_compact().await.unwrap();
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
let start = metrics
.iter()
.find(|record| record.metric.name == "compact.start")
.unwrap();
assert_eq!(start.metric.dimensions["mode"], "manual");
assert_eq!(start.metric.dimensions["trigger"], "manual");
assert_eq!(start.metric.dimensions["threshold_policy"], "manual");
let finish = metrics
.iter()
.find(|record| record.metric.name == "compact.finish")
.unwrap();
assert_eq!(finish.metric.dimensions["outcome"], "succeeded");
assert_eq!(finish.metric.value, Some(1.0));
assert_eq!(finish.metric.correlation_id, start.metric.correlation_id);
}
#[tokio::test]
async fn compact_failure_and_cancellation_emit_bounded_categories() {
let client = MockClient::new(vec![
single_text_events("seed response"),
single_text_events("missing summary"),
single_text_events("still missing summary"),
]);
let mut worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
worker.run_text("seed input").await.unwrap();
let session_id = worker.session_id();
let source_segment_id = worker.segment_id();
let error = worker.manual_compact().await.unwrap_err();
assert!(matches!(error, worker::WorkerError::CompactSummaryMissing));
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
let failure = metrics
.iter()
.find(|record| {
record.metric.name == "compact.finish"
&& record.metric.dimensions["outcome"] == "failed"
})
.unwrap();
assert_eq!(failure.segment_id, source_segment_id);
assert_eq!(failure.metric.value, Some(1.0));
assert_eq!(
failure.metric.dimensions["failure_category"],
"summary_missing"
);
let encoded = serde_json::to_string(&failure.metric).unwrap();
assert!(!encoded.contains("missing summary"));
let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(true);
let error = worker
.manual_compact_with_cancel(cancel_rx)
.await
.unwrap_err();
assert!(matches!(error, worker::WorkerError::CompactCancelled));
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
let cancelled = metrics
.iter()
.filter(|record| {
record.metric.name == "compact.finish"
&& record.metric.dimensions["outcome"] == "cancelled"
})
.last()
.unwrap();
assert_eq!(cancelled.segment_id, source_segment_id);
assert_eq!(cancelled.metric.value, Some(1.0));
assert_eq!(cancelled.metric.dimensions["failure_category"], "cancelled");
}
#[tokio::test]
async fn pre_run_compact_publishes_runtime_progress_phases() {
// Responses: (1) first run returns short text, (2) compact worker // Responses: (1) first run returns short text, (2) compact worker
// emits write_summary then closes (two LLM calls inside the compact // emits write_summary then closes (two LLM calls inside the compact
// worker: one for write_summary, one that the compact loop consumes // worker: one for write_summary, one that the compact loop consumes
@@ -477,86 +927,62 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
assert_ne!(worker.segment_id(), segment_before); assert_ne!(worker.segment_id(), segment_before);
let events = drain(&mut rx); let events = drain(&mut rx);
let kinds: Vec<&str> = events let progress = events
.iter()
.map(|e| match e {
Event::CompactStart { .. } => "start",
Event::CompactDone { .. } => "done",
Event::CompactFailed { .. } => "failed",
_ => "other",
})
.collect();
assert!(
kinds.contains(&"start") && kinds.contains(&"done"),
"expected CompactStart + CompactDone in {kinds:?}"
);
assert!(
!kinds.contains(&"failed"),
"unexpected CompactFailed in {kinds:?}"
);
let starts = events
.iter() .iter()
.filter_map(|event| match event { .filter_map(|event| match event {
Event::CompactStart { lifecycle } => Some(lifecycle), Event::CompactionProgress { compaction } => {
Some(compaction.as_ref().map(|item| item.phase))
}
_ => None, _ => None,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!( assert_eq!(
starts.len(), progress,
2, vec![
"start and Internal Worker binding revisions" Some(protocol::CompactionPhase::Preparing),
Some(protocol::CompactionPhase::Summarizing),
Some(protocol::CompactionPhase::Committing),
None,
]
); );
assert_eq!(starts[0].compaction_id, starts[1].compaction_id); assert!(events.iter().all(|event| !matches!(
assert_eq!(starts[0].revision, 1); event,
assert!(starts[0].internal_worker.is_none()); Event::CompactStart { .. } | Event::CompactDone { .. } | Event::CompactFailed { .. }
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!( assert!(events.iter().any(|event| matches!(
event, event,
Event::InternalWorker { worker, .. } Event::InternalWorker { worker, .. }
if matches!(&worker.kind, protocol::InternalWorkerKind::Service { kind } if kind == "compaction") if matches!(&worker.kind, protocol::InternalWorkerKind::Service { kind } if kind == "compaction")
)), "compactor activity must be projected through the parent stream"); )), "compactor activity must be projected through the parent stream");
let completed = events
.iter()
.find_map(|event| match event {
Event::CompactDone { lifecycle } => Some(lifecycle),
_ => None,
})
.expect("completed lifecycle");
assert_eq!(completed.compaction_id, starts[0].compaction_id);
assert_eq!(completed.revision, 3);
assert_eq!(completed.summary.as_deref(), Some("summary"));
assert_eq!(completed.state, protocol::CompactionLifecycleState::Done);
let done_index = events
.iter()
.position(|event| matches!(event, Event::CompactDone { .. }))
.expect("done event");
let removed_index = events
.iter()
.position(|event| matches!(event, Event::InternalWorkerRemoved { .. }))
.expect("terminal compactor session must be released");
assert!(
done_index < removed_index,
"terminal lifecycle precedes release fence"
);
// CompactDone carries the new Segment ID; the Session ID is unchanged. let active_entries = worker
let new_id_in_event = events.iter().find_map(|e| match e { .store()
Event::CompactDone { lifecycle } => lifecycle .read_all(worker.session_id(), worker.segment_id())
.new_segment_id .unwrap();
.as_deref() assert!(!active_entries.iter().any(|entry| matches!(
.and_then(|value| uuid::Uuid::parse_str(value).ok()), entry,
_ => None, LogEntry::Extension { domain, .. } if domain == "yoi.compaction"
}); )));
assert!(new_id_in_event.is_some(), "CompactDone missing"); let metrics = session_metrics::read_session_metrics(worker.store(), session_before).unwrap();
assert_eq!(new_id_in_event.unwrap(), worker.segment_id()); let start = metrics
.iter()
.find(|record| record.metric.name == "compact.start")
.unwrap();
assert_eq!(start.segment_id, segment_before);
assert_eq!(start.metric.dimensions["mode"], "automatic");
assert_eq!(start.metric.dimensions["trigger"], "pre_run");
assert_eq!(start.metric.dimensions["threshold_policy"], "pre_run");
let finish = metrics
.iter()
.find(|record| record.metric.name == "compact.finish")
.unwrap();
assert_eq!(finish.metric.dimensions["outcome"], "succeeded");
assert_eq!(finish.metric.value, Some(1.0));
assert_eq!(finish.metric.correlation_id, start.metric.correlation_id);
} }
#[tokio::test] #[tokio::test]
async fn mid_turn_compact_success_broadcasts_start_and_done() { async fn request_threshold_compact_publishes_runtime_progress() {
// Path: `do_compact_and_resume` via PreRequestAction::Yield. // Path: `do_compact_and_resume` via PreRequestAction::Yield.
// //
// Sequence of LLM calls the mock will serve: // Sequence of LLM calls the mock will serve:
@@ -566,12 +992,16 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
// [2] compact worker closes (its final "done" response). // [2] compact worker closes (its final "done" response).
// [3] resume() after compact makes one more LLM call. // [3] resume() after compact makes one more LLM call.
let client = MockClient::new(vec![ let client = MockClient::new(vec![
text_events_with_usage("a", 1000), text_events_with_usage("a", 100_000),
write_summary_tool_use_events("call-1", "summary"), write_summary_tool_use_events("call-1", "summary"),
single_text_events("done"), single_text_events("done"),
single_text_events("b"), text_events_with_usage("b", 50),
]); ]);
let mut worker = make_worker_with_manifest(MID_TURN_MANIFEST_TOML, client).await; let manifest = MID_TURN_MANIFEST_TOML.replace(
"compact_request_threshold = 100",
"compact_request_threshold = 50000",
);
let mut worker = make_worker_with_manifest(&manifest, client).await;
let (tx, mut rx) = broadcast::channel::<Event>(64); let (tx, mut rx) = broadcast::channel::<Event>(64);
worker.attach_working_event_tx(tx); worker.attach_working_event_tx(tx);
@@ -585,36 +1015,62 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
worker.run_text("second").await.unwrap(); worker.run_text("second").await.unwrap();
let events = drain(&mut rx); let events = drain(&mut rx);
let kinds: Vec<&str> = events assert!(events.iter().any(|event| matches!(
event,
Event::CompactionProgress { compaction: Some(progress) }
if progress.phase == protocol::CompactionPhase::Committing
)));
assert!(
events
.iter()
.any(|event| matches!(event, Event::CompactionProgress { compaction: None }))
);
let metrics =
session_metrics::read_session_metrics(worker.store(), worker.session_id()).unwrap();
let start = metrics
.iter() .iter()
.map(|e| match e { .find(|record| record.metric.name == "compact.start")
Event::CompactStart { .. } => "start", .unwrap();
Event::CompactDone { .. } => "done", assert_eq!(start.metric.dimensions["trigger"], "request_threshold");
Event::CompactFailed { .. } => "failed", assert_eq!(
_ => "other", start.metric.dimensions["threshold_policy"],
}) "request_threshold"
.collect();
assert!(
kinds.contains(&"start") && kinds.contains(&"done"),
"expected CompactStart + CompactDone in {kinds:?}"
); );
assert!( let correlation_id = start.metric.correlation_id.as_deref().unwrap();
!kinds.contains(&"failed"), let post = metrics
"unexpected CompactFailed in {kinds:?}" .iter()
); .find(|record| record.metric.name == "compact.post_request")
.unwrap();
let new_id_in_event = events.iter().find_map(|e| match e { assert_eq!(post.metric.correlation_id.as_deref(), Some(correlation_id));
Event::CompactDone { lifecycle } => lifecycle
.new_segment_id
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
_ => None,
});
assert_eq!(new_id_in_event, Some(worker.segment_id()));
} }
#[tokio::test] #[tokio::test]
async fn pre_run_compact_failure_broadcasts_start_and_failed() { async fn compacted_context_above_request_threshold_fails_before_provider_request() {
let client = MockClient::new(vec![
text_events_with_usage("seed", 1000),
write_summary_tool_use_events("call-1", "still too large after compaction"),
single_text_events("done"),
single_text_events("must not be requested"),
]);
let call_count = Arc::clone(&client.call_count);
let mut worker = make_worker_with_manifest(MID_TURN_MANIFEST_TOML, client).await;
worker.run_text("first").await.unwrap();
let error = worker
.run_text("second")
.await
.expect_err("unsafe compacted context must fail closed");
assert!(matches!(error, WorkerError::CompactThrash));
assert_eq!(
call_count.load(Ordering::SeqCst),
3,
"the provider must receive only the seed and compaction requests"
);
}
#[tokio::test]
async fn pre_run_compact_failure_clears_runtime_progress() {
// Only the first run has a response. Compaction will run the // Only the first run has a response. Compaction will run the
// compact worker which immediately exhausts the mock → failure. // compact worker which immediately exhausts the mock → failure.
let client = MockClient::new(vec![single_text_events("hi")]); let client = MockClient::new(vec![single_text_events("hi")]);
@@ -626,31 +1082,28 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
worker.run_text("first").await.unwrap(); worker.run_text("first").await.unwrap();
let _ = drain(&mut rx); let _ = drain(&mut rx);
// Best-effort: returns Ok(()) even on failure, but emits CompactFailed. // Best-effort: returns Ok(()) even on failure and clears runtime progress.
worker.try_pre_run_compact().await; worker.try_pre_run_compact().await;
let events = drain(&mut rx); let events = drain(&mut rx);
let kinds: Vec<&str> = events assert!(events.iter().any(|event| matches!(
.iter() event,
.map(|e| match e { Event::CompactionProgress { compaction: Some(progress) }
Event::CompactStart { .. } => "start", if progress.phase == protocol::CompactionPhase::Preparing
Event::CompactDone { .. } => "done", )));
Event::CompactFailed { .. } => "failed",
_ => "other",
})
.collect();
assert!( assert!(
kinds.contains(&"start") && kinds.contains(&"failed"), events
"expected CompactStart + CompactFailed in {kinds:?}" .iter()
); .any(|event| matches!(event, Event::CompactionProgress { compaction: None }))
assert!(
!kinds.contains(&"done"),
"unexpected CompactDone in {kinds:?}"
); );
assert!(events.iter().all(|event| !matches!(
event,
Event::CompactStart { .. } | Event::CompactDone { .. } | Event::CompactFailed { .. }
)));
} }
#[tokio::test] #[tokio::test]
async fn manual_compact_cancel_terminalizes_before_returning_idle() { async fn manual_compact_cancel_clears_progress_before_returning_idle() {
let worker = let worker =
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new()).await; make_worker_with_manifest(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new()).await;
let runtime_tmp = tempfile::tempdir().unwrap(); let runtime_tmp = tempfile::tempdir().unwrap();
@@ -682,7 +1135,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
} }
} }
let compact = protocol::WorkerCommandEnvelope::for_snapshot(1, &handle.shared_state.snapshot()); let compact = protocol::WorkerCommandEnvelope::new(1);
handle handle
.send(Method::Compact { command: compact }) .send(Method::Compact { command: compact })
.await .await
@@ -693,13 +1146,15 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
.await .await
.expect("timeout waiting for compact start") .expect("timeout waiting for compact start")
.expect("event"), .expect("event"),
Event::CompactStart { .. } Event::CompactionProgress {
compaction: Some(_)
}
) { ) {
break; break;
} }
} }
let cancel = protocol::WorkerCommandEnvelope::for_snapshot(2, &handle.shared_state.snapshot()); let cancel = protocol::WorkerCommandEnvelope::new(2);
handle handle
.send(Method::Cancel { command: cancel }) .send(Method::Cancel { command: cancel })
.await .await
@@ -712,9 +1167,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
.expect("timeout waiting for compact cancellation") .expect("timeout waiting for compact cancellation")
.expect("event") .expect("event")
{ {
Event::CompactFailed { lifecycle } Event::CompactionProgress { compaction: None } => {
if lifecycle.state == protocol::CompactionLifecycleState::Interrupted =>
{
saw_interrupted = true; saw_interrupted = true;
} }
Event::WorkerState { snapshot } Event::WorkerState { snapshot }
@@ -730,7 +1183,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
} }
} }
let compact = protocol::WorkerCommandEnvelope::for_snapshot(3, &handle.shared_state.snapshot()); let compact = protocol::WorkerCommandEnvelope::new(3);
handle handle
.send(Method::Compact { command: compact }) .send(Method::Compact { command: compact })
.await .await
@@ -741,13 +1194,14 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
.await .await
.expect("timeout waiting for second compact start") .expect("timeout waiting for second compact start")
.expect("event"), .expect("event"),
Event::CompactStart { .. } Event::CompactionProgress {
compaction: Some(_)
}
) { ) {
break; break;
} }
} }
let shutdown = let shutdown = protocol::WorkerCommandEnvelope::new(4);
protocol::WorkerCommandEnvelope::for_snapshot(4, &handle.shared_state.snapshot());
handle handle
.send(Method::Shutdown { command: shutdown }) .send(Method::Shutdown { command: shutdown })
.await .await
@@ -759,9 +1213,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
.expect("timeout waiting for shutdown") .expect("timeout waiting for shutdown")
.expect("event") .expect("event")
{ {
Event::CompactFailed { lifecycle } Event::CompactionProgress { compaction: None } => {
if lifecycle.state == protocol::CompactionLifecycleState::Interrupted =>
{
interrupted_before_shutdown = true; interrupted_before_shutdown = true;
} }
Event::Shutdown => { Event::Shutdown => {
@@ -781,14 +1233,14 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
} }
#[tokio::test] #[tokio::test]
async fn controller_compact_method_emits_start_and_done() { async fn controller_compact_method_publishes_progress_and_clear() {
let client = MockClient::new(vec![ let client = MockClient::new(vec![
text_events_with_usage("hi", 1000), text_events_with_usage("hi", 1000),
write_summary_tool_use_events("manual-summary", "manual compact summary"), write_summary_tool_use_events("manual-summary", "manual compact summary"),
single_text_events("done"), single_text_events("done"),
single_text_events("follow-up"), single_text_events("follow-up"),
]); ]);
let worker = make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await; let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
let runtime_tmp = tempfile::tempdir().unwrap(); let runtime_tmp = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_tmp.path().join("bash-output"); let bash_output_dir = runtime_tmp.path().join("bash-output");
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
@@ -816,7 +1268,7 @@ async fn controller_compact_method_emits_start_and_done() {
} }
} }
let command = protocol::WorkerCommandEnvelope::for_snapshot(1, &handle.shared_state.snapshot()); let command = protocol::WorkerCommandEnvelope::new(1);
handle handle
.send(Method::Compact { command }) .send(Method::Compact { command })
.await .await
@@ -828,14 +1280,12 @@ 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::CompactionProgress {
Event::CompactDone { .. } => { compaction: Some(_),
} => saw_start = true,
Event::CompactionProgress { compaction: None } => {
break; break;
} }
Event::CompactFailed { lifecycle } => panic!(
"manual compact failed: {}",
lifecycle.error.as_deref().unwrap_or("unknown error")
),
_ => {} _ => {}
} }
} }
@@ -865,6 +1315,6 @@ async fn controller_compact_method_emits_start_and_done() {
protocol::WorkerStatus::Idle, protocol::WorkerStatus::Idle,
"successful manual compaction must release the execution fence" "successful manual compaction must release the execution fence"
); );
let command = protocol::WorkerCommandEnvelope::for_snapshot(2, &handle.shared_state.snapshot()); let command = protocol::WorkerCommandEnvelope::new(2);
let _ = handle.send(Method::Shutdown { command }).await; let _ = handle.send(Method::Shutdown { command }).await;
} }
+66 -5
View File
@@ -27,11 +27,8 @@ type TestStore = CombinedStore<FsStore, FsWorkerStore>;
static NEXT_COMMAND_ID: AtomicU64 = AtomicU64::new(1); static NEXT_COMMAND_ID: AtomicU64 = AtomicU64::new(1);
fn worker_command(handle: &WorkerHandle) -> protocol::WorkerCommandEnvelope { fn worker_command(_handle: &WorkerHandle) -> protocol::WorkerCommandEnvelope {
protocol::WorkerCommandEnvelope::for_snapshot( protocol::WorkerCommandEnvelope::new(NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed))
NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed),
&handle.shared_state.snapshot(),
)
} }
/// Reconstruct a worker-history-like `Vec<Item>` from the live session /// Reconstruct a worker-history-like `Vec<Item>` from the live session
@@ -2219,6 +2216,70 @@ async fn status_json_reflects_worker_name() {
// Socket transport tests // Socket transport tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[tokio::test]
async fn shutdown_closes_method_admission_before_terminal_confirmation() {
let worker = make_worker(MockClient::new(simple_text_events())).await;
let runtime_base = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_base.path().join("bash-output");
let (handle, mut shutdown_rx) =
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
.await
.unwrap();
handle
.send(Method::Shutdown {
command: worker_command(&handle),
})
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
tokio::select! {
biased;
result = handle.send(Method::ListRewindTargets) => {
if result.is_err() {
break;
}
}
result = &mut shutdown_rx => {
result.expect("controller shutdown signal should remain open");
panic!("method admission remained open until terminal confirmation");
}
}
}
})
.await
.expect("method admission did not close during shutdown");
shutdown_rx.await.unwrap();
}
#[tokio::test]
async fn shutdown_joins_socket_server_with_active_connection() {
use tokio::net::UnixStream;
let worker = make_worker(MockClient::new(simple_text_events())).await;
let runtime_base = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_base.path().join("bash-output");
let (handle, shutdown_rx) =
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
.await
.unwrap();
let socket_path = handle.runtime_dir.socket_path();
let _connection = UnixStream::connect(&socket_path).await.unwrap();
handle
.send(Method::Shutdown {
command: worker_command(&handle),
})
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), shutdown_rx)
.await
.expect("controller should join its socket tasks")
.expect("controller shutdown signal should remain open");
assert!(!socket_path.exists());
}
#[tokio::test] #[tokio::test]
async fn socket_run_receives_events() { async fn socket_run_receives_events() {
use protocol::stream::{JsonLineReader, JsonLineWriter}; use protocol::stream::{JsonLineReader, JsonLineWriter};
@@ -27,6 +27,7 @@ async fn restore_reclaims_and_clears_legacy_process_children() {
target: scope_root.path().to_path_buf(), target: scope_root.path().to_path_buf(),
permission: "write".into(), permission: "write".into(),
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}); });
store.write(&metadata).unwrap(); store.write(&metadata).unwrap();
@@ -35,6 +36,7 @@ async fn restore_reclaims_and_clears_legacy_process_children() {
target: scope_root.path().to_path_buf(), target: scope_root.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}; };
let parent_scope = SharedScope::new( let parent_scope = SharedScope::new(
Scope::from_config(&ScopeConfig { Scope::from_config(&ScopeConfig {
@@ -36,6 +36,7 @@ async fn legacy_callback_cannot_register_process_subworker_authority() {
target: scope_root.path().to_path_buf(), target: scope_root.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}; };
+167 -8
View File
@@ -365,23 +365,23 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> {
/// ///
/// Local paths remain distinct from network Git transports so callers cannot /// Local paths remain distinct from network Git transports so callers cannot
/// accidentally treat an unmaterialized remote as a server-local filesystem path. /// accidentally treat an unmaterialized remote as a server-local filesystem path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[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 RepositorySourceKind { pub enum RepositorySourceKind {
LocalPath, LocalPath,
File, File,
Ssh, Ssh,
Http,
Https, Https,
/// A legacy value that could not be classified during migration. It remains /// A legacy value that could not be classified during migration. It remains
/// inspectable but every provider operation must fail closed. /// inspectable but every provider operation must fail closed. Historical
/// `http` wire values decode into this non-executable classification.
Invalid, Invalid,
} }
impl RepositorySourceKind { impl RepositorySourceKind {
pub const fn is_remote(self) -> bool { pub const fn is_remote(self) -> bool {
matches!(self, Self::Ssh | Self::Http | Self::Https) matches!(self, Self::Ssh | Self::Https)
} }
pub const fn as_str(self) -> &'static str { pub const fn as_str(self) -> &'static str {
@@ -389,7 +389,6 @@ impl RepositorySourceKind {
Self::LocalPath => "local_path", Self::LocalPath => "local_path",
Self::File => "file", Self::File => "file",
Self::Ssh => "ssh", Self::Ssh => "ssh",
Self::Http => "http",
Self::Https => "https", Self::Https => "https",
Self::Invalid => "invalid", Self::Invalid => "invalid",
} }
@@ -400,14 +399,28 @@ impl RepositorySourceKind {
"local_path" => Self::LocalPath, "local_path" => Self::LocalPath,
"file" => Self::File, "file" => Self::File,
"ssh" => Self::Ssh, "ssh" => Self::Ssh,
"http" => Self::Http,
"https" => Self::Https, "https" => Self::Https,
"invalid" => Self::Invalid, "http" | "invalid" => Self::Invalid,
_ => return None, _ => return None,
}) })
} }
} }
impl<'de> Deserialize<'de> for RepositorySourceKind {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).ok_or_else(|| {
serde::de::Error::unknown_variant(
&value,
&["local_path", "file", "ssh", "https", "invalid"],
)
})
}
}
/// Stable Repository source identity stored by Workspace authority. /// Stable Repository source identity stored by Workspace authority.
#[derive(Debug, Clone, 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))]
@@ -507,6 +520,25 @@ pub struct WorkspaceRepositoryRecord {
pub updated_at: String, pub updated_at: String,
} }
/// Initial Repository registration intent for Workspace creation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct InitialRepositoryIntent {
pub repository_key: String,
pub uri: String,
#[serde(default)]
pub default_ref: Option<String>,
}
/// Request for atomically creating a Workspace and its initial Repository.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceCreateRequest {
pub operation_key: String,
pub display_name: String,
pub repository: InitialRepositoryIntent,
}
/// Response returned after atomically creating a Workspace and its first Repository. /// Response returned after atomically creating a Workspace and its first Repository.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -577,7 +609,7 @@ pub struct WorkspaceResponse {
pub extension_points: WorkspaceExtensionPoints, pub extension_points: WorkspaceExtensionPoints,
} }
/// Workspace identity metadata exposed by the current settings resource. /// Workspace display metadata exposed from the Server DB settings authority.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -1797,6 +1829,43 @@ pub struct RevokeRuntimeTrustKeyRequest {
pub expected_revision: u64, pub expected_revision: u64,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RemoveRuntimeRequest {
pub operation_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub expected_binding_revision: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeRemovalOperationState {
Pending,
CleanupPending,
Succeeded,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeRemovalOperationResponse {
pub operation_id: String,
pub workspace_id: String,
pub runtime_id: String,
pub state: RuntimeRemovalOperationState,
pub binding_removed: bool,
pub runtime_registration_removed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_category: Option<String>,
pub created_at: String,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_at: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[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")]
@@ -1839,6 +1908,15 @@ pub struct CreateRemoteRuntimeRequest {
pub expected_revision: Option<u64>, pub expected_revision: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct UpdateRemoteRuntimeRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub endpoint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[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")]
@@ -3094,10 +3172,14 @@ pub fn catalog_typescript() -> String {
WorkspaceRuntimeDetail::decl(&config), WorkspaceRuntimeDetail::decl(&config),
RuntimeTrustKeyRevealResponse::decl(&config), RuntimeTrustKeyRevealResponse::decl(&config),
RevokeRuntimeTrustKeyRequest::decl(&config), RevokeRuntimeTrustKeyRequest::decl(&config),
RemoveRuntimeRequest::decl(&config),
RuntimeRemovalOperationState::decl(&config),
RuntimeRemovalOperationResponse::decl(&config),
RuntimeTrustConflictKind::decl(&config), RuntimeTrustConflictKind::decl(&config),
RuntimeTrustConflictResponse::decl(&config), RuntimeTrustConflictResponse::decl(&config),
RuntimePublicIdentityBundle::decl(&config), RuntimePublicIdentityBundle::decl(&config),
CreateRemoteRuntimeRequest::decl(&config), CreateRemoteRuntimeRequest::decl(&config),
UpdateRemoteRuntimeRequest::decl(&config),
RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestStatus::decl(&config),
RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestFailureKind::decl(&config),
RuntimeConnectionTestResponse::decl(&config), RuntimeConnectionTestResponse::decl(&config),
@@ -3400,6 +3482,27 @@ mod workdir_typescript_tests {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn historical_http_repository_source_kind_decodes_as_invalid_evidence() {
let source: RepositorySource = serde_json::from_value(serde_json::json!({
"kind": "http",
"uri": "http://git.example.test/team/project.git",
"revision": 1,
}))
.unwrap();
assert_eq!(source.kind, RepositorySourceKind::Invalid);
assert_eq!(
serde_json::to_value(source).unwrap()["kind"],
serde_json::json!("invalid")
);
assert_eq!(
RepositorySourceKind::parse("http"),
Some(RepositorySourceKind::Invalid)
);
assert!(!RepositorySourceKind::Invalid.is_remote());
}
fn skill_projection() -> SkillProjectionIdentity { fn skill_projection() -> SkillProjectionIdentity {
SkillProjectionIdentity { SkillProjectionIdentity {
config_revision: 42, config_revision: 42,
@@ -3617,6 +3720,33 @@ mod tests {
} }
} }
#[test]
fn remote_runtime_metadata_update_cannot_carry_public_key_authority() {
let request = UpdateRemoteRuntimeRequest {
display_name: Some("Runtime A".to_string()),
endpoint: "https://runtime.example.test".to_string(),
};
assert_eq!(
serde_json::to_value(&request).unwrap(),
serde_json::json!({
"display_name": "Runtime A",
"endpoint": "https://runtime.example.test",
})
);
assert!(
serde_json::from_value::<UpdateRemoteRuntimeRequest>(serde_json::json!({
"display_name": "Runtime A",
"endpoint": "https://runtime.example.test",
"public_bundle": {
"identity_id": "runtime-a",
"public_key": "yoi-ed25519-pub:v1:not-accepted",
},
}))
.is_err(),
"metadata updates must reject public key fields"
);
}
#[test] #[test]
fn worker_launch_optional_omission_and_request_shape_are_stable() { fn worker_launch_optional_omission_and_request_shape_are_stable() {
assert_eq!( assert_eq!(
@@ -3788,6 +3918,35 @@ mod tests {
); );
} }
#[test]
fn workspace_create_request_has_one_closed_shared_wire_shape() {
let request = WorkspaceCreateRequest {
operation_key: "workspace-create-1".to_string(),
display_name: "Workspace".to_string(),
repository: InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: "/srv/repositories/main".to_string(),
default_ref: Some("develop".to_string()),
},
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["operation_key"], "workspace-create-1");
assert_eq!(json["repository"]["uri"], "/srv/repositories/main");
assert!(json.get("operation_id").is_none());
assert!(json["repository"].get("source").is_none());
assert!(
serde_json::from_value::<WorkspaceCreateRequest>(serde_json::json!({
"operation_id": "workspace-create-1",
"display_name": "Workspace",
"repository": {
"repository_key": "main",
"source": "/srv/repositories/main"
}
}))
.is_err()
);
}
#[test] #[test]
fn workspace_and_repository_response_shapes_round_trip() { fn workspace_and_repository_response_shapes_round_trip() {
let workspace = serde_json::json!({ let workspace = serde_json::json!({
+12 -9
View File
@@ -5,8 +5,8 @@ use std::{fs, io};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use crate::identity::WorkspaceIdentity;
use crate::server::{AuthConfig, ServerConfig}; use crate::server::{AuthConfig, ServerConfig};
use crate::store::WorkspaceRecord;
use crate::{Error, Result}; use crate::{Error, Result};
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml"; pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
@@ -100,15 +100,15 @@ impl ServerHostConfigFile {
impl ResolvedWorkspaceBackendConfig { impl ResolvedWorkspaceBackendConfig {
pub fn local_dev( pub fn local_dev(
workspace_root: impl AsRef<Path>, workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity, workspace: WorkspaceRecord,
host_config: &ServerHostConfigFile, host_config: &ServerHostConfigFile,
) -> Result<Self> { ) -> Result<Self> {
let workspace_root = workspace_root.as_ref(); let workspace_root = workspace_root.as_ref();
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id); let data_root = ServerConfig::default_workspace_backend_data_root(&workspace.workspace_id);
let database_path = ServerConfig::default_server_database_path(); let database_path = ServerConfig::default_server_database_path();
let (browser_public_url, browser_rp_id) = let (browser_public_url, browser_rp_id) =
resolve_browser_public_url(&host_config.browser.public_url)?; resolve_browser_public_url(&host_config.browser.public_url)?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), workspace);
server.database_path = database_path.clone(); server.database_path = database_path.clone();
server.embedded_runtime_store_root = data_root.join("embedded-runtime"); server.embedded_runtime_store_root = data_root.join("embedded-runtime");
server.max_records = DEFAULT_MAX_RECORDS; server.max_records = DEFAULT_MAX_RECORDS;
@@ -185,11 +185,14 @@ fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
mod tests { mod tests {
use super::*; use super::*;
fn identity() -> WorkspaceIdentity { fn workspace() -> WorkspaceRecord {
WorkspaceIdentity { WorkspaceRecord {
workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(), workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(),
owner_account_id: "018f6a2c-1111-7000-8000-000000000002".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
display_name: "Workspace".to_string(), display_name: "Workspace".to_string(),
state: "active".to_string(),
} }
} }
@@ -197,7 +200,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
ResolvedWorkspaceBackendConfig::local_dev( ResolvedWorkspaceBackendConfig::local_dev(
dir.path(), dir.path(),
identity(), workspace(),
&ServerHostConfigFile::default(), &ServerHostConfigFile::default(),
) )
.unwrap() .unwrap()
@@ -250,7 +253,7 @@ mod tests {
.unwrap(); .unwrap();
let resolved = ResolvedWorkspaceBackendConfig::local_dev( let resolved = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(), tempfile::tempdir().unwrap().path(),
identity(), workspace(),
&host_config, &host_config,
) )
.unwrap(); .unwrap();
@@ -280,7 +283,7 @@ mod tests {
}; };
let result = ResolvedWorkspaceBackendConfig::local_dev( let result = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(), tempfile::tempdir().unwrap().path(),
identity(), workspace(),
&host_config, &host_config,
); );
let error = match result { let error = match result {
+112 -28
View File
@@ -2129,7 +2129,11 @@ impl EmbeddedWorkerRuntime {
identity: "runtime_registry_worker".to_string(), identity: "runtime_registry_worker".to_string(),
workspace_id: summary.workspace_id.clone(), workspace_id: summary.workspace_id.clone(),
}, },
state: embedded_worker_status_label(summary.status).to_string(), state: embedded_worker_state_label(
summary.status,
summary.execution_metadata_available,
)
.to_string(),
worker_state: summary.worker_state.clone(), worker_state: summary.worker_state.clone(),
last_seen_at: None, last_seen_at: None,
pinned: false, pinned: false,
@@ -2139,11 +2143,14 @@ impl EmbeddedWorkerRuntime {
display_hint: "backend-internal worker-runtime Worker".to_string(), display_hint: "backend-internal worker-runtime Worker".to_string(),
}, },
capabilities: WorkerCapabilitySummary { capabilities: WorkerCapabilitySummary {
can_stop: self.can_stop_embedded_worker(summary.status), can_stop: summary.execution_metadata_available
&& self.can_stop_embedded_worker(summary.status),
can_spawn_followup: false, can_spawn_followup: false,
}, },
working_directory: summary.working_directory.map(|status| status.summary), working_directory: summary.working_directory.map(|status| status.summary),
diagnostics: embedded_worker_projection_diagnostics(), diagnostics: embedded_worker_projection_diagnostics(
summary.execution_metadata_available,
),
} }
} }
@@ -2169,7 +2176,8 @@ impl EmbeddedWorkerRuntime {
identity: "runtime_registry_worker".to_string(), identity: "runtime_registry_worker".to_string(),
workspace_id: detail.workspace_id.clone(), workspace_id: detail.workspace_id.clone(),
}, },
state: embedded_worker_status_label(detail.status).to_string(), state: embedded_worker_state_label(detail.status, detail.execution_metadata_available)
.to_string(),
worker_state: detail.worker_state.clone(), worker_state: detail.worker_state.clone(),
last_seen_at: None, last_seen_at: None,
pinned: false, pinned: false,
@@ -2179,11 +2187,14 @@ impl EmbeddedWorkerRuntime {
display_hint: "backend-internal worker-runtime Worker".to_string(), display_hint: "backend-internal worker-runtime Worker".to_string(),
}, },
capabilities: WorkerCapabilitySummary { capabilities: WorkerCapabilitySummary {
can_stop: self.can_stop_embedded_worker(detail.status), can_stop: detail.execution_metadata_available
&& self.can_stop_embedded_worker(detail.status),
can_spawn_followup: false, can_spawn_followup: false,
}, },
working_directory: detail.working_directory.map(|status| status.summary), working_directory: detail.working_directory.map(|status| status.summary),
diagnostics: embedded_worker_projection_diagnostics(), diagnostics: embedded_worker_projection_diagnostics(
detail.execution_metadata_available,
),
} }
} }
} }
@@ -3914,7 +3925,11 @@ impl RemoteWorkerRuntime {
identity: "runtime_registry_worker".to_string(), identity: "runtime_registry_worker".to_string(),
workspace_id: summary.workspace_id.clone(), workspace_id: summary.workspace_id.clone(),
}, },
state: embedded_worker_status_label(summary.status).to_string(), state: embedded_worker_state_label(
summary.status,
summary.execution_metadata_available,
)
.to_string(),
worker_state: summary.worker_state.clone(), worker_state: summary.worker_state.clone(),
last_seen_at: None, last_seen_at: None,
pinned: false, pinned: false,
@@ -3924,15 +3939,12 @@ impl RemoteWorkerRuntime {
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(), display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
}, },
capabilities: WorkerCapabilitySummary { capabilities: WorkerCapabilitySummary {
can_stop: runtime_worker_can_stop(true, summary.status), can_stop: summary.execution_metadata_available
&& runtime_worker_can_stop(true, summary.status),
can_spawn_followup: false, can_spawn_followup: false,
}, },
working_directory: summary.working_directory.map(|status| status.summary), working_directory: summary.working_directory.map(|status| status.summary),
diagnostics: vec![diagnostic( diagnostics: remote_worker_projection_diagnostics(summary.execution_metadata_available),
"remote_runtime_projection",
DiagnosticSeverity::Info,
"Remote Worker identity is projected only as runtime_id plus worker_id; endpoint and credentials remain backend-private".to_string(),
)],
} }
} }
@@ -3958,7 +3970,8 @@ impl RemoteWorkerRuntime {
identity: "runtime_registry_worker".to_string(), identity: "runtime_registry_worker".to_string(),
workspace_id: detail.workspace_id.clone(), workspace_id: detail.workspace_id.clone(),
}, },
state: embedded_worker_status_label(detail.status).to_string(), state: embedded_worker_state_label(detail.status, detail.execution_metadata_available)
.to_string(),
worker_state: detail.worker_state.clone(), worker_state: detail.worker_state.clone(),
last_seen_at: None, last_seen_at: None,
pinned: false, pinned: false,
@@ -3968,15 +3981,12 @@ impl RemoteWorkerRuntime {
display_hint: "Backend-proxied remote worker-runtime Worker".to_string(), display_hint: "Backend-proxied remote worker-runtime Worker".to_string(),
}, },
capabilities: WorkerCapabilitySummary { capabilities: WorkerCapabilitySummary {
can_stop: runtime_worker_can_stop(true, detail.status), can_stop: detail.execution_metadata_available
&& runtime_worker_can_stop(true, detail.status),
can_spawn_followup: false, can_spawn_followup: false,
}, },
working_directory: detail.working_directory.map(|status| status.summary), working_directory: detail.working_directory.map(|status| status.summary),
diagnostics: vec![diagnostic( diagnostics: remote_worker_projection_diagnostics(detail.execution_metadata_available),
"remote_runtime_projection",
DiagnosticSeverity::Info,
"Remote Worker identity is projected only as runtime_id plus worker_id; endpoint and credentials remain backend-private".to_string(),
)],
} }
} }
@@ -4696,12 +4706,49 @@ fn embedded_worker_status_label(status: EmbeddedWorkerStatus) -> &'static str {
} }
} }
fn embedded_worker_projection_diagnostics() -> Vec<RuntimeDiagnostic> { fn embedded_worker_state_label(
vec![diagnostic( status: EmbeddedWorkerStatus,
execution_metadata_available: bool,
) -> &'static str {
if !execution_metadata_available {
return "execution_unavailable";
}
embedded_worker_status_label(status)
}
fn execution_metadata_diagnostic(execution_metadata_available: bool) -> Option<RuntimeDiagnostic> {
(!execution_metadata_available).then(|| {
diagnostic(
"worker_execution_unavailable",
DiagnosticSeverity::Error,
"Persisted Worker identity is available, but execution metadata is unavailable"
.to_string(),
)
})
}
fn embedded_worker_projection_diagnostics(
execution_metadata_available: bool,
) -> Vec<RuntimeDiagnostic> {
let mut diagnostics = vec![diagnostic(
"embedded_runtime_projection", "embedded_runtime_projection",
DiagnosticSeverity::Info, DiagnosticSeverity::Info,
"Worker identity is projected only as runtime_id plus worker_id; embedded runtime internals remain backend-private".to_string(), "Worker identity is projected only as runtime_id plus worker_id; embedded runtime internals remain backend-private".to_string(),
)] )];
diagnostics.extend(execution_metadata_diagnostic(execution_metadata_available));
diagnostics
}
fn remote_worker_projection_diagnostics(
execution_metadata_available: bool,
) -> Vec<RuntimeDiagnostic> {
let mut diagnostics = vec![diagnostic(
"remote_runtime_projection",
DiagnosticSeverity::Info,
"Remote Worker identity is projected only as runtime_id plus worker_id; endpoint and credentials remain backend-private".to_string(),
)];
diagnostics.extend(execution_metadata_diagnostic(execution_metadata_available));
diagnostics
} }
fn spawn_config_bundle_ref(request: &WorkerSpawnRequest) -> Option<ConfigBundleRef> { fn spawn_config_bundle_ref(request: &WorkerSpawnRequest) -> Option<ConfigBundleRef> {
@@ -5064,6 +5111,17 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
DiagnosticSeverity::Warning, DiagnosticSeverity::Warning,
"Embedded Runtime rejected the request".to_string(), "Embedded Runtime rejected the request".to_string(),
), ),
EmbeddedRuntimeError::RuntimeStoreAlreadyOpen { .. } => diagnostic(
"embedded_runtime_store_already_open",
DiagnosticSeverity::Error,
"Embedded Runtime store is already owned by another Runtime process".to_string(),
),
EmbeddedRuntimeError::WorkerDeletePersistenceFailed { .. } => diagnostic(
"worker_delete_persistence_failed",
DiagnosticSeverity::Error,
"Worker metadata deletion failed; the persisted Worker identity was retained for retry"
.to_string(),
),
EmbeddedRuntimeError::StoreIo { .. } EmbeddedRuntimeError::StoreIo { .. }
| EmbeddedRuntimeError::StoreMissing { .. } | EmbeddedRuntimeError::StoreMissing { .. }
| EmbeddedRuntimeError::StoreCorrupt { .. } => diagnostic( | EmbeddedRuntimeError::StoreCorrupt { .. } => diagnostic(
@@ -5370,6 +5428,20 @@ mod tests {
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
#[test]
fn embedded_delete_persistence_failure_diagnostic_is_bounded_and_path_free() {
let diagnostic =
embedded_runtime_diagnostic(&EmbeddedRuntimeError::WorkerDeletePersistenceFailed {
worker_id: EmbeddedWorkerId::now_v7(),
message: "/private/runtime/workers/raw-os-error".to_string(),
});
assert_eq!(diagnostic.code, "worker_delete_persistence_failed");
assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
assert!(diagnostic.message.len() <= 256);
assert!(!diagnostic.message.contains("/private/runtime"));
}
#[test] #[test]
fn strict_remote_runtime_dns_resolution_has_a_short_timeout() { fn strict_remote_runtime_dns_resolution_has_a_short_timeout() {
let error = resolve_remote_addresses_with_timeout(Duration::from_millis(1), || { let error = resolve_remote_addresses_with_timeout(Duration::from_millis(1), || {
@@ -5831,7 +5903,6 @@ mod tests {
self.backend_id(), self.backend_id(),
), ),
worker_state: protocol::WorkerStateSnapshot { worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into() ..protocol::WorkerStatus::Idle.into()
}, },
working_directory: request working_directory: request
@@ -6783,11 +6854,14 @@ mod tests {
} }
#[test] #[test]
fn remote_runtime_projection_uses_canonical_worker_status_for_stop_capability() { fn remote_runtime_projection_uses_execution_availability_and_canonical_status() {
let worker_ids = (1..=4) let worker_ids = (1..=5)
.map(|value| EmbeddedWorkerId::from_legacy_u64(value).to_string()) .map(|value| EmbeddedWorkerId::from_legacy_u64(value).to_string())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let worker_id = worker_ids[0].clone(); let worker_id = worker_ids[0].clone();
let mut execution_unavailable =
worker_json_with_status("remote:primary", &worker_ids[4], "running");
execution_unavailable["execution_metadata_available"] = serde_json::json!(false);
let (base_url, server) = serve_mock_http(vec![ let (base_url, server) = serve_mock_http(vec![
mock_response( mock_response(
"GET", "GET",
@@ -6799,7 +6873,8 @@ mod tests {
worker_json_with_status("remote:primary", &worker_ids[0], "stopped"), worker_json_with_status("remote:primary", &worker_ids[0], "stopped"),
worker_json_with_status("remote:primary", &worker_ids[1], "running"), worker_json_with_status("remote:primary", &worker_ids[1], "running"),
worker_json_with_status("remote:primary", &worker_ids[2], "paused"), worker_json_with_status("remote:primary", &worker_ids[2], "paused"),
worker_json_with_status("remote:primary", &worker_ids[3], "idle") worker_json_with_status("remote:primary", &worker_ids[3], "idle"),
execution_unavailable
] ]
}) })
.to_string(), .to_string(),
@@ -6834,15 +6909,23 @@ mod tests {
)]); )]);
let workers = registry.list_workers(10); let workers = registry.list_workers(10);
assert_eq!(workers.items.len(), 4); assert_eq!(workers.items.len(), 5);
assert!(!workers.items[0].capabilities.can_stop); assert!(!workers.items[0].capabilities.can_stop);
assert!(workers.items[1].capabilities.can_stop); assert!(workers.items[1].capabilities.can_stop);
assert!(workers.items[2].capabilities.can_stop); assert!(workers.items[2].capabilities.can_stop);
assert!(workers.items[3].capabilities.can_stop); assert!(workers.items[3].capabilities.can_stop);
assert!(!workers.items[4].capabilities.can_stop);
assert_eq!(workers.items[0].state, "stopped"); assert_eq!(workers.items[0].state, "stopped");
assert_eq!(workers.items[1].state, "running"); assert_eq!(workers.items[1].state, "running");
assert_eq!(workers.items[2].state, "paused"); assert_eq!(workers.items[2].state, "paused");
assert_eq!(workers.items[3].state, "idle"); assert_eq!(workers.items[3].state, "idle");
assert_eq!(workers.items[4].state, "execution_unavailable");
assert!(
workers.items[4]
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "worker_execution_unavailable")
);
let stopped_detail = registry let stopped_detail = registry
.worker(&RuntimeWorkerRef::new("remote:primary", &worker_id)) .worker(&RuntimeWorkerRef::new("remote:primary", &worker_id))
@@ -7115,6 +7198,7 @@ mod tests {
"runtime_id": runtime_id, "runtime_id": runtime_id,
"worker_id": worker_id, "worker_id": worker_id,
"status": status, "status": status,
"execution_metadata_available": true,
"intent": { "kind": "role", "role": "coder", "purpose": "remote test" }, "intent": { "kind": "role", "role": "coder", "purpose": "remote test" },
"profile": { "kind": "builtin", "value": "coder" }, "profile": { "kind": "builtin", "value": "coder" },
"profile_source": { "profile_source": {
-355
View File
@@ -1,355 +0,0 @@
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{Error, Result};
pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml";
/// Stable local Workspace identity persisted as a tracked, safe project record.
///
/// The v0 TOML schema contains identity metadata plus optional tracked project
/// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected
/// here because this loader cannot safely round-trip future local runtime settings
/// without risking accidental path or secret persistence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceIdentity {
pub workspace_id: String,
pub created_at: String,
pub display_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceIdentityFile {
workspace_id: String,
created_at: String,
display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
ticket: Option<toml::Value>,
}
impl WorkspaceIdentity {
pub fn load_or_init(workspace_root: impl AsRef<Path>) -> Result<Self> {
Self::load_or_init_with_clock(workspace_root.as_ref(), || {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
})
}
pub fn load_required(workspace_root: impl AsRef<Path>) -> Result<Self> {
let path = Self::path(workspace_root.as_ref());
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!(
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
workspace_root.as_ref().display()
)))
}
Err(error) => Err(Error::Io(error)),
}
}
pub fn path(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root
.as_ref()
.join(WORKSPACE_IDENTITY_RELATIVE_PATH)
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let parsed: WorkspaceIdentityFile = toml::from_str(raw).map_err(|error| {
workspace_identity_error(path, format!("failed to parse TOML: {error}"))
})?;
Self::from_file(parsed, path)
}
fn load_or_init_with_clock(
workspace_root: &Path,
now_utc_rfc3339: impl FnOnce() -> String,
) -> Result<Self> {
let path = Self::path(workspace_root);
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => {
Self::init(workspace_root, &path, now_utc_rfc3339())
}
Err(error) => Err(Error::Io(error)),
}
}
fn init(workspace_root: &Path, path: &Path, created_at: String) -> Result<Self> {
validate_created_at(&created_at, path)?;
let display_name = workspace_display_name_from_root(workspace_root, path)?;
let workspace_id = Uuid::now_v7().to_string();
let identity = Self {
workspace_id,
created_at,
display_name,
};
identity.write_new_or_read_existing(path)
}
fn from_file(parsed: WorkspaceIdentityFile, path: &Path) -> Result<Self> {
let workspace_id = validate_workspace_id(&parsed.workspace_id, path)?;
validate_created_at(&parsed.created_at, path)?;
validate_display_name(&parsed.display_name, path)?;
Ok(Self {
workspace_id,
created_at: parsed.created_at,
display_name: parsed.display_name,
})
}
fn write_new_or_read_existing(&self, path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(&WorkspaceIdentityFile {
workspace_id: self.workspace_id.clone(),
created_at: self.created_at.clone(),
display_name: self.display_name.clone(),
ticket: None,
})
.map_err(|error| {
workspace_identity_error(path, format!("failed to encode TOML: {error}"))
})?;
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut file) => {
file.write_all(raw.as_bytes())?;
file.sync_all()?;
Ok(self.clone())
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
let raw = fs::read_to_string(path)?;
Self::parse_str(&raw, path)
}
Err(error) => Err(Error::Io(error)),
}
}
}
fn validate_workspace_id(value: &str, path: &Path) -> Result<String> {
let uuid = Uuid::parse_str(value).map_err(|error| {
workspace_identity_error(path, format!("workspace_id is not a UUID: {error}"))
})?;
if uuid.get_version_num() != 7 {
return Err(workspace_identity_error(
path,
"workspace_id must be a UUIDv7 canonical string".to_string(),
));
}
let canonical = uuid.to_string();
if value != canonical {
return Err(workspace_identity_error(
path,
"workspace_id must use lowercase hyphenated UUID canonical form".to_string(),
));
}
Ok(canonical)
}
fn validate_created_at(value: &str, path: &Path) -> Result<()> {
let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|error| {
workspace_identity_error(path, format!("created_at is not RFC3339: {error}"))
})?;
if parsed.offset().local_minus_utc() != 0 || !value.ends_with('Z') {
return Err(workspace_identity_error(
path,
"created_at must be a UTC RFC3339 timestamp ending in Z".to_string(),
));
}
Ok(())
}
fn validate_display_name(value: &str, path: &Path) -> Result<()> {
if value.trim().is_empty() {
return Err(workspace_identity_error(
path,
"display_name must not be empty".to_string(),
));
}
if value.contains('\0') || value.chars().any(|ch| ch.is_control()) {
return Err(workspace_identity_error(
path,
"display_name must not contain control characters".to_string(),
));
}
Ok(())
}
fn workspace_display_name_from_root(workspace_root: &Path, path: &Path) -> Result<String> {
let display_name = workspace_root
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
workspace_identity_error(
path,
"workspace root must have a UTF-8 final path component".to_string(),
)
})?
.to_string();
validate_display_name(&display_name, path)?;
Ok(display_name)
}
fn workspace_identity_error(path: &Path, message: String) -> Error {
Error::WorkspaceIdentity(format!("{}: {message}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z";
#[test]
fn load_required_rejects_uninitialized_workspace_without_creating_identity() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("uninitialized-workspace");
fs::create_dir_all(&workspace_root).unwrap();
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
assert!(
error.to_string().contains("workspace identity is missing"),
"unexpected error: {error}"
);
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
}
#[test]
fn missing_identity_file_is_created_with_safe_fields() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("example-workspace");
fs::create_dir_all(&workspace_root).unwrap();
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
FIXED_CREATED_AT.to_string()
})
.unwrap();
assert_eq!(identity.display_name, "example-workspace");
assert_eq!(identity.created_at, FIXED_CREATED_AT);
validate_workspace_id(
&identity.workspace_id,
&WorkspaceIdentity::path(&workspace_root),
)
.unwrap();
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
assert!(raw.contains("workspace_id"));
assert!(raw.contains("display_name"));
assert!(raw.contains("created_at"));
assert!(!raw.contains(&workspace_root.to_string_lossy().to_string()));
let reloaded = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
"2026-06-24T00:00:00Z".to_string()
})
.unwrap();
assert_eq!(reloaded, identity);
}
#[test]
fn existing_identity_file_is_stable() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("moved-workspace");
let yoi_dir = workspace_root.join(".yoi");
fs::create_dir_all(&yoi_dir).unwrap();
let path = yoi_dir.join("workspace.toml");
let raw = format!(
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\n"
);
fs::write(&path, &raw).unwrap();
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
"2026-06-24T00:00:00Z".to_string()
})
.unwrap();
assert_eq!(identity.workspace_id, FIXED_WORKSPACE_ID);
assert_eq!(identity.created_at, FIXED_CREATED_AT);
assert_eq!(identity.display_name, "Stable Project");
assert_eq!(fs::read_to_string(path).unwrap(), raw);
}
#[test]
fn create_new_race_returns_existing_persisted_identity() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(".yoi/workspace.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let persisted_raw = format!(
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Persisted Project\"\n"
);
fs::write(&path, &persisted_raw).unwrap();
let generated = WorkspaceIdentity {
workspace_id: "0192f0e8-4d84-7d6e-b000-000000000002".to_string(),
created_at: "2026-06-24T00:00:00Z".to_string(),
display_name: "Generated Project".to_string(),
};
let returned = generated.write_new_or_read_existing(&path).unwrap();
assert_eq!(returned.workspace_id, FIXED_WORKSPACE_ID);
assert_eq!(returned.created_at, FIXED_CREATED_AT);
assert_eq!(returned.display_name, "Persisted Project");
assert_eq!(fs::read_to_string(path).unwrap(), persisted_raw);
}
#[test]
fn invalid_identity_file_fails_closed_without_rewriting() {
let temp = tempfile::tempdir().unwrap();
let workspace_root = temp.path().join("invalid-workspace");
let yoi_dir = workspace_root.join(".yoi");
fs::create_dir_all(&yoi_dir).unwrap();
let path = yoi_dir.join("workspace.toml");
let raw = "workspace_id = \"not-a-uuid\"\ncreated_at = \"2026-06-23T06:43:28Z\"\ndisplay_name = \"Invalid\"\n";
fs::write(&path, raw).unwrap();
let error = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
FIXED_CREATED_AT.to_string()
})
.unwrap_err();
assert!(error.to_string().contains("workspace_id is not a UUID"));
assert_eq!(fs::read_to_string(path).unwrap(), raw);
}
#[test]
fn generated_identity_does_not_leak_parent_paths() {
let temp = tempfile::tempdir().unwrap();
let secret_parent = temp.path().join("user-secret-parent");
let workspace_root = secret_parent.join("public-project-name");
fs::create_dir_all(&workspace_root).unwrap();
WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
FIXED_CREATED_AT.to_string()
})
.unwrap();
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
assert!(raw.contains("public-project-name"));
assert!(!raw.contains(&secret_parent.to_string_lossy().to_string()));
assert!(!raw.contains("user-secret-parent"));
assert!(!raw.contains("/"));
}
#[test]
fn unknown_fields_are_rejected() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("workspace.toml");
let raw = format!(
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\nlocal_root = \"/tmp/secret\"\n"
);
let error = WorkspaceIdentity::parse_str(&raw, &path).unwrap_err();
assert!(error.to_string().contains("unknown field"));
}
}
+231 -1
View File
@@ -628,6 +628,42 @@ CREATE TABLE workdir_create_operations (
PRIMARY KEY (workspace_id, operation_id), PRIMARY KEY (workspace_id, operation_id),
UNIQUE (workspace_id, working_directory_id) UNIQUE (workspace_id, working_directory_id)
); );
CREATE TABLE workdir_create_credential_candidates (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
credential_revision INTEGER NOT NULL CHECK (credential_revision > 0),
PRIMARY KEY (workspace_id, operation_id, ordinal),
UNIQUE (workspace_id, operation_id, role),
UNIQUE (workspace_id, operation_id, credential_id),
FOREIGN KEY (workspace_id, operation_id)
REFERENCES workdir_create_operations(workspace_id, operation_id)
ON DELETE CASCADE
);
CREATE INDEX idx_workdir_create_credential_candidates_revision
ON workdir_create_credential_candidates(
workspace_id, credential_id, credential_revision
);
CREATE TABLE workdir_create_credential_revision_retentions (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
credential_id TEXT NOT NULL,
credential_revision INTEGER NOT NULL,
PRIMARY KEY (workspace_id, operation_id, ordinal),
FOREIGN KEY (workspace_id, operation_id, ordinal)
REFERENCES workdir_create_credential_candidates(
workspace_id, operation_id, ordinal
)
ON DELETE CASCADE,
FOREIGN KEY (workspace_id, credential_id, credential_revision)
REFERENCES repository_ssh_credential_revisions(
workspace_id, credential_id, revision
)
ON DELETE RESTRICT
);
CREATE TABLE "workdir_registry" ( CREATE TABLE "workdir_registry" (
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
workdir_id TEXT NOT NULL, workdir_id TEXT NOT NULL,
@@ -739,7 +775,7 @@ CREATE TABLE "worker_registry" (
CREATE TABLE worker_removal_operations ( CREATE TABLE worker_removal_operations (
operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL, operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL,
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
worker_revision TEXT NOT NULL, run_generation INTEGER NOT NULL CHECK(run_generation>=0), worker_revision TEXT NOT NULL,
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL, session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER, archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
@@ -1021,6 +1057,200 @@ CREATE UNIQUE INDEX worker_workdir_links_active_worker_unique
WHERE unlinked_at IS NULL; WHERE unlinked_at IS NULL;
CREATE INDEX worker_workdir_links_workdir CREATE INDEX worker_workdir_links_workdir
ON worker_workdir_links(workspace_id, workdir_id); ON worker_workdir_links(workspace_id, workdir_id);
CREATE TABLE runtime_removal_operations (
operation_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
expected_binding_revision INTEGER NOT NULL,
config_revision INTEGER NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')),
failure_category TEXT,
binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)),
runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX runtime_removal_operations_one_active_runtime
ON runtime_removal_operations(runtime_id)
WHERE state IN ('pending', 'cleanup_pending');
CREATE INDEX runtime_removal_operations_workspace_state
ON runtime_removal_operations(workspace_id, state, updated_at);
CREATE TRIGGER runtime_binding_insert_blocked_by_removal
BEFORE INSERT ON workspace_runtime_bindings
FOR EACH ROW
WHEN EXISTS (
SELECT 1
FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN
SELECT RAISE(ABORT, 'runtime_removal_in_progress');
END;
CREATE TRIGGER runtime_binding_update_blocked_by_removal
BEFORE UPDATE ON workspace_runtime_bindings
FOR EACH ROW
WHEN EXISTS (
SELECT 1
FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN
SELECT RAISE(ABORT, 'runtime_removal_in_progress');
END;
CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_registry FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_registry FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_registry FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_registry FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_assignment_insert_blocked_by_runtime_removal
BEFORE INSERT ON ticket_current_worker_assignments FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_assignment_update_blocked_by_runtime_removal
BEFORE UPDATE ON ticket_current_worker_assignments FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_workdir_links FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_workdir_links FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_create_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_create_reservations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_create_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_create_reservations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.resolved_runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.resolved_runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_removal_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_removal_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_removal_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_removal_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_removal_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_removal_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_removal_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_removal_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TABLE workspace_deletion_operations ( CREATE TABLE workspace_deletion_operations (
operation_id TEXT PRIMARY KEY, operation_id TEXT PRIMARY KEY,
request_fingerprint TEXT NOT NULL, request_fingerprint TEXT NOT NULL,
-4
View File
@@ -10,7 +10,6 @@ pub mod companion;
pub mod config; pub mod config;
pub mod config_source; pub mod config_source;
pub mod hosts; pub mod hosts;
pub mod identity;
pub mod memory_backend; pub mod memory_backend;
pub mod memory_staging; pub mod memory_staging;
pub mod observation; pub mod observation;
@@ -43,7 +42,6 @@ pub use authority::{
WorkspaceAuthority, WorkspaceAuthority,
}; };
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile}; pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
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::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader}; pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
pub use server::{ pub use server::{
@@ -137,8 +135,6 @@ pub enum Error {
RegistryInconsistency(String), RegistryInconsistency(String),
#[error("Worker source identity is invalid: {0}")] #[error("Worker source identity is invalid: {0}")]
WorkerSourceIdentity(String), WorkerSourceIdentity(String),
#[error("workspace identity error: {0}")]
WorkspaceIdentity(String),
#[error("Workspace signing identity error ({code}): {message}")] #[error("Workspace signing identity error ({code}): {message}")]
WorkspaceSigningIdentity { code: String, message: String }, WorkspaceSigningIdentity { code: String, message: String },
#[error("store error: {0}")] #[error("store error: {0}")]
+18 -13
View File
@@ -14,7 +14,7 @@ use yoi_workspace_server::store::{
}; };
use yoi_workspace_server::{ use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, WorkspaceRecord, serve_workspace_catalog,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -235,21 +235,21 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?); let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let workspaces = store.list_workspaces()?; let workspaces = store.list_workspaces()?;
let (identity, workspace_root) = if let Some(workspace) = workspaces.first() { let (workspace, workspace_execution_root) = if let Some(workspace) = workspaces.first() {
( (
WorkspaceIdentity { workspace.clone(),
workspace_id: workspace.workspace_id.clone(), workspace_execution_root_from_server_data(workspace)?,
created_at: workspace.created_at.clone(),
display_name: workspace.display_name.clone(),
},
workspace_root_from_server_data(workspace)?,
) )
} else { } else {
let now = Utc::now().to_rfc3339();
( (
WorkspaceIdentity { WorkspaceRecord {
workspace_id: "00000000-0000-0000-0000-000000000000".to_string(), workspace_id: "00000000-0000-0000-0000-000000000000".to_string(),
created_at: Utc::now().to_rfc3339(), owner_account_id: "00000000-0000-0000-0000-000000000000".to_string(),
created_at: now.clone(),
updated_at: now,
display_name: "Server bootstrap".to_string(), display_name: "Server bootstrap".to_string(),
state: "bootstrap".to_string(),
}, },
database_path database_path
.parent() .parent()
@@ -261,8 +261,11 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
Some(path) => ServerHostConfigFile::load_from_path(path)?, Some(path) => ServerHostConfigFile::load_from_path(path)?,
None => ServerHostConfigFile::load_default()?, None => ServerHostConfigFile::load_default()?,
}; };
let mut resolved = let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
ResolvedWorkspaceBackendConfig::local_dev(&workspace_root, identity, &host_config)?; &workspace_execution_root,
workspace,
&host_config,
)?;
resolved.database_path = database_path.clone(); resolved.database_path = database_path.clone();
resolved.server.database_path = database_path.clone(); resolved.server.database_path = database_path.clone();
append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?; append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
@@ -322,7 +325,9 @@ fn append_workspace_runtime_sources(
Ok(()) Ok(())
} }
fn workspace_root_from_server_data(workspace: &WorkspaceRecord) -> Result<PathBuf, CliError> { fn workspace_execution_root_from_server_data(
workspace: &WorkspaceRecord,
) -> Result<PathBuf, CliError> {
Ok(ServerConfig::default_workspace_backend_data_root( Ok(ServerConfig::default_workspace_backend_data_root(
&workspace.workspace_id, &workspace.workspace_id,
)) ))
+47 -141
View File
@@ -1,11 +1,9 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath}; use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value}; use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use worker::EffectivePromptCatalog; use worker::EffectivePromptCatalog;
use worker_runtime::config_bundle::{ use worker_runtime::config_bundle::{
@@ -13,14 +11,14 @@ use worker_runtime::config_bundle::{
}; };
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
use workspace_api::{ use workspace_api::{
Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest, ProfileSettingsResponse, WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
WorkspaceProfileSourceSummary, WorkspaceProfileSummary, WorkspaceProfileSourceSummary, WorkspaceProfileSummary,
}; };
use crate::config_source::{ use crate::config_source::{
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
}; };
use crate::store::WorkspaceRecord;
use crate::{Error, Result}; use crate::{Error, Result};
const PROFILE_SCHEMA_SOURCE: &str = r#"{ const PROFILE_SCHEMA_SOURCE: &str = r#"{
@@ -467,103 +465,29 @@ fn build_virtual_profile_archive(
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string())) .map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceIdentityFile {
workspace_id: String,
created_at: String,
display_name: String,
}
pub fn workspace_metadata_settings( pub fn workspace_metadata_settings(
workspace_root: &Path, workspace: &WorkspaceRecord,
fallback_workspace_id: &str,
fallback_created_at: &str,
fallback_display_name: &str,
) -> WorkspaceMetadataSettingsResponse { ) -> WorkspaceMetadataSettingsResponse {
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
let mut diagnostics = Vec::new();
let (workspace_id, created_at, display_name) = match fs::read_to_string(&path) {
Ok(raw) => match toml::from_str::<WorkspaceIdentityFile>(&raw) {
Ok(file) => (file.workspace_id, file.created_at, file.display_name),
Err(err) => {
diagnostics.push(diagnostic(
"workspace_identity_parse_failed",
DiagnosticSeverity::Error,
format!("Workspace identity could not be parsed: {err}"),
));
(
fallback_workspace_id.to_string(),
fallback_created_at.to_string(),
fallback_display_name.to_string(),
)
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
diagnostics.push(diagnostic(
"workspace_identity_missing",
DiagnosticSeverity::Warning,
"Workspace identity record is missing; showing active backend metadata.",
));
(
fallback_workspace_id.to_string(),
fallback_created_at.to_string(),
fallback_display_name.to_string(),
)
}
Err(err) => {
diagnostics.push(diagnostic(
"workspace_identity_read_failed",
DiagnosticSeverity::Error,
format!(
"Workspace identity could not be read: {}",
sanitize_error(&err.to_string())
),
));
(
fallback_workspace_id.to_string(),
fallback_created_at.to_string(),
fallback_display_name.to_string(),
)
}
};
WorkspaceMetadataSettingsResponse { WorkspaceMetadataSettingsResponse {
workspace_id, workspace_id: workspace.workspace_id.clone(),
display_name, display_name: workspace.display_name.clone(),
created_at, created_at: workspace.created_at.clone(),
revision: file_revision(&path), revision: workspace.updated_at.clone(),
source: "workspace_identity".to_string(), source: "server_db".to_string(),
diagnostics, diagnostics: Vec::new(),
} }
} }
pub fn update_workspace_metadata( pub fn sanitize_workspace_display_name(value: &str) -> Result<String> {
workspace_root: &Path, let trimmed = value.trim();
request: UpdateWorkspaceMetadataRequest, if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 {
) -> Result<WorkspaceMetadataSettingsResponse> {
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
let current_revision = file_revision(&path);
if request.revision != current_revision {
return Err(Error::RuntimeOperationFailed { return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(), runtime_id: "workspace-backend".to_string(),
code: "workspace_metadata_revision_conflict".to_string(), code: "workspace_display_name_invalid".to_string(),
message: "Workspace metadata changed before this update was applied".to_string(), message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(),
}); });
} }
let raw = fs::read_to_string(&path)?; Ok(trimmed.to_string())
let mut file: WorkspaceIdentityFile = toml::from_str(&raw)
.map_err(|err| Error::Config(format!("failed to parse workspace identity: {err}")))?;
let display_name = sanitize_display_name(&request.display_name)?;
file.display_name = display_name;
let encoded = toml::to_string_pretty(&file)
.map_err(|err| Error::Config(format!("failed to serialize workspace identity: {err}")))?;
fs::write(&path, encoded)?;
Ok(workspace_metadata_settings(
workspace_root,
&file.workspace_id,
&file.created_at,
&file.display_name,
))
} }
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> { fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
@@ -728,17 +652,6 @@ fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
specifiers specifiers
} }
fn sanitize_display_name(value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "workspace_display_name_invalid".to_string(),
message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(),
});
}
Ok(trimmed.to_string())
}
pub fn selector_for_builtin_candidate( pub fn selector_for_builtin_candidate(
id: &str, id: &str,
) -> Option<worker_runtime::catalog::ProfileSelector> { ) -> Option<worker_runtime::catalog::ProfileSelector> {
@@ -753,48 +666,41 @@ pub fn selector_for_builtin_candidate(
_ => None, _ => None,
} }
} }
fn file_revision(path: &Path) -> String {
let Ok(metadata) = fs::metadata(path) else {
return "missing".to_string();
};
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_nanos())
.unwrap_or_default();
format!("rev:{modified}:{}", metadata.len())
}
fn diagnostic(
code: impl Into<String>,
severity: DiagnosticSeverity,
message: impl Into<String>,
) -> Diagnostic {
Diagnostic {
code: code.into(),
severity,
message: message.into(),
}
}
fn sanitize_error(value: &str) -> String {
value
.split_whitespace()
.map(|token| {
if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions")
{
"<redacted-path>"
} else {
token
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
let workspace = WorkspaceRecord {
workspace_id: "workspace-a".to_string(),
owner_account_id: "owner-account".to_string(),
display_name: "Workspace A".to_string(),
state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-02T00:00:00Z".to_string(),
};
let settings = workspace_metadata_settings(&workspace);
assert_eq!(settings.workspace_id, workspace.workspace_id);
assert_eq!(settings.display_name, workspace.display_name);
assert_eq!(settings.created_at, workspace.created_at);
assert_eq!(settings.revision, workspace.updated_at);
assert_eq!(settings.source, "server_db");
assert!(settings.diagnostics.is_empty());
}
#[test]
fn workspace_display_name_validation_is_bounded() {
assert_eq!(
sanitize_workspace_display_name(" Workspace A ").unwrap(),
"Workspace A"
);
assert!(sanitize_workspace_display_name("\n").is_err());
assert!(sanitize_workspace_display_name(&"a".repeat(121)).is_err());
}
fn valid_decodal(slug: &str) -> String { fn valid_decodal(slug: &str) -> String {
format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#) format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#)
} }
+38 -6
View File
@@ -288,13 +288,12 @@ impl RepositoryRegistryReader {
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary { fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
if repository.source.kind == workspace_api::RepositorySourceKind::Http { if crate::repository_source::is_plain_http_repository_source(&repository.source) {
diagnostics.push(RepositoryDiagnostic { diagnostics.push(RepositoryDiagnostic {
severity: "warning".to_string(), severity: "error".to_string(),
code: "repository_source_insecure_http".to_string(), code: "repository_source_plain_http_unsupported".to_string(),
message: message: "Plain HTTP Repository sources are not executable; register an HTTPS or SSH source instead."
"HTTP Repository source is unencrypted; prefer HTTPS or SSH when available." .to_string(),
.to_string(),
}); });
} }
let git = match repository.provider.as_str() { let git = match repository.provider.as_str() {
@@ -607,6 +606,39 @@ mod tests {
assert_eq!(projection.diagnostics[0].code, "repository_config_empty"); assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
} }
#[test]
fn legacy_plain_http_source_is_projected_as_non_executable_error() {
let source: RepositorySource = serde_json::from_value(serde_json::json!({
"kind": "http",
"uri": "http://git.example.test/team/project.git",
}))
.unwrap();
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
id: "legacy-http".into(),
repository_key: "legacy-http".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();
assert_eq!(
projection.items[0].source.kind,
workspace_api::RepositorySourceKind::Invalid
);
let diagnostics = projection.items[0].diagnostics.as_ref().unwrap();
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic.severity == "error"
&& diagnostic.code == "repository_source_plain_http_unsupported"
&& diagnostic.message.contains("HTTPS or SSH")
}));
}
#[test] #[test]
fn remote_source_is_visible_but_local_provider_operations_fail_closed() { fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
let source = RepositorySource { let source = RepositorySource {
@@ -136,6 +136,18 @@ pub fn project_repository_access_state(
) )
} }
fn validate_repository_access_source(
repository_key: &str,
source: &workspace_api::RepositorySource,
) -> Result<()> {
if crate::repository_source::is_plain_http_repository_source(source) {
return Err(Error::InvalidInput(format!(
"repository_source_plain_http_unsupported: Repository `{repository_key}` uses unsupported plain HTTP; register an HTTPS or SSH source instead"
)));
}
Ok(())
}
pub(crate) fn repository_ssh_endpoint( pub(crate) fn repository_ssh_endpoint(
repository_key: &str, repository_key: &str,
repository_uri: &str, repository_uri: &str,
@@ -200,6 +212,7 @@ fn project_repository_access_evaluation(
let repository = store let repository = store
.get_repository_by_key(workspace_id, &repository_key)? .get_repository_by_key(workspace_id, &repository_key)?
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?; .ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?;
validate_repository_access_source(&repository_key, &repository.source)?;
if repository.source.kind != workspace_api::RepositorySourceKind::Ssh { if repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
return Err(Error::InvalidInput(format!( return Err(Error::InvalidInput(format!(
"Repository `{repository_key}` is not an ssh:// Repository" "Repository `{repository_key}` is not an ssh:// Repository"
@@ -749,6 +762,20 @@ impl RepositorySecretService {
"credential `{credential_id}` revision changed" "credential `{credential_id}` revision changed"
))); )));
} }
let retained_by_workdir_create: bool = tx.query_row(
r#"SELECT EXISTS(
SELECT 1
FROM workdir_create_credential_revision_retentions
WHERE workspace_id = ?1 AND credential_id = ?2
)"#,
params![workspace_id, credential_id],
|row| row.get(0),
)?;
if retained_by_workdir_create {
return Err(Error::RepositoryConflict(format!(
"credential `{credential_id}` is retained by a retryable Workdir create operation"
)));
}
insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, current.current_revision, actor_account_id, &now)?; insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, current.current_revision, actor_account_id, &now)?;
let deleted = tx.execute( let deleted = tx.execute(
"DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3", "DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3",
@@ -1956,6 +1983,30 @@ mod tests {
assert!(!contribution.source.contains("secret_ref")); assert!(!contribution.source.contains("secret_ref"));
} }
#[test]
fn workspace_config_projection_rejects_legacy_plain_http_repository_source() {
let source: RepositorySource = serde_json::from_value(serde_json::json!({
"kind": "http",
"uri": "http://git.example.test/team/project.git",
}))
.unwrap();
let error = validate_repository_access_source("remote", &source).unwrap_err();
assert!(error.to_string().contains("unsupported plain HTTP"));
assert!(error.to_string().contains("HTTPS or SSH"));
let mismatched = workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Https,
uri: "http://git.example.test/team/project.git".to_string(),
};
let error = validate_repository_access_source("remote", &mismatched).unwrap_err();
assert!(
error
.to_string()
.contains("repository_source_plain_http_unsupported")
);
}
#[test] #[test]
fn master_key_is_external_and_stable() { fn master_key_is_external_and_stable() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -2405,6 +2456,114 @@ mod tests {
); );
} }
#[test]
fn retryable_workdir_create_retains_candidate_revision_until_success() {
let (_dir, store, service) = test_service();
let (private_key, _) = test_private_key(13);
service
.create_credential(
"workspace-a",
CreateRepositorySshCredentialRequest {
operation_id: "create-retained".to_string(),
credential_id: "retained-deploy".to_string(),
name: "Retained deploy".to_string(),
private_key,
passphrase: None,
},
"owner-a",
)
.unwrap();
let operation = crate::store::WorkdirCreateOperationRecord {
workspace_id: "workspace-a".to_string(),
operation_id: "create-workdir-retained".to_string(),
request_fingerprint: "sha256:request".to_string(),
repository_id: "repo-a".to_string(),
selector: Some("develop".to_string()),
requested_runtime_id: Some("runtime-a".to_string()),
resolved_runtime_id: "runtime-a".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
source_kind: Some("ssh".to_string()),
source_uri: Some("ssh://git@example.test/org/main.git".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,
credential_candidates: Vec::new(),
working_directory_id: "workdir-retained".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(),
};
store.reserve_workdir_create_operation(&operation).unwrap();
let candidates = vec![crate::store::WorkdirCreateCredentialCandidate {
role: crate::store::WorkdirCreateCredentialCandidateRole::Primary,
credential_id: "retained-deploy".to_string(),
credential_revision: 1,
}];
store
.bind_workdir_create_repository_access(
"workspace-a",
"create-workdir-retained",
"sha256:request",
"retained-deploy",
1,
"host-a",
1,
"read_only",
&candidates,
"2026-08-24T00:00:01Z",
)
.unwrap();
let projection = RepositoryAccessProjection {
workspace_id: "workspace-a".to_string(),
config_revision: 1,
projection_digest: "sha256:empty".to_string(),
bindings: Vec::new(),
};
let retained = service
.delete_credential(
"workspace-a",
"retained-deploy",
DeleteRepositorySshCredentialRequest {
operation_id: "delete-retained".to_string(),
expected_revision: 1,
},
"owner-a",
&projection,
)
.unwrap_err();
assert!(matches!(retained, Error::RepositoryConflict(_)));
store
.finish_workdir_create_operation(
"workspace-a",
"create-workdir-retained",
"sha256:request",
true,
None,
"2026-08-24T00:00:02Z",
)
.unwrap();
service
.delete_credential(
"workspace-a",
"retained-deploy",
DeleteRepositorySshCredentialRequest {
operation_id: "delete-released".to_string(),
expected_revision: 1,
},
"owner-a",
&projection,
)
.unwrap();
}
#[test] #[test]
fn referenced_resources_cannot_be_deleted() { fn referenced_resources_cannot_be_deleted() {
let (_dir, _store, service) = test_service(); let (_dir, _store, service) = test_service();
@@ -76,18 +76,19 @@ pub fn parse_repository_source(value: &str) -> Result<RepositorySource> {
require_remote_host_and_path(&parsed)?; require_remote_host_and_path(&parsed)?;
RepositorySourceKind::Ssh RepositorySourceKind::Ssh
} }
"http" | "https" => { "http" => {
return Err(Error::InvalidInput(
"repository_source_plain_http_unsupported: plain HTTP Repository sources are not supported; use HTTPS or SSH".to_string(),
));
}
"https" => {
if !parsed.username().is_empty() { if !parsed.username().is_empty() {
return Err(Error::InvalidInput( return Err(Error::InvalidInput(
"HTTP repository URI must not contain user information".to_string(), "HTTPS repository URI must not contain user information".to_string(),
)); ));
} }
require_remote_host_and_path(&parsed)?; require_remote_host_and_path(&parsed)?;
if parsed.scheme() == "http" { RepositorySourceKind::Https
RepositorySourceKind::Http
} else {
RepositorySourceKind::Https
}
} }
scheme => { scheme => {
return Err(Error::InvalidInput(format!( return Err(Error::InvalidInput(format!(
@@ -111,6 +112,10 @@ pub fn classify_legacy_repository_source(value: &str) -> RepositorySource {
}) })
} }
pub(crate) fn is_plain_http_repository_source(source: &RepositorySource) -> bool {
Url::parse(&source.uri).is_ok_and(|url| url.scheme() == "http")
}
pub fn repository_source_fingerprint(source: &RepositorySource) -> String { pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
let payload = serde_json::to_vec(source).expect("Repository source serializes"); let payload = serde_json::to_vec(source).expect("Repository source serializes");
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
@@ -172,7 +177,7 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn parses_local_file_ssh_http_and_https_sources_without_io() { fn parses_local_file_ssh_and_https_sources_without_io() {
let cases = [ let cases = [
("/runtime/repos/project", RepositorySourceKind::LocalPath), ("/runtime/repos/project", RepositorySourceKind::LocalPath),
("file:///runtime/repos/project", RepositorySourceKind::File), ("file:///runtime/repos/project", RepositorySourceKind::File),
@@ -184,10 +189,6 @@ mod tests {
"git@example.test:org/project.git", "git@example.test:org/project.git",
RepositorySourceKind::Ssh, RepositorySourceKind::Ssh,
), ),
(
"http://git.test/org/project.git",
RepositorySourceKind::Http,
),
( (
"https://git.test/org/project.git", "https://git.test/org/project.git",
RepositorySourceKind::Https, RepositorySourceKind::Https,
@@ -198,6 +199,26 @@ mod tests {
} }
} }
#[test]
fn rejects_plain_http_with_secure_transport_guidance() {
for source in [
"http://git.test/org/project.git",
"http://localhost/org/project.git",
"http://127.0.0.1/org/project.git",
] {
let error = parse_repository_source(source).expect_err("plain HTTP must fail closed");
assert!(error.to_string().contains("plain HTTP Repository sources"));
assert!(error.to_string().contains("HTTPS or SSH"));
}
}
#[test]
fn legacy_plain_http_is_preserved_only_as_invalid_evidence() {
let source = classify_legacy_repository_source("http://git.test/org/project.git");
assert_eq!(source.kind, RepositorySourceKind::Invalid);
assert_eq!(source.uri, "http://git.test/org/project.git");
}
#[test] #[test]
fn rejects_relative_unsupported_and_credential_bearing_sources() { fn rejects_relative_unsupported_and_credential_bearing_sources() {
for source in [ for source in [
+19 -27
View File
@@ -91,7 +91,6 @@ pub struct WorkerRemovalPlan {
pub workspace_id: String, pub workspace_id: String,
pub worker: RuntimeWorkerRef, pub worker: RuntimeWorkerRef,
pub worker_revision: String, pub worker_revision: String,
pub run_generation: u64,
pub policy_id: String, pub policy_id: String,
pub policy_revision: u64, pub policy_revision: u64,
pub session_disposition: SessionDisposition, pub session_disposition: SessionDisposition,
@@ -219,7 +218,7 @@ impl SqliteWorkspaceStore {
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp); let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp)); let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp));
let state=if blockers.is_empty(){WorkerRemovalPlanState::Planned}else{WorkerRemovalPlanState::Blocked}; let state=if blockers.is_empty(){WorkerRemovalPlanState::Planned}else{WorkerRemovalPlanState::Blocked};
tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,run_generation,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?21)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,inv.run_generation,policy.policy_id,policy.revision,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?; tx.execute("INSERT OR IGNORE INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,state,reason,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?20)",params![operation_id,plan_id,fp,req.workspace_id,req.worker.runtime_id,req.worker.worker_id,worker.updated_at,policy.policy_id,policy.revision,sess(policy.session_disposition),meta(policy.metadata_disposition),archive_kind(policy.archive_retention),archive_seconds(policy.archive_retention),diag(policy.diagnostics_disposition),policy.diagnostics_retention_seconds,archive_id,serde_json::to_string(&blockers).map_err(|e|StoreError::InvalidInput(e.to_string()))?,state_s(state),req.reason,now])?;
let plan=load_plan(&tx,&plan_id)?.ok_or_else(||StoreError::InvalidInput("plan missing".into()))?; let plan=load_plan(&tx,&plan_id)?.ok_or_else(||StoreError::InvalidInput("plan missing".into()))?;
if plan.input_fingerprint!=fp{return Err(StoreError::InvalidInput(format!("fingerprint:{}",plan.operation_id)));} if plan.input_fingerprint!=fp{return Err(StoreError::InvalidInput(format!("fingerprint:{}",plan.operation_id)));}
tx.commit()?; Ok(plan) tx.commit()?; Ok(plan)
@@ -304,7 +303,6 @@ impl SqliteWorkspaceStore {
source_runtime_id: plan.worker.runtime_id.clone(), source_runtime_id: plan.worker.runtime_id.clone(),
worker_id: worker_id, worker_id: worker_id,
expected_worker_revision: plan.worker_revision.clone(), expected_worker_revision: plan.worker_revision.clone(),
expected_run_generation: plan.run_generation,
source_created_at: worker.created_at, source_created_at: worker.created_at,
removed_at, removed_at,
effective_profile: worker.profile, effective_profile: worker.profile,
@@ -376,7 +374,6 @@ impl SqliteWorkspaceStore {
source_runtime_id: plan.worker.runtime_id.clone(), source_runtime_id: plan.worker.runtime_id.clone(),
worker_id: worker_id, worker_id: worker_id,
expected_worker_revision: plan.worker_revision.clone(), expected_worker_revision: plan.worker_revision.clone(),
expected_run_generation: plan.run_generation,
source_created_at: worker source_created_at: worker
.as_ref() .as_ref()
.map(|worker| worker.created_at.clone()) .map(|worker| worker.created_at.clone())
@@ -682,20 +679,20 @@ fn load_plan_op(c: &Connection, id: &str) -> crate::Result<Option<WorkerRemovalP
fn load_plan_q(c: &Connection, key: &str, id: &str) -> crate::Result<Option<WorkerRemovalPlan>> { fn load_plan_q(c: &Connection, key: &str, id: &str) -> crate::Result<Option<WorkerRemovalPlan>> {
let query = format!( let query = format!(
"SELECT plan_id,operation_id,input_fingerprint,workspace_id,runtime_id,worker_id, "SELECT plan_id,operation_id,input_fingerprint,workspace_id,runtime_id,worker_id,
worker_revision,run_generation,policy_id,policy_revision,session_disposition, worker_revision,policy_id,policy_revision,session_disposition,
metadata_disposition,archive_retention_kind,archive_retention_seconds, metadata_disposition,archive_retention_kind,archive_retention_seconds,
diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json, diagnostics_disposition,diagnostics_retention_seconds,archive_id,blockers_json,
state,reason,created_at,updated_at,failure_category state,reason,created_at,updated_at,failure_category
FROM worker_removal_operations WHERE {key}=?1" FROM worker_removal_operations WHERE {key}=?1"
); );
c.query_row(&query, params![id], |row| { c.query_row(&query, params![id], |row| {
let session: String = row.get(10)?; let session: String = row.get(9)?;
let metadata: String = row.get(11)?; let metadata: String = row.get(10)?;
let archive_kind: String = row.get(12)?; let archive_kind: String = row.get(11)?;
let archive_seconds: Option<i64> = row.get(13)?; let archive_seconds: Option<i64> = row.get(12)?;
let diagnostics: String = row.get(14)?; let diagnostics: String = row.get(13)?;
let blockers: String = row.get(17)?; let blockers: String = row.get(16)?;
let state: String = row.get(18)?; let state: String = row.get(17)?;
Ok(WorkerRemovalPlan { Ok(WorkerRemovalPlan {
plan_id: row.get(0)?, plan_id: row.get(0)?,
operation_id: row.get(1)?, operation_id: row.get(1)?,
@@ -706,27 +703,26 @@ fn load_plan_q(c: &Connection, key: &str, id: &str) -> crate::Result<Option<Work
worker_id: row.get(5)?, worker_id: row.get(5)?,
}, },
worker_revision: row.get(6)?, worker_revision: row.get(6)?,
run_generation: row.get::<_, i64>(7)? as u64, policy_id: row.get(7)?,
policy_id: row.get(8)?, policy_revision: row.get::<_, i64>(8)? as u64,
policy_revision: row.get::<_, i64>(9)? as u64,
session_disposition: parse_s(&session)?, session_disposition: parse_s(&session)?,
metadata_disposition: parse_m(&metadata)?, metadata_disposition: parse_m(&metadata)?,
archive_retention: parse_archive(&archive_kind, archive_seconds)?, archive_retention: parse_archive(&archive_kind, archive_seconds)?,
diagnostics_disposition: parse_d(&diagnostics)?, diagnostics_disposition: parse_d(&diagnostics)?,
diagnostics_retention_seconds: row.get::<_, Option<i64>>(15)?.map(|v| v as u64), diagnostics_retention_seconds: row.get::<_, Option<i64>>(14)?.map(|v| v as u64),
archive_id: row.get(16)?, archive_id: row.get(15)?,
blockers: serde_json::from_str(&blockers).map_err(|error| { blockers: serde_json::from_str(&blockers).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure( rusqlite::Error::FromSqlConversionFailure(
17, 16,
rusqlite::types::Type::Text, rusqlite::types::Type::Text,
Box::new(error), Box::new(error),
) )
})?, })?,
state: parse_state(&state)?, state: parse_state(&state)?,
reason: row.get(19)?, reason: row.get(18)?,
created_at: row.get(20)?, created_at: row.get(19)?,
updated_at: row.get(21)?, updated_at: row.get(20)?,
failure_category: row.get(22)?, failure_category: row.get(21)?,
}) })
}) })
.optional() .optional()
@@ -755,7 +751,6 @@ fn fingerprint(
r.worker.runtime_id, r.worker.runtime_id,
r.worker.worker_id, r.worker.worker_id,
worker_revision, worker_revision,
i.run_generation,
i.session_id, i.session_id,
i.segment_ids, i.segment_ids,
p.policy_id, p.policy_id,
@@ -992,7 +987,6 @@ mod tests {
workspace_id: "w".into(), workspace_id: "w".into(),
runtime_id: "r".into(), runtime_id: "r".into(),
worker_id: worker_id(), worker_id: worker_id(),
run_generation: 2,
session_id: Some("s".into()), session_id: Some("s".into()),
segment_ids: vec!["a".into()], segment_ids: vec!["a".into()],
session_bytes: 1, session_bytes: 1,
@@ -1139,13 +1133,12 @@ mod tests {
} }
#[test] #[test]
fn prepared_execution_is_derived_from_pinned_plan_generation() { fn prepared_execution_is_derived_from_pinned_plan() {
let s = setup(); let s = setup();
let plan = s.plan_worker_removal(&req(), &inv()).unwrap(); let plan = s.plan_worker_removal(&req(), &inv()).unwrap();
let prepared = s let prepared = s
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap(); .unwrap();
assert_eq!(prepared.runtime_request.expected_run_generation, 2);
assert_eq!( assert_eq!(
prepared.runtime_request.session_disposition, prepared.runtime_request.session_disposition,
SessionDisposition::Archive SessionDisposition::Archive
@@ -1264,7 +1257,6 @@ mod tests {
workspace_id: "w".into(), workspace_id: "w".into(),
runtime_id: "r".into(), runtime_id: "r".into(),
worker_id: WorkerId::from_legacy_u64(2), worker_id: WorkerId::from_legacy_u64(2),
run_generation: 1,
session_id: Some("orphan-session".into()), session_id: Some("orphan-session".into()),
segment_ids: vec![], segment_ids: vec![],
session_bytes: 10, session_bytes: 10,
@@ -20,7 +20,6 @@ impl WorkerExecutionBackend for TestExecutionBackend {
WorkerExecutionSpawnResult::connected( WorkerExecutionSpawnResult::connected(
WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
protocol::WorkerStateSnapshot { protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into() ..protocol::WorkerStatus::Idle.into()
}, },
None, None,
@@ -182,7 +181,6 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() {
.worker_state .worker_state
.clone() .clone()
.expect("connected test Worker must expose its initial state"); .expect("connected test Worker must expose its initial state");
running.revision += 1;
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running, protocol::WorkerRunState::Running,
)); ));
@@ -348,7 +346,6 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
.worker_state .worker_state
.clone() .clone()
.expect("connected test Worker must expose its initial state"); .expect("connected test Worker must expose its initial state");
running.revision += 1;
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running, protocol::WorkerRunState::Running,
)); ));
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,15 @@
use rusqlite::{OptionalExtension, TransactionBehavior, params}; use rusqlite::{OptionalExtension, TransactionBehavior, params};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::store::WorkdirCreateOperationRecord; use crate::store::{
WorkdirCreateCredentialCandidate, WorkdirCreateCredentialCandidateRole,
WorkdirCreateOperationRecord,
};
use crate::{Error, Result, SqliteWorkspaceStore}; use crate::{Error, Result, SqliteWorkspaceStore};
const MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES: usize = 2;
const MAX_CREDENTIAL_ID_BYTES: usize = 128;
pub fn selector_for_retry( pub fn selector_for_retry(
explicit_selector: Option<&str>, explicit_selector: Option<&str>,
persisted_selector: Option<&str>, persisted_selector: Option<&str>,
@@ -172,10 +178,17 @@ impl SqliteWorkspaceStore {
host_trust_id: &str, host_trust_id: &str,
host_trust_revision: u64, host_trust_revision: u64,
repository_access_mode: &str, repository_access_mode: &str,
credential_candidates: &[WorkdirCreateCredentialCandidate],
now: &str, now: &str,
) -> Result<WorkdirCreateOperationRecord> { ) -> Result<WorkdirCreateOperationRecord> {
validate_workdir_create_credential_candidates(
credential_id,
credential_revision,
credential_candidates,
)?;
self.with_conn_mut(|conn| { self.with_conn_mut(|conn| {
let operation = read_workdir_create_operation(conn, workspace_id, operation_id)? let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| { .ok_or_else(|| {
Error::RegistryInconsistency(format!( Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared before Repository access binding" "Workdir create operation `{operation_id}` disappeared before Repository access binding"
@@ -193,6 +206,7 @@ impl SqliteWorkspaceStore {
|| operation.host_trust_revision != Some(host_trust_revision) || operation.host_trust_revision != Some(host_trust_revision)
|| operation.repository_access_mode.as_deref() || operation.repository_access_mode.as_deref()
!= Some(repository_access_mode) != Some(repository_access_mode)
|| operation.credential_candidates != credential_candidates
{ {
return Err(Error::InvalidInput(format!( return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` Repository access evidence changed" "Workdir create operation `{operation_id}` Repository access evidence changed"
@@ -200,7 +214,7 @@ impl SqliteWorkspaceStore {
} }
return Ok(operation); return Ok(operation);
} }
conn.execute( let updated = tx.execute(
r#"UPDATE workdir_create_operations r#"UPDATE workdir_create_operations
SET credential_id = ?4, credential_revision = ?5, SET credential_id = ?4, credential_revision = ?5,
host_trust_id = ?6, host_trust_revision = ?7, host_trust_id = ?6, host_trust_revision = ?7,
@@ -223,11 +237,60 @@ impl SqliteWorkspaceStore {
now, now,
], ],
)?; )?;
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| { if updated != 1 {
Error::RegistryInconsistency(format!( return Err(Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after Repository access binding" "Workdir create operation `{operation_id}` changed before Repository access binding"
)) )));
}) }
for (ordinal, candidate) in credential_candidates.iter().enumerate() {
tx.execute(
r#"INSERT INTO workdir_create_credential_candidates (
workspace_id, operation_id, ordinal, role,
credential_id, credential_revision
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
params![
workspace_id,
operation_id,
i64::try_from(ordinal).map_err(|_| Error::InvalidInput(
"credential candidate ordinal is out of range".to_string()
))?,
candidate.role.as_str(),
candidate.credential_id,
i64::try_from(candidate.credential_revision).map_err(|_| {
Error::InvalidInput(
"credential candidate revision is out of range".to_string(),
)
})?,
],
)?;
tx.execute(
r#"INSERT INTO workdir_create_credential_revision_retentions (
workspace_id, operation_id, ordinal,
credential_id, credential_revision
) VALUES (?1, ?2, ?3, ?4, ?5)"#,
params![
workspace_id,
operation_id,
i64::try_from(ordinal).map_err(|_| Error::InvalidInput(
"credential candidate ordinal is out of range".to_string()
))?,
candidate.credential_id,
i64::try_from(candidate.credential_revision).map_err(|_| {
Error::InvalidInput(
"credential candidate revision is out of range".to_string(),
)
})?,
],
)?;
}
let bound = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after Repository access binding"
))
})?;
tx.commit()?;
Ok(bound)
}) })
} }
@@ -241,7 +304,8 @@ impl SqliteWorkspaceStore {
updated_at: &str, updated_at: &str,
) -> Result<WorkdirCreateOperationRecord> { ) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| { self.with_conn_mut(|conn| {
let changed = conn.execute( let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let changed = tx.execute(
r#"UPDATE workdir_create_operations r#"UPDATE workdir_create_operations
SET state = ?1, failure = ?2, updated_at = ?3 SET state = ?1, failure = ?2, updated_at = ?3
WHERE workspace_id = ?4 AND operation_id = ?5 WHERE workspace_id = ?4 AND operation_id = ?5
@@ -260,11 +324,21 @@ impl SqliteWorkspaceStore {
"Workdir create operation `{operation_id}` could not be finalized" "Workdir create operation `{operation_id}` could not be finalized"
))); )));
} }
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| { if succeeded {
Error::RegistryInconsistency(format!( tx.execute(
"Workdir create operation `{operation_id}` disappeared" r#"DELETE FROM workdir_create_credential_revision_retentions
)) WHERE workspace_id = ?1 AND operation_id = ?2"#,
}) params![workspace_id, operation_id],
)?;
}
let finished = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared"
))
})?;
tx.commit()?;
Ok(finished)
}) })
} }
@@ -282,8 +356,9 @@ fn read_workdir_create_operation(
workspace_id: &str, workspace_id: &str,
operation_id: &str, operation_id: &str,
) -> Result<Option<WorkdirCreateOperationRecord>> { ) -> Result<Option<WorkdirCreateOperationRecord>> {
conn.query_row( let mut operation = conn
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector, .query_row(
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision, requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, source_kind, source_uri, source_revision, config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, credential_id, credential_revision, source_fingerprint, credential_id, credential_revision,
@@ -292,37 +367,146 @@ fn read_workdir_create_operation(
created_at, updated_at created_at, updated_at
FROM workdir_create_operations FROM workdir_create_operations
WHERE workspace_id = ?1 AND operation_id = ?2"#, WHERE workspace_id = ?1 AND operation_id = ?2"#,
params![workspace_id, operation_id], params![workspace_id, operation_id],
|row| { |row| {
Ok(WorkdirCreateOperationRecord { Ok(WorkdirCreateOperationRecord {
workspace_id: row.get(0)?, workspace_id: row.get(0)?,
operation_id: row.get(1)?, operation_id: row.get(1)?,
request_fingerprint: row.get(2)?, request_fingerprint: row.get(2)?,
repository_id: row.get(3)?, repository_id: row.get(3)?,
selector: row.get(4)?, selector: row.get(4)?,
requested_runtime_id: row.get(5)?, requested_runtime_id: row.get(5)?,
resolved_runtime_id: row.get(6)?, resolved_runtime_id: row.get(6)?,
config_revision: row.get::<_, i64>(7)? as u64, config_revision: row.get::<_, i64>(7)? as u64,
config_projection_digest: row.get(8)?, config_projection_digest: row.get(8)?,
source_kind: row.get(9)?, source_kind: row.get(9)?,
source_uri: row.get(10)?, source_uri: row.get(10)?,
source_revision: row.get::<_, Option<i64>>(11)?.map(|value| value as u64), source_revision: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
source_fingerprint: row.get(12)?, source_fingerprint: row.get(12)?,
credential_id: row.get(13)?, credential_id: row.get(13)?,
credential_revision: row.get::<_, Option<i64>>(14)?.map(|value| value as u64), credential_revision: row.get::<_, Option<i64>>(14)?.map(|value| value as u64),
host_trust_id: row.get(15)?, host_trust_id: row.get(15)?,
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64), host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
repository_access_mode: row.get(17)?, repository_access_mode: row.get(17)?,
working_directory_id: row.get(18)?, credential_candidates: Vec::new(),
state: row.get(19)?, working_directory_id: row.get(18)?,
failure: row.get(20)?, state: row.get(19)?,
created_at: row.get(21)?, failure: row.get(20)?,
updated_at: row.get(22)?, created_at: row.get(21)?,
}) updated_at: row.get(22)?,
}, })
) },
.optional() )
.map_err(Error::from) .optional()?;
if let Some(operation) = operation.as_mut() {
let mut statement = conn.prepare(
r#"SELECT ordinal, role, credential_id, credential_revision
FROM workdir_create_credential_candidates
WHERE workspace_id = ?1 AND operation_id = ?2
ORDER BY ordinal ASC"#,
)?;
let rows = statement.query_map(params![workspace_id, operation_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
))
})?;
for (expected_ordinal, row) in rows.enumerate() {
let (ordinal, role, credential_id, credential_revision) = row?;
if ordinal
!= i64::try_from(expected_ordinal).map_err(|_| {
Error::Store(
"Workdir create credential candidate ordinal is out of range".to_string(),
)
})?
{
return Err(Error::Store(
"Workdir create credential candidate ordinals are not contiguous".to_string(),
));
}
operation
.credential_candidates
.push(WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::parse(&role)?,
credential_id,
credential_revision: u64::try_from(credential_revision).map_err(|_| {
Error::Store(format!(
"invalid Workdir create credential candidate revision `{credential_revision}`"
))
})?,
});
}
if !operation.credential_candidates.is_empty() {
validate_workdir_create_credential_candidates(
operation.credential_id.as_deref().unwrap_or_default(),
operation.credential_revision.unwrap_or_default(),
&operation.credential_candidates,
)
.map_err(|error| {
Error::Store(format!(
"invalid persisted Workdir create credential snapshot: {error}"
))
})?;
}
}
Ok(operation)
}
pub(crate) fn validate_workdir_create_credential_candidates(
credential_id: &str,
credential_revision: u64,
candidates: &[WorkdirCreateCredentialCandidate],
) -> Result<()> {
if candidates.is_empty() || candidates.len() > MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES {
return Err(Error::InvalidInput(format!(
"Workdir create credential candidates must contain 1..={MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES} entries"
)));
}
if credential_id.is_empty() || credential_id.len() > MAX_CREDENTIAL_ID_BYTES {
return Err(Error::InvalidInput(
"Workdir create credential id is invalid".to_string(),
));
}
if credential_revision == 0 {
return Err(Error::InvalidInput(
"Workdir create credential revision must be greater than zero".to_string(),
));
}
let primary = &candidates[0];
if primary.role != WorkdirCreateCredentialCandidateRole::Primary
|| primary.credential_id != credential_id
|| primary.credential_revision != credential_revision
{
return Err(Error::InvalidInput(
"Workdir create primary credential evidence does not match the ordered candidate snapshot"
.to_string(),
));
}
if candidates.len() == 2
&& candidates[1].role != WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback
{
return Err(Error::InvalidInput(
"Workdir create fallback credential role is invalid".to_string(),
));
}
if candidates.iter().any(|candidate| {
candidate.credential_id.is_empty()
|| candidate.credential_id.len() > MAX_CREDENTIAL_ID_BYTES
|| candidate.credential_revision == 0
}) {
return Err(Error::InvalidInput(
"Workdir create credential candidate identity is invalid".to_string(),
));
}
if candidates.len() == 2 && candidates[0].credential_id == candidates[1].credential_id {
return Err(Error::InvalidInput(
"Workdir create credential candidates contain a duplicate credential id".to_string(),
));
}
Ok(())
} }
#[cfg(test)] #[cfg(test)]
@@ -347,7 +531,7 @@ mod tests {
} }
#[test] #[test]
fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() { fn retry_keeps_resolved_config_and_credential_candidates_after_fallback_moves() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord { futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace".to_string(), workspace_id: "workspace".to_string(),
@@ -403,6 +587,7 @@ mod tests {
host_trust_id: None, host_trust_id: None,
host_trust_revision: None, host_trust_revision: None,
repository_access_mode: None, repository_access_mode: None,
credential_candidates: Vec::new(),
working_directory_id: "wd-1".to_string(), working_directory_id: "wd-1".to_string(),
state: "pending".to_string(), state: "pending".to_string(),
failure: None, failure: None,
@@ -413,6 +598,44 @@ mod tests {
store.reserve_workdir_create_operation(&record).unwrap(), store.reserve_workdir_create_operation(&record).unwrap(),
record record
); );
store
.with_conn_mut(|conn| {
for (credential_id, revision) in
[("credential-1", 3_i64), ("workspace-default-ssh", 7_i64)]
{
conn.execute(
r#"INSERT INTO repository_ssh_credentials (
workspace_id, credential_id, name,
public_key_algorithm, public_key_fingerprint,
current_revision, status, created_at
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?1, ?2,
'active', '2026-08-24T00:00:00Z')"#,
params![credential_id, revision],
)?;
conn.execute(
r#"INSERT INTO repository_ssh_credential_revisions (
workspace_id, credential_id, revision,
public_key_algorithm, public_key_fingerprint, created_at
) VALUES ('workspace', ?1, ?2, 'ssh-ed25519', ?1,
'2026-08-24T00:00:00Z')"#,
params![credential_id, revision],
)?;
}
Ok(())
})
.unwrap();
let candidates = vec![
WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::Primary,
credential_id: "credential-1".to_string(),
credential_revision: 3,
},
WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback,
credential_id: "workspace-default-ssh".to_string(),
credential_revision: 7,
},
];
let bound = store let bound = store
.bind_workdir_create_repository_access( .bind_workdir_create_repository_access(
"workspace", "workspace",
@@ -423,12 +646,22 @@ mod tests {
"trust-1", "trust-1",
5, 5,
"read_only", "read_only",
&candidates,
"2026-08-24T00:00:01Z", "2026-08-24T00:00:01Z",
) )
.unwrap(); .unwrap();
assert_eq!(bound.credential_id.as_deref(), Some("credential-1")); assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
assert_eq!(bound.credential_revision, Some(3)); assert_eq!(bound.credential_revision, Some(3));
assert_eq!(bound.host_trust_revision, Some(5)); assert_eq!(bound.host_trust_revision, Some(5));
assert_eq!(bound.credential_candidates, candidates);
let serialized = serde_json::to_string(&bound).unwrap();
assert!(serialized.contains("workspace_default_fallback"));
assert!(!serialized.contains("private_key"));
assert!(!serialized.contains("known_hosts"));
// A concurrent Workspace-default rotation must not replace the fallback
// revision already bound to this operation.
let mut changed_candidates = candidates.clone();
changed_candidates[1].credential_revision = 8;
assert!( assert!(
store store
.bind_workdir_create_repository_access( .bind_workdir_create_repository_access(
@@ -436,10 +669,11 @@ mod tests {
"call-1", "call-1",
&record.request_fingerprint, &record.request_fingerprint,
"credential-1", "credential-1",
4, 3,
"trust-1", "trust-1",
5, 5,
"read_only", "read_only",
&changed_candidates,
"2026-08-24T00:00:02Z", "2026-08-24T00:00:02Z",
) )
.is_err() .is_err()
@@ -475,6 +709,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(retry.state, "pending"); assert_eq!(retry.state, "pending");
assert_eq!(retry.failure, None); assert_eq!(retry.failure, None);
assert_eq!(retry.credential_candidates, candidates);
assert_eq!( assert_eq!(
store store
.load_workdir_create_operation("workspace", "call-1") .load_workdir_create_operation("workspace", "call-1")
+18 -1
View File
@@ -264,7 +264,9 @@ impl SqliteWorkspaceStore {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let operation = let operation =
require_operation(&tx, workspace_id, operation_id, request_fingerprint)?; require_operation(&tx, workspace_id, operation_id, request_fingerprint)?;
if operation.state == WorkdirRemovalOperationState::Completed { if operation.state == WorkdirRemovalOperationState::Completed
&& operation.disposition == Some(WorkdirRemovalDisposition::Removed)
{
tx.commit()?; tx.commit()?;
return Ok(operation); return Ok(operation);
} }
@@ -1096,6 +1098,7 @@ mod tests {
host_trust_id: None, host_trust_id: None,
host_trust_revision: None, host_trust_revision: None,
repository_access_mode: None, repository_access_mode: None,
credential_candidates: Vec::new(),
working_directory_id: "workdir-a".to_string(), working_directory_id: "workdir-a".to_string(),
state: "pending".to_string(), state: "pending".to_string(),
failure: None, failure: None,
@@ -1197,5 +1200,19 @@ mod tests {
.unwrap() .unwrap()
.is_some() .is_some()
); );
let retry = store
.begin_workdir_removal_attempt(
&retained.workspace_id,
&retained.operation_id,
&retained.request_fingerprint,
attempt_owner(),
)
.unwrap();
assert_eq!(retry.state, WorkdirRemovalOperationState::Pending);
assert_eq!(retry.attempt_count, 1);
assert_eq!(retry.disposition, None);
assert_eq!(retry.failure_category, None);
assert!(retry.retryable);
} }
} }
@@ -1,10 +1,10 @@
use std::sync::Arc; use std::sync::Arc;
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
pub use workspace_api::{InitialRepositoryIntent, WorkspaceCreateRequest};
use workspace_api::{RepositoryObservedStatus, RepositorySource}; use workspace_api::{RepositoryObservedStatus, RepositorySource};
use crate::repository_source::{parse_repository_source, repository_source_fingerprint}; use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
@@ -19,23 +19,6 @@ use crate::{Error, Result};
const MAX_DISPLAY_NAME_BYTES: usize = 200; const MAX_DISPLAY_NAME_BYTES: usize = 200;
const MAX_OPERATION_KEY_BYTES: usize = 200; const MAX_OPERATION_KEY_BYTES: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct InitialRepositoryIntent {
pub repository_key: String,
pub uri: String,
#[serde(default)]
pub default_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceCreateRequest {
pub operation_key: String,
pub display_name: String,
pub repository: InitialRepositoryIntent,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreateResult { pub struct WorkspaceCreateResult {
pub workspace: WorkspaceRecord, pub workspace: WorkspaceRecord,
@@ -32,6 +32,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"objective_ticket_links", "objective_ticket_links",
"objectives", "objectives",
"repositories", "repositories",
"runtime_removal_operations",
"repository_secret_audit_events", "repository_secret_audit_events",
"repository_secret_operations", "repository_secret_operations",
"repository_ssh_credential_revisions", "repository_ssh_credential_revisions",
@@ -55,6 +56,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"typed_ticket_relations", "typed_ticket_relations",
"typed_ticket_risk_flags", "typed_ticket_risk_flags",
"typed_tickets", "typed_tickets",
"workdir_create_credential_candidates",
"workdir_create_credential_revision_retentions",
"workdir_create_operations", "workdir_create_operations",
"workdir_registry", "workdir_registry",
"workdir_removal_operations", "workdir_removal_operations",

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