Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96592b1a24 | ||
|
|
d70dee77a9 | ||
|
|
1402240ef0 | ||
|
|
decd1311cb | ||
|
|
c4622e9e3a | ||
|
|
360387c986 | ||
|
|
bc0342b03e | ||
|
|
10ebac142e | ||
|
|
d6bef5d1c7 | ||
|
|
9d8e0114f5 | ||
|
|
93070a3a93 | ||
|
|
d1b94625b1 | ||
|
|
fbbea1bf91 | ||
|
|
a0f8ee51a4 | ||
|
|
ffc1933f06 | ||
|
|
b6960878a6 | ||
|
|
fef3b6f4a0 | ||
|
|
c0a73c12ec | ||
|
|
f40714efc2 | ||
|
|
07ed908ae6 | ||
|
|
cfe772ea7d | ||
|
|
c0fe20e8a2 | ||
|
|
df22526a0d | ||
|
|
1d06d4b08a | ||
|
|
4e7a314a00 | ||
|
|
016dbd7cb1 | ||
|
|
7210d3c202 | ||
|
|
572204b49a | ||
|
|
86dd67a24c | ||
|
|
beeba1fdfc | ||
|
|
41b7b289d0 | ||
|
|
24237249d1 | ||
|
|
e448073b98 | ||
|
|
c08152d069 | ||
|
|
3995561220 | ||
|
|
aea51caeb4 | ||
|
|
3eca380bd8 | ||
|
|
8a3e06bc81 | ||
|
|
c4274c42cb | ||
|
|
a61ad15767 | ||
|
|
7f1e374fd7 | ||
|
|
e3f5445a02 | ||
|
|
d2cb50d081 | ||
|
|
6c609808c9 | ||
|
|
2d4c7b383a | ||
|
|
c21ed7dff2 | ||
|
|
448e392a0e | ||
|
|
d97c40d6af | ||
|
|
2d512b6be6 | ||
|
|
f061a95b48 | ||
|
|
eefdef1bef | ||
|
|
7675f81999 | ||
|
|
8fb592071f | ||
|
|
2528312142 | ||
|
|
08d7965ea8 | ||
|
|
e0badad91f | ||
|
|
f0a91ce2d8 | ||
|
|
24cab83f48 | ||
|
|
33a2b5d702 | ||
|
|
7f807004ad | ||
|
|
5564425488 | ||
|
|
4a89c04732 | ||
|
|
ec5a403ec6 | ||
|
|
f6ce1df766 | ||
|
|
9d7ddcc04a | ||
|
|
3df611636b | ||
|
|
d0999326bd | ||
|
|
6fbc65476c | ||
|
|
fcc7d79d80 | ||
|
|
18fd6a1f5e | ||
|
|
a072562034 | ||
|
|
2b4a2bc688 | ||
|
|
3344d9f8b2 | ||
|
|
fae36d220d | ||
|
|
7b6a84a550 | ||
|
|
f29c343879 | ||
|
|
f5e9f49a13 | ||
|
|
73a35599d2 | ||
|
|
5080d7860e | ||
|
|
7fb1d4056c | ||
|
|
243a081874 | ||
|
|
04924cf796 | ||
|
|
8f0917b8bc | ||
|
|
d4ad46127a | ||
|
|
fba5ecf54c | ||
|
|
e035df9e7b | ||
|
|
3baf0b6358 | ||
|
|
4de04e42b5 | ||
|
|
ebec98a14c |
@@ -1,21 +1,19 @@
|
||||
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。
|
||||
すでにシステムのドッグフーディングに成功しており、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceの実装を進めている。
|
||||
|
||||
## このシステムに置ける設計要旨
|
||||
|
||||
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
|
||||
- プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
|
||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||
|
||||
### 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
+35
-654
@@ -2,15 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
|
||||
dependencies = [
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -177,12 +168,6 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
@@ -476,9 +461,6 @@ name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
@@ -662,15 +644,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -765,15 +738,6 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -792,148 +756,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -1374,18 +1196,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -1788,18 +1598,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
@@ -1922,11 +1720,6 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
@@ -2614,15 +2407,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "manifest"
|
||||
version = "0.1.0"
|
||||
@@ -2713,15 +2497,6 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "memmap2"
|
||||
version = "0.9.10"
|
||||
@@ -3003,18 +2778,6 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "oid-registry"
|
||||
version = "0.7.1"
|
||||
@@ -3410,18 +3173,6 @@ version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "postscript"
|
||||
version = "0.14.1"
|
||||
@@ -3524,29 +3275,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -3824,20 +3552,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -3971,12 +3685,6 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -4216,10 +3924,6 @@ name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
@@ -4393,6 +4097,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4536,9 +4241,6 @@ name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -4771,12 +4473,6 @@ dependencies = [
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "target-triple"
|
||||
version = "1.0.0"
|
||||
@@ -5086,8 +4782,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite 0.29.0",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5395,6 +5095,8 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.4",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -5696,27 +5398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.244.0",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5727,8 +5409,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder 0.244.0",
|
||||
"wasmparser 0.244.0",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5756,286 +5438,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "web-sys"
|
||||
version = "0.3.94"
|
||||
@@ -6133,6 +5535,24 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -6242,25 +5662,6 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -6524,7 +5925,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser 0.244.0",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6571,10 +5972,10 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder 0.244.0",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser 0.244.0",
|
||||
"wit-parser 0.244.0",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6592,26 +5993,7 @@ dependencies = [
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser 0.244.0",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6674,11 +6056,8 @@ dependencies = [
|
||||
"tracing",
|
||||
"tungstenite 0.28.0",
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
"wat",
|
||||
"workdir",
|
||||
"workspace-api",
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6691,6 +6070,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"decodal",
|
||||
"flow",
|
||||
"fs4",
|
||||
"futures",
|
||||
"manifest",
|
||||
"protocol",
|
||||
@@ -6798,6 +6178,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml",
|
||||
"tui",
|
||||
"uuid",
|
||||
"worker",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ pub use workspace_api::{
|
||||
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
|
||||
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
|
||||
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
|
||||
WorkerOperationState as BackendWorkerOperationState,
|
||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
||||
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
||||
@@ -913,7 +914,7 @@ mod tests {
|
||||
"working_directory": {
|
||||
"working_directory_id": "wd-1",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"materializer_kind": "runtime_git_clone",
|
||||
"status": "active",
|
||||
"occupied_by": {
|
||||
"runtime_id": "arcadia",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::{BackendApiClient, BackendApiClientError};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use workspace_api::{
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
|
||||
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
|
||||
WorkspaceSummary,
|
||||
};
|
||||
|
||||
@@ -11,23 +11,8 @@ const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||
|
||||
pub type BackendWorkspace = WorkspaceSummary;
|
||||
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
|
||||
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
|
||||
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
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(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
@@ -149,8 +176,8 @@ mod tests {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Alpha".to_string(),
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repos/alpha".to_string(),
|
||||
display_name: Some("Main".to_string()),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,15 +26,18 @@ pub use backend_runtime::{
|
||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
|
||||
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
||||
BackendWorkerLaunchTarget, BackendWorkerRestoreResponse, BackendWorkerRestoreResult,
|
||||
BackendWorkerSummary, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary,
|
||||
connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options,
|
||||
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
|
||||
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
||||
BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary,
|
||||
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
|
||||
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
|
||||
restore_backend_worker,
|
||||
};
|
||||
pub use backend_workspace::{
|
||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||
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 target::{
|
||||
|
||||
@@ -12,10 +12,10 @@ use workspace_api::{
|
||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
|
||||
RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse,
|
||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
};
|
||||
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
@@ -266,18 +266,6 @@ impl BackendWorkspaceProductClient {
|
||||
))
|
||||
}
|
||||
|
||||
pub fn put_runtime_trust_key(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
request: &PutRuntimeTrustKeyRequest,
|
||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||
self.send_json(
|
||||
Method::PUT,
|
||||
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
||||
Some(request),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn revoke_runtime_trust_key(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
|
||||
@@ -9,14 +9,21 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Alpha".to_string(),
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repos/alpha".to_string(),
|
||||
display_name: Some("Main".to_string()),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(request.clone(), request);
|
||||
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]
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
use globset::Glob;
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink};
|
||||
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, resolve_access_path};
|
||||
|
||||
/// Execute a bounded glob entirely inside the provider process.
|
||||
pub fn run_glob(
|
||||
@@ -15,26 +15,24 @@ pub fn run_glob(
|
||||
if !root.is_absolute() {
|
||||
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())));
|
||||
}
|
||||
if let Some(info) = direct_symlink(base)
|
||||
&& info.target_exists
|
||||
&& info.resolved_path.is_dir()
|
||||
{
|
||||
return Err(FsError::SymlinkDirectoryNotTraversed {
|
||||
tool: "Glob",
|
||||
path: PathBuf::from(request.path.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
let matcher = Glob::new(&request.pattern)
|
||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
|
||||
.compile_matcher();
|
||||
let mut matches = Vec::new();
|
||||
for entry in WalkBuilder::new(base).hidden(false).build().flatten() {
|
||||
let mut walker = WalkBuilder::new(base);
|
||||
walker.hidden(false).follow_links(false);
|
||||
for entry in walker.build().flatten() {
|
||||
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;
|
||||
}
|
||||
let relative = path.strip_prefix(base).unwrap_or(path);
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
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 search::run_grep;
|
||||
|
||||
@@ -22,6 +22,19 @@ pub use search::run_grep;
|
||||
pub trait FsAccessPolicy: Send + Sync {
|
||||
fn is_readable(&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.
|
||||
@@ -477,13 +490,14 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() {
|
||||
fn grep_traverses_a_direct_symlink_directory_and_rejects_a_broken_path() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
std::fs::create_dir(root.join("target-dir")).unwrap();
|
||||
std::fs::write(root.join("target-dir/nested.rs"), "needle nested\n").unwrap();
|
||||
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
|
||||
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
|
||||
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
|
||||
@@ -501,18 +515,35 @@ mod tests {
|
||||
assert_eq!(file_result.match_count, 1);
|
||||
assert!(file_result.output.starts_with("file-link.rs\n"));
|
||||
|
||||
let directory_error = run_grep(
|
||||
let directory_result = run_grep(
|
||||
&root,
|
||||
root.join("directory-link"),
|
||||
request("directory-link"),
|
||||
&readable,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
directory_error,
|
||||
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. }
|
||||
if path == root.join("directory-link")
|
||||
));
|
||||
.unwrap();
|
||||
assert_eq!(directory_result.match_count, 1);
|
||||
assert!(
|
||||
directory_result
|
||||
.output
|
||||
.starts_with("directory-link/nested.rs\n")
|
||||
);
|
||||
|
||||
let glob_result = run_glob(
|
||||
&root,
|
||||
&root.join("directory-link"),
|
||||
GlobRequest {
|
||||
pattern: "**/*.rs".to_string(),
|
||||
path: FsPath::new("directory-link").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
&readable,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
glob_result.paths,
|
||||
vec![FsPath::new("directory-link/nested.rs").unwrap()]
|
||||
);
|
||||
|
||||
let broken_error = run_grep(
|
||||
&root,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -18,7 +19,8 @@ pub fn run_stat(
|
||||
) -> Result<StatResult, FsError> {
|
||||
let logical = request.path;
|
||||
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())));
|
||||
}
|
||||
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
@@ -45,7 +47,7 @@ pub fn run_read(
|
||||
) -> Result<ReadResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let path = require_access(&path, &logical, access, false)?;
|
||||
let path = require_access(&path, &logical, access, false, false)?;
|
||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
if metadata.is_dir() {
|
||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||
@@ -99,7 +101,7 @@ pub fn run_write(
|
||||
let path = resolve(root, &logical)?;
|
||||
let created = !path.exists();
|
||||
if path.exists() {
|
||||
let target = require_access(&path, &logical, access, true)?;
|
||||
let target = require_access(&path, &logical, access, true, false)?;
|
||||
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
|
||||
if metadata.is_dir() {
|
||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||
@@ -113,12 +115,8 @@ pub fn run_write(
|
||||
if request.expected_hash.is_some() {
|
||||
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
FsError::InvalidArgument(format!("{} has no parent", logical.as_str()))
|
||||
})?;
|
||||
let parent_logical = logical_parent(&logical);
|
||||
require_access(parent, &parent_logical, access, true)?;
|
||||
atomic_write(&path, &request.content, &logical)?;
|
||||
let target = require_access(&path, &logical, access, true, true)?;
|
||||
atomic_write(&target, &request.content, &logical)?;
|
||||
}
|
||||
Ok(WriteResult {
|
||||
bytes_written: request.content.len(),
|
||||
@@ -133,7 +131,7 @@ pub fn run_edit(
|
||||
) -> Result<EditResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let target = require_access(&path, &logical, access, true)?;
|
||||
let target = require_access(&path, &logical, access, true, false)?;
|
||||
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
|
||||
let actual_hash = hash_bytes(&bytes);
|
||||
if actual_hash != request.expected_hash {
|
||||
@@ -173,7 +171,8 @@ pub fn run_list(
|
||||
) -> Result<ListResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let path = require_access(&path, &logical, access, false)?;
|
||||
let logical_base = path.clone();
|
||||
let path = require_access(&path, &logical, access, false, true)?;
|
||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
||||
@@ -183,7 +182,15 @@ pub fn run_list(
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
||||
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;
|
||||
}
|
||||
let link_metadata =
|
||||
@@ -203,7 +210,7 @@ pub fn run_list(
|
||||
} else {
|
||||
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())
|
||||
})?;
|
||||
entries.push(ListEntry {
|
||||
@@ -247,19 +254,24 @@ fn require_access(
|
||||
logical: &FsPath,
|
||||
access: &dyn FsAccessPolicy,
|
||||
write: bool,
|
||||
allow_symlink_directory: bool,
|
||||
) -> Result<PathBuf, FsError> {
|
||||
if let Some(info) = direct_symlink(path) {
|
||||
if !info.target_exists {
|
||||
return Err(FsError::BrokenSymlink {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
link: PathBuf::from(logical.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
let symlink = direct_symlink(path);
|
||||
if let Some(info) = symlink.as_ref()
|
||||
&& !info.target_exists
|
||||
{
|
||||
return Err(FsError::BrokenSymlink {
|
||||
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 {
|
||||
access.is_writable(&info.resolved_path)
|
||||
access.is_writable_paths(path, &resolved)
|
||||
} else {
|
||||
access.is_readable(&info.resolved_path)
|
||||
access.is_readable_paths(path, &resolved)
|
||||
};
|
||||
if !allowed {
|
||||
return Err(FsError::SymlinkOutOfScope {
|
||||
@@ -268,21 +280,21 @@ fn require_access(
|
||||
required_permission: if write { "write" } else { "read" },
|
||||
});
|
||||
}
|
||||
if write && info.resolved_path.is_dir() {
|
||||
if !allow_symlink_directory && info.resolved_path.is_dir() {
|
||||
return Err(FsError::SymlinkTargetIsDirectory {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
return Ok(info.resolved_path);
|
||||
return Ok(resolved);
|
||||
}
|
||||
let allowed = if write {
|
||||
access.is_writable(path)
|
||||
access.is_writable_paths(path, &resolved)
|
||||
} else {
|
||||
access.is_readable(path)
|
||||
access.is_readable_paths(path, &resolved)
|
||||
};
|
||||
if allowed {
|
||||
Ok(path.to_path_buf())
|
||||
Ok(resolved)
|
||||
} else if write {
|
||||
Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
|
||||
} else {
|
||||
@@ -290,12 +302,38 @@ fn require_access(
|
||||
}
|
||||
}
|
||||
|
||||
fn logical_parent(path: &FsPath) -> FsPath {
|
||||
let parent = Path::new(path.as_str())
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.to_string_lossy();
|
||||
FsPath::new(parent).unwrap_or_else(|_| FsPath::root())
|
||||
/// Resolve every existing component of an absolute provider path while
|
||||
/// retaining a missing final tail for create operations. Dangling symlinks are
|
||||
/// rejected because no resolved authority identity can be established.
|
||||
pub fn resolve_access_path(path: &Path) -> std::io::Result<PathBuf> {
|
||||
let mut cursor = path;
|
||||
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> {
|
||||
|
||||
@@ -10,7 +10,9 @@ use ignore::WalkBuilder;
|
||||
use ignore::overrides::{Override, OverrideBuilder};
|
||||
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 {
|
||||
path: PathBuf,
|
||||
@@ -220,14 +222,28 @@ pub fn run_grep(
|
||||
return Err(FsError::RelativePath(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() {
|
||||
let link_parent_readable = info
|
||||
.link_path
|
||||
.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);
|
||||
if info.target_exists && link_parent_readable {
|
||||
if link_parent_readable {
|
||||
FsError::SymlinkOutOfScope {
|
||||
path: base.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
@@ -240,15 +256,6 @@ pub fn run_grep(
|
||||
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() {
|
||||
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
||||
_ => FsError::io(&base, e),
|
||||
@@ -259,16 +266,6 @@ pub fn run_grep(
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if base_meta.is_dir()
|
||||
&& let Some(info) = symlink.as_ref()
|
||||
{
|
||||
return Err(FsError::SymlinkDirectoryNotTraversed {
|
||||
tool: "Grep",
|
||||
path: base.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let filter_base = if base_meta.is_file() { root } else { &base };
|
||||
let types = build_types(p.file_type.as_deref())?;
|
||||
let overrides = build_overrides(filter_base, p.glob.as_deref())?;
|
||||
@@ -331,7 +328,9 @@ pub fn run_grep(
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if scan_path(
|
||||
|
||||
@@ -15,7 +15,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::defaults;
|
||||
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||
use crate::plugin::PluginConfig;
|
||||
use crate::{
|
||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
||||
@@ -55,10 +54,6 @@ pub struct WorkerManifestConfig {
|
||||
/// disabled after cascade merge.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// never starts a local MCP subprocess.
|
||||
#[serde(default)]
|
||||
@@ -74,6 +69,7 @@ pub struct WorkerManifestConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub task: Option<FeatureFlagConfigPartial>,
|
||||
@@ -101,8 +97,6 @@ pub struct FeatureConfigPartial {
|
||||
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub orchestration: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||
}
|
||||
|
||||
impl FeatureConfigPartial {
|
||||
@@ -145,7 +139,6 @@ impl FeatureConfigPartial {
|
||||
other.orchestration,
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,10 +363,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.orchestration
|
||||
.map(FeatureFlagConfig::from)
|
||||
.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()),
|
||||
merge_request: Some(value.merge_request.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)",
|
||||
));
|
||||
}
|
||||
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
|
||||
.get("feature")
|
||||
.and_then(toml::Value::as_table)
|
||||
@@ -771,7 +773,6 @@ impl WorkerManifestConfig {
|
||||
PermissionConfigPartial::merge,
|
||||
),
|
||||
feature: self.feature.merge(upper.feature),
|
||||
plugins: merge_plugin_config(self.plugins, upper.plugins),
|
||||
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||
compaction: merge_option(
|
||||
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 {
|
||||
base.stdio_servers.extend(upper.stdio_servers);
|
||||
base
|
||||
@@ -1289,7 +1280,6 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
session,
|
||||
permissions,
|
||||
feature: FeatureConfig::from(cfg.feature),
|
||||
plugins: cfg.plugins,
|
||||
mcp: cfg.mcp,
|
||||
compaction,
|
||||
web: cfg.web,
|
||||
@@ -1329,13 +1319,13 @@ mod tests {
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
},
|
||||
delegation_scope: ScopeConfig::default(),
|
||||
permissions: None,
|
||||
feature: FeatureConfigPartial::default(),
|
||||
plugins: PluginConfig::default(),
|
||||
mcp: McpConfig::default(),
|
||||
session: None,
|
||||
compaction: None,
|
||||
@@ -1575,6 +1565,7 @@ mod tests {
|
||||
target: PathBuf::from("secrets"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
});
|
||||
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
|
||||
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
|
||||
@@ -1712,6 +1703,7 @@ mod tests {
|
||||
target: abs("/a"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
},
|
||||
@@ -1723,11 +1715,13 @@ mod tests {
|
||||
target: abs("/b"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: abs("/a/secret"),
|
||||
permission: Permission::Read,
|
||||
recursive: false,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
},
|
||||
..Default::default()
|
||||
@@ -2091,6 +2085,7 @@ enabled = false
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
},
|
||||
@@ -2193,6 +2188,7 @@ enabled = true
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
},
|
||||
@@ -2269,6 +2265,7 @@ permission = "write"
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
},
|
||||
|
||||
+170
-40
@@ -29,7 +29,7 @@ pub use profile::{
|
||||
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
|
||||
validate_profile_execution_target,
|
||||
};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use protocol::{Permission, ScopeRule, SymlinkPolicy};
|
||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
@@ -69,10 +69,6 @@ pub struct WorkerManifest {
|
||||
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// is config data only: declaring a server never starts a subprocess or
|
||||
/// 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
|
||||
/// the normal scope, host-authority, backend, memory, and network checks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FeatureConfig {
|
||||
#[serde(default)]
|
||||
pub task: FeatureFlagConfig,
|
||||
@@ -135,8 +132,6 @@ pub struct FeatureConfig {
|
||||
pub merge_request: MergeRequestFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub orchestration: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub plugins: FeatureFlagConfig,
|
||||
}
|
||||
|
||||
impl Default for FeatureConfig {
|
||||
@@ -155,7 +150,6 @@ impl Default for FeatureConfig {
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
merge_request: MergeRequestFeatureConfig::default(),
|
||||
orchestration: FeatureFlagConfig::disabled(),
|
||||
plugins: FeatureFlagConfig::disabled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -941,9 +935,7 @@ impl Default for CompactionConfig {
|
||||
|
||||
impl WorkerManifest {
|
||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
|| self.plugins.has_resolved_plan()
|
||||
|| self.feature.memory.workspace_settings.is_some()
|
||||
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
|
||||
}
|
||||
|
||||
/// Parse a manifest from a TOML string.
|
||||
@@ -984,7 +976,8 @@ struct LegacyMemoryConfig {
|
||||
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.
|
||||
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",
|
||||
))
|
||||
})?;
|
||||
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(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
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",
|
||||
)));
|
||||
}
|
||||
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(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"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",
|
||||
)));
|
||||
}
|
||||
if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
|
||||
migrate_legacy_manifest_authority(&mut manifest)?;
|
||||
}
|
||||
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
|
||||
}
|
||||
|
||||
@@ -1063,6 +1061,49 @@ fn validate_persisted_worker_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(
|
||||
mut snapshot: serde_json::Value,
|
||||
) -> Result<WorkerManifest, serde_json::Error> {
|
||||
@@ -1088,7 +1129,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
|
||||
.remove("memory")
|
||||
.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 legacy_memory: LegacyMemoryConfig =
|
||||
@@ -1111,9 +1152,14 @@ fn migrate_legacy_resolved_manifest_snapshot(
|
||||
)));
|
||||
}
|
||||
};
|
||||
if !enabled {
|
||||
if !requested_enabled {
|
||||
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();
|
||||
if legacy_memory.consolidation_model.is_some() {
|
||||
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);
|
||||
}
|
||||
feature.insert("memory".to_string(), resolved);
|
||||
migrate_legacy_manifest_authority(&mut snapshot)?;
|
||||
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
|
||||
}
|
||||
|
||||
@@ -1322,33 +1369,61 @@ model_id = "claude-sonnet-4-20250514"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plugin_enablement_config() {
|
||||
fn dynamic_plugin_manifest_config_is_rejected() {
|
||||
let toml = format!(
|
||||
"{MINIMAL_REQUIRED}\n\
|
||||
[[plugins.enabled]]\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"
|
||||
id = \"project:example\"\n"
|
||||
);
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
assert_eq!(manifest.plugins.enabled.len(), 1);
|
||||
let enabled = &manifest.plugins.enabled[0];
|
||||
assert_eq!(enabled.id, "project:example");
|
||||
assert_eq!(
|
||||
enabled.version.as_ref().map(|version| version.0.as_str()),
|
||||
Some("0.1.0")
|
||||
let error = WorkerManifest::from_toml(&toml).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("dynamic Plugins are not supported"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]);
|
||||
assert_eq!(
|
||||
enabled
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("greeting"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("hello")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_manifest_with_dynamic_plugin_plan_is_rejected() {
|
||||
let base =
|
||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||
|
||||
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"
|
||||
);
|
||||
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());
|
||||
|
||||
let mut disabled =
|
||||
@@ -1597,6 +1672,61 @@ model_id = "claude-sonnet-4-20250514"
|
||||
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]
|
||||
fn persisted_manifest_adapter_rejects_mixed_or_future_authority() {
|
||||
let manifest =
|
||||
@@ -1639,7 +1769,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
assert!(
|
||||
read_persisted_worker_manifest_snapshot(serde_json::json!({
|
||||
"schema_version": 3,
|
||||
"schema_version": 4,
|
||||
"manifest": manifest,
|
||||
}))
|
||||
.is_err()
|
||||
|
||||
+100
-1874
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,6 @@ use crate::config::{
|
||||
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
|
||||
};
|
||||
use crate::model::{AuthRef, ModelManifest};
|
||||
use crate::plugin::PluginConfig;
|
||||
use crate::{
|
||||
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
|
||||
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
|
||||
@@ -148,7 +147,6 @@ pub enum WorkspaceAuthorityRequirement {
|
||||
MergeRequest,
|
||||
Objective,
|
||||
Orchestration,
|
||||
Plugins,
|
||||
Ticket,
|
||||
Worker,
|
||||
}
|
||||
@@ -162,7 +160,6 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
|
||||
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
||||
Self::Objective => formatter.write_str("feature.objective"),
|
||||
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::Worker => formatter.write_str("feature.worker"),
|
||||
}
|
||||
@@ -202,9 +199,6 @@ pub fn validate_profile_execution_target(
|
||||
if feature.orchestration.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
|
||||
}
|
||||
if feature.plugins.enabled || !manifest.plugins.is_empty() {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Plugins);
|
||||
}
|
||||
if feature.ticket.enabled
|
||||
|| feature.ticket.authoring
|
||||
|| feature.ticket.thread
|
||||
@@ -638,7 +632,6 @@ fn resolve_profile_value(
|
||||
session: profile.session,
|
||||
permissions: profile.permissions,
|
||||
feature: profile.feature,
|
||||
plugins: profile.plugins,
|
||||
mcp: profile.mcp,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
@@ -684,8 +677,6 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
feature: FeatureConfigPartial,
|
||||
#[serde(default)]
|
||||
plugins: PluginConfig,
|
||||
#[serde(default)]
|
||||
mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
compaction: Option<serde_json::Value>,
|
||||
@@ -970,6 +961,7 @@ fn profile_scope_intent_to_config(
|
||||
target: workspace_base.join(path),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
});
|
||||
}
|
||||
Ok(ScopeConfig {
|
||||
@@ -977,6 +969,7 @@ fn profile_scope_intent_to_config(
|
||||
target: workspace_base.to_path_buf(),
|
||||
permission,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
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]
|
||||
fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -1305,8 +1343,6 @@ mod tests {
|
||||
assert!(!resolved.manifest.feature.flow.enabled);
|
||||
assert!(!resolved.manifest.feature.worker.enabled);
|
||||
assert!(!resolved.manifest.feature.manage_workdir.enabled);
|
||||
assert!(!resolved.manifest.feature.plugins.enabled);
|
||||
assert!(resolved.manifest.plugins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+271
-69
@@ -3,16 +3,17 @@
|
||||
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
||||
//! rule `target` must already be an absolute path — per-layer path
|
||||
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
|
||||
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
|
||||
//! possible) so access checks are pure path comparisons.
|
||||
//! All rule targets retain both their lexically normalized logical identity and
|
||||
//! their provider-resolved identity. Allow rules select one identity explicitly;
|
||||
//! deny rules always inspect both so aliases cannot bypass a restriction.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
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.
|
||||
///
|
||||
@@ -26,10 +27,13 @@ pub struct Scope {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ResolvedRule {
|
||||
/// Absolute, canonicalized-or-normalized target directory/file.
|
||||
target: PathBuf,
|
||||
/// Absolute, lexically normalized target as presented through the Workdir.
|
||||
logical_target: PathBuf,
|
||||
/// Absolute target after provider-side symbolic-link resolution.
|
||||
resolved_target: PathBuf,
|
||||
permission: Permission,
|
||||
recursive: bool,
|
||||
symlink_policy: SymlinkPolicy,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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 {
|
||||
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) {
|
||||
// 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 recursive request always includes descendants beyond that finite-depth
|
||||
// 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
|
||||
// target is identical. A request rooted at a direct child would also grant
|
||||
// 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) {
|
||||
(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),
|
||||
(false, true) => recursive_and_non_recursive_sets_overlap(right, left),
|
||||
(true, false) => recursive_and_non_recursive_sets_overlap(left_target, right_target),
|
||||
(false, true) => recursive_and_non_recursive_sets_overlap(right_target, left_target),
|
||||
(false, false) => {
|
||||
left.target == right.target
|
||||
|| direct_child(&left.target, &right.target)
|
||||
|| direct_child(&right.target, &left.target)
|
||||
left_target == right_target
|
||||
|| direct_child(left_target, right_target)
|
||||
|| direct_child(right_target, left_target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recursive_and_non_recursive_sets_overlap(
|
||||
recursive: &ResolvedRule,
|
||||
non_recursive: &ResolvedRule,
|
||||
recursive_target: &Path,
|
||||
non_recursive_target: &Path,
|
||||
) -> bool {
|
||||
// The non-recursive set is `{target} + direct children`. It overlaps a
|
||||
// recursive subtree when either the non-recursive target is inside that
|
||||
// subtree, or the recursive subtree begins at the non-recursive target or
|
||||
// one of its direct children.
|
||||
non_recursive.target.starts_with(&recursive.target)
|
||||
|| recursive.target == non_recursive.target
|
||||
|| direct_child(&recursive.target, &non_recursive.target)
|
||||
non_recursive_target.starts_with(recursive_target)
|
||||
|| recursive_target == non_recursive_target
|
||||
|| direct_child(recursive_target, non_recursive_target)
|
||||
}
|
||||
|
||||
fn direct_child(child: &Path, parent: &Path) -> bool {
|
||||
@@ -201,23 +239,35 @@ impl Scope {
|
||||
}
|
||||
|
||||
/// Convenience constructor for tests and simple setups: a single
|
||||
/// recursive `allow(Write)` rule rooted at `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> {
|
||||
let root = root.as_ref().canonicalize()?;
|
||||
let root = normalize_path(root.as_ref()).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"scope root must be an absolute path without root traversal",
|
||||
)
|
||||
})?;
|
||||
let resolved_root = resolve_path(&root)?;
|
||||
Ok(Self {
|
||||
allow: vec![ResolvedRule {
|
||||
target: root,
|
||||
logical_target: root,
|
||||
resolved_target: resolved_root,
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: SymlinkPolicy::Resolved,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve one rule target with the same symlink and missing-tail semantics
|
||||
/// used by scope matching.
|
||||
/// Return one rule target in the identity selected by its symlink policy.
|
||||
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.
|
||||
@@ -244,10 +294,23 @@ impl Scope {
|
||||
/// Returns `None` when `path` is outside every allow rule, or when
|
||||
/// deny rules have knocked it below `Read`.
|
||||
pub fn permission_at(&self, path: &Path) -> Option<Permission> {
|
||||
let resolved = resolve_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;
|
||||
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 {
|
||||
None => Some(rule.permission),
|
||||
Some(cur) => Some(cur.max(rule.permission)),
|
||||
@@ -256,11 +319,13 @@ impl Scope {
|
||||
}
|
||||
let mut effective = effective?;
|
||||
|
||||
// Deny: min(min_deny) dictates the cap. Effective level is capped
|
||||
// strictly below that value, so deny(read) wipes access entirely.
|
||||
// Deny rules always inspect both identities. This prevents a logical
|
||||
// alias or a second symlink to the same target from bypassing a deny.
|
||||
let mut min_deny: Option<Permission> = None;
|
||||
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 {
|
||||
None => Some(rule.permission),
|
||||
Some(cur) => Some(cur.min(rule.permission)),
|
||||
@@ -293,7 +358,7 @@ impl Scope {
|
||||
/// rule, preserving declaration order. Does not account for deny
|
||||
/// rules, which only cap effective permission at query time.
|
||||
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.
|
||||
@@ -305,9 +370,10 @@ impl Scope {
|
||||
self.allow
|
||||
.iter()
|
||||
.map(|r| ScopeRule {
|
||||
target: r.target.clone(),
|
||||
target: r.logical_target.clone(),
|
||||
permission: r.permission,
|
||||
recursive: r.recursive,
|
||||
symlink_policy: r.symlink_policy,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -322,9 +388,10 @@ impl Scope {
|
||||
self.deny
|
||||
.iter()
|
||||
.map(|r| ScopeRule {
|
||||
target: r.target.clone(),
|
||||
target: r.logical_target.clone(),
|
||||
permission: r.permission,
|
||||
recursive: r.recursive,
|
||||
symlink_policy: r.symlink_policy,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -335,7 +402,7 @@ impl Scope {
|
||||
self.allow
|
||||
.iter()
|
||||
.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
|
||||
@@ -412,7 +479,10 @@ impl Scope {
|
||||
pub fn summary(&self) -> String {
|
||||
fn push_rule(out: &mut String, rule: &ResolvedRule) {
|
||||
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 {
|
||||
out.push_str(" [non-recursive]");
|
||||
}
|
||||
@@ -510,11 +580,15 @@ impl SharedScope {
|
||||
}
|
||||
|
||||
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 {
|
||||
path.starts_with(&self.target)
|
||||
path.starts_with(target)
|
||||
} else {
|
||||
path == self.target || path.parent() == Some(self.target.as_path())
|
||||
path == target || path.parent() == Some(target.as_path())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,48 +597,84 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
|
||||
if !rule.target.is_absolute() {
|
||||
return Err(ScopeError::RelativeTarget(rule.target.clone()));
|
||||
}
|
||||
let target = resolve_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
|
||||
let logical_target = normalize_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
|
||||
path: rule.target.clone(),
|
||||
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 {
|
||||
target,
|
||||
logical_target,
|
||||
resolved_target,
|
||||
permission: rule.permission,
|
||||
recursive: rule.recursive,
|
||||
symlink_policy: rule.symlink_policy,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert `path` to an absolute form suitable for prefix comparison.
|
||||
///
|
||||
/// Tries `canonicalize` on the full path first (resolves symlinks). If
|
||||
/// the path doesn't exist yet, climbs to the closest existing ancestor,
|
||||
/// canonicalizes it, then rejoins the missing tail. Returns `None` for
|
||||
/// relative inputs that have no existing ancestor to anchor against.
|
||||
fn resolve_path(path: &Path) -> Option<PathBuf> {
|
||||
/// 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
|
||||
/// filesystem metadata or resolving symbolic links.
|
||||
fn normalize_path(path: &Path) -> Option<PathBuf> {
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(canonical) = path.canonicalize() {
|
||||
return Some(canonical);
|
||||
}
|
||||
let mut tail: Vec<OsString> = Vec::new();
|
||||
let mut cur = path.to_path_buf();
|
||||
loop {
|
||||
if let Ok(canonical) = cur.canonicalize() {
|
||||
let mut out = canonical;
|
||||
for segment in tail.iter().rev() {
|
||||
out.push(segment);
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||
Component::RootDir => normalized.push(component.as_os_str()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !normalized.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return Some(out);
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
}
|
||||
let name = cur.file_name()?.to_os_string();
|
||||
tail.push(name);
|
||||
let parent = cur.parent()?.to_path_buf();
|
||||
if parent == cur {
|
||||
return None;
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
normalized.is_absolute().then_some(normalized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -577,6 +687,7 @@ mod tests {
|
||||
target: target.to_path_buf(),
|
||||
permission,
|
||||
recursive,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,6 +802,7 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: false,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
@@ -790,6 +902,7 @@ mod tests {
|
||||
target: PathBuf::from("relative/path"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
@@ -805,6 +918,88 @@ mod tests {
|
||||
assert!(!scope.is_readable(&traversal));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn scope_defaults_to_resolved_symlink_authority_and_logical_is_explicit() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(outside.path().join("outside.txt"), "visible through link").unwrap();
|
||||
symlink(outside.path(), dir.path().join("external")).unwrap();
|
||||
|
||||
let resolved = Scope::writable(dir.path()).unwrap();
|
||||
assert!(!resolved.is_readable(&dir.path().join("external/outside.txt")));
|
||||
assert!(!resolved.is_writable(&dir.path().join("external/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]
|
||||
fn summary_lists_readable_and_writable() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -851,11 +1046,13 @@ mod tests {
|
||||
target: docs.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: false,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
],
|
||||
deny: Vec::new(),
|
||||
@@ -914,6 +1111,7 @@ mod tests {
|
||||
target: extra.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
.unwrap();
|
||||
assert!(extended.is_readable(&extra.path().join("x")));
|
||||
@@ -931,6 +1129,7 @@ mod tests {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
.unwrap();
|
||||
let f = sub.join("a.txt");
|
||||
@@ -950,6 +1149,7 @@ mod tests {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
};
|
||||
let base = Scope::writable(dir.path())
|
||||
.unwrap()
|
||||
@@ -1003,6 +1203,7 @@ mod tests {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1021,6 +1222,7 @@ mod tests {
|
||||
target: extra.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -23,6 +23,7 @@ fn deny_write(target: &Path) -> ScopeRule {
|
||||
target: target.to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+142
-137
@@ -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)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct WorkerCommandEnvelope {
|
||||
/// Caller-owned sequence. A controller accepts command ids in strictly
|
||||
/// increasing order for one execution generation.
|
||||
pub command_id: u64,
|
||||
pub expected_execution_generation: u64,
|
||||
pub expected_worker_state_revision: u64,
|
||||
}
|
||||
|
||||
impl WorkerCommandEnvelope {
|
||||
pub fn for_snapshot(command_id: u64, snapshot: &WorkerStateSnapshot) -> Self {
|
||||
Self {
|
||||
command_id,
|
||||
expected_execution_generation: snapshot.execution_generation,
|
||||
expected_worker_state_revision: snapshot.revision,
|
||||
}
|
||||
pub fn new(command_id: u64) -> Self {
|
||||
Self { command_id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,8 +117,6 @@ pub enum WorkerCommandKind {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerCommandDisposition {
|
||||
Accepted,
|
||||
StaleExecutionGeneration,
|
||||
StaleWorkerStateRevision,
|
||||
StaleCommandId,
|
||||
Conflict,
|
||||
InvalidState,
|
||||
@@ -175,18 +168,14 @@ pub enum WorkerMaintenanceState {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct WorkerStateSnapshot {
|
||||
pub execution_generation: u64,
|
||||
pub revision: u64,
|
||||
/// Highest lifecycle command id observed by this controller generation.
|
||||
/// Highest lifecycle command id observed by this controller instance.
|
||||
pub last_command_id: u64,
|
||||
pub state: WorkerState,
|
||||
}
|
||||
|
||||
impl WorkerStateSnapshot {
|
||||
pub fn initial(execution_generation: u64) -> Self {
|
||||
pub fn initial() -> Self {
|
||||
Self {
|
||||
execution_generation,
|
||||
revision: 0,
|
||||
last_command_id: 0,
|
||||
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 {
|
||||
fn from(status: WorkerStatus) -> Self {
|
||||
let state = match status {
|
||||
@@ -261,8 +203,6 @@ impl From<WorkerStatus> for WorkerStateSnapshot {
|
||||
WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)),
|
||||
};
|
||||
Self {
|
||||
execution_generation: 1,
|
||||
revision: 0,
|
||||
last_command_id: 0,
|
||||
state,
|
||||
}
|
||||
@@ -1228,8 +1168,12 @@ pub enum Event {
|
||||
/// This is not part of LLM history or prompt context; clients may display it
|
||||
/// briefly as operational status.
|
||||
MemoryWorker(MemoryWorkerEvent),
|
||||
/// Worker has started compacting the current session, or bound the run to its
|
||||
/// observable Internal Worker. Revisions upsert one stable lifecycle item.
|
||||
/// Runtime-only compaction progress. `None` clears the current status.
|
||||
/// 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 {
|
||||
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
|
||||
/// `Event::Snapshot` for clients that attach while work is still streaming.
|
||||
///
|
||||
@@ -1430,11 +1400,17 @@ pub struct InFlightSnapshot {
|
||||
pub blocks: Vec<InFlightBlock>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
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 {
|
||||
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`.
|
||||
#[serde(default = "default_recursive")]
|
||||
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 {
|
||||
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`].
|
||||
///
|
||||
/// The derived `Ord` instance follows declaration order, so
|
||||
@@ -1624,55 +1618,43 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn worker_state_snapshot_apply_is_monotonic_and_detects_conflicts() {
|
||||
let mut current = WorkerStateSnapshot::initial(4);
|
||||
let mut newer = current.clone();
|
||||
newer.revision = 1;
|
||||
newer.state = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running));
|
||||
fn scope_rule_defaults_to_resolved_symlink_policy() {
|
||||
let rule: ScopeRule = serde_json::from_value(serde_json::json!({
|
||||
"target": "/workspace",
|
||||
"permission": "read"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(rule.recursive);
|
||||
assert_eq!(rule.symlink_policy, SymlinkPolicy::Resolved);
|
||||
|
||||
assert_eq!(
|
||||
apply_worker_state_snapshot(&mut current, &newer),
|
||||
Ok(WorkerStateSnapshotApply::Applied)
|
||||
);
|
||||
assert_eq!(
|
||||
apply_worker_state_snapshot(&mut current, &newer),
|
||||
Ok(WorkerStateSnapshotApply::Duplicate)
|
||||
);
|
||||
let logical: ScopeRule = serde_json::from_value(serde_json::json!({
|
||||
"target": "/workspace",
|
||||
"permission": "read",
|
||||
"symlink_policy": "logical"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(logical.symlink_policy, SymlinkPolicy::Logical);
|
||||
}
|
||||
|
||||
let stale_revision = WorkerStateSnapshot::initial(4);
|
||||
assert_eq!(
|
||||
apply_worker_state_snapshot(&mut current, &stale_revision),
|
||||
Ok(WorkerStateSnapshotApply::Stale)
|
||||
);
|
||||
let stale_generation = WorkerStateSnapshot {
|
||||
execution_generation: 3,
|
||||
revision: u64::MAX,
|
||||
..newer.clone()
|
||||
#[test]
|
||||
fn worker_state_snapshot_wire_shape_has_one_authoritative_state() {
|
||||
let snapshot = WorkerStateSnapshot {
|
||||
last_command_id: 7,
|
||||
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||
};
|
||||
let value = serde_json::to_value(&snapshot).unwrap();
|
||||
assert_eq!(
|
||||
apply_worker_state_snapshot(&mut current, &stale_generation),
|
||||
Ok(WorkerStateSnapshotApply::Stale)
|
||||
);
|
||||
|
||||
let conflicting = WorkerStateSnapshot {
|
||||
state: WorkerState::Idle,
|
||||
..newer.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
apply_worker_state_snapshot(&mut current, &conflicting),
|
||||
Err(WorkerStateSnapshotConflict {
|
||||
execution_generation: 4,
|
||||
revision: 1,
|
||||
value,
|
||||
serde_json::json!({
|
||||
"last_command_id": 7,
|
||||
"state": {
|
||||
"kind": "busy",
|
||||
"state": { "kind": "run", "state": "running" }
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(current, newer);
|
||||
|
||||
let next_generation = WorkerStateSnapshot::initial(5);
|
||||
assert_eq!(
|
||||
apply_worker_state_snapshot(&mut current, &next_generation),
|
||||
Ok(WorkerStateSnapshotApply::Applied)
|
||||
);
|
||||
assert_eq!(current, next_generation);
|
||||
assert!(value.get("execution_generation").is_none());
|
||||
assert!(value.get("revision").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1869,28 +1851,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_methods_roundtrip_with_fences() {
|
||||
fn lifecycle_methods_roundtrip_with_command_identity() {
|
||||
for method in [
|
||||
Method::Pause {
|
||||
command: WorkerCommandEnvelope {
|
||||
command_id: 11,
|
||||
expected_execution_generation: 4,
|
||||
expected_worker_state_revision: 8,
|
||||
},
|
||||
command: WorkerCommandEnvelope { command_id: 11 },
|
||||
},
|
||||
Method::Compact {
|
||||
command: WorkerCommandEnvelope {
|
||||
command_id: 12,
|
||||
expected_execution_generation: 4,
|
||||
expected_worker_state_revision: 9,
|
||||
},
|
||||
command: WorkerCommandEnvelope { command_id: 12 },
|
||||
},
|
||||
] {
|
||||
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();
|
||||
match decoded {
|
||||
Method::Pause { command } | Method::Compact { command } => {
|
||||
assert_eq!(command.expected_execution_generation, 4);
|
||||
assert!(command.command_id >= 11);
|
||||
}
|
||||
other => panic!("unexpected lifecycle method: {other:?}"),
|
||||
@@ -2187,7 +2162,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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();
|
||||
match decoded {
|
||||
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
||||
@@ -2243,6 +2218,11 @@ mod tests {
|
||||
stderr: CommandStreamSlice::default(),
|
||||
exit_code: None,
|
||||
}],
|
||||
compaction: Some(InFlightCompaction {
|
||||
phase: CompactionPhase::Summarizing,
|
||||
started_at_ms: 99,
|
||||
trigger: CompactionTrigger::Manual,
|
||||
}),
|
||||
},
|
||||
internal_workers: Vec::new(),
|
||||
};
|
||||
@@ -2254,9 +2234,35 @@ mod tests {
|
||||
parsed["data"]["in_flight"]["blocks"][2]["state"],
|
||||
"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() {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -2300,8 +2306,6 @@ mod tests {
|
||||
fn event_worker_state_format() {
|
||||
let event = Event::WorkerState {
|
||||
snapshot: WorkerStateSnapshot {
|
||||
execution_generation: 7,
|
||||
revision: 3,
|
||||
last_command_id: 9,
|
||||
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||
},
|
||||
@@ -2309,8 +2313,12 @@ mod tests {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "worker_state");
|
||||
assert_eq!(parsed["data"]["snapshot"]["execution_generation"], 7);
|
||||
assert_eq!(parsed["data"]["snapshot"]["revision"], 3);
|
||||
assert!(
|
||||
parsed["data"]["snapshot"]
|
||||
.get("execution_generation")
|
||||
.is_none()
|
||||
);
|
||||
assert!(parsed["data"]["snapshot"].get("revision").is_none());
|
||||
assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy");
|
||||
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
@@ -2318,10 +2326,8 @@ mod tests {
|
||||
decoded,
|
||||
Event::WorkerState {
|
||||
snapshot: WorkerStateSnapshot {
|
||||
execution_generation: 7,
|
||||
revision: 3,
|
||||
last_command_id: 9,
|
||||
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||
..
|
||||
}
|
||||
}
|
||||
));
|
||||
@@ -2462,6 +2468,7 @@ mod tests {
|
||||
target: "/tmp/work".into(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
});
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
@@ -2780,8 +2787,6 @@ mod tests {
|
||||
"tools": []
|
||||
},
|
||||
"state": {
|
||||
"execution_generation": 1,
|
||||
"revision": 0,
|
||||
"last_command_id": 0,
|
||||
"state": { "kind": "idle" }
|
||||
}
|
||||
|
||||
@@ -4,18 +4,18 @@ use ts_rs::{Config, TS};
|
||||
|
||||
use crate::{
|
||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
|
||||
RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, SessionContentPart,
|
||||
SessionEntryProvenance, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
||||
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, ToolResultDisposition,
|
||||
TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
|
||||
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||
WorkerStateSnapshot, WorkerStatus,
|
||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
||||
CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
||||
InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
|
||||
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
|
||||
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
||||
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
|
||||
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
|
||||
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
||||
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
@@ -64,6 +64,7 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<ToolResultDisposition>(&cfg, &mut output);
|
||||
push_decl::<ErrorCode>(&cfg, &mut output);
|
||||
push_decl::<Permission>(&cfg, &mut output);
|
||||
push_decl::<SymlinkPolicy>(&cfg, &mut output);
|
||||
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
||||
push_decl::<CommandStatus>(&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::<CompactionLifecycleState>(&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::<UploadedFileRef>(&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::<RewindSummary>(&cfg, &mut output);
|
||||
push_decl::<InFlightBlock>(&cfg, &mut output);
|
||||
push_decl::<InFlightCompaction>(&cfg, &mut output);
|
||||
push_decl::<InFlightSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
|
||||
push_decl::<SessionMessageRole>(&cfg, &mut output);
|
||||
|
||||
@@ -8,3 +8,6 @@ license.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
session-store = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
# 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
|
||||
- explicit memory usage/read/reference observations where applicable
|
||||
- lightweight diagnostic data that should not become model context by itself
|
||||
let metric = Metric::now("compact.start")
|
||||
.with_value(12_345.0)
|
||||
.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:
|
||||
|
||||
- prompt context packing (`agen`)
|
||||
- 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)
|
||||
let records = read_session_metrics(&store, location.session_id)?;
|
||||
let jsonl = export_metrics_jsonl(&records)?;
|
||||
# Ok::<(), Box<dyn std::error::Error>>(())
|
||||
```
|
||||
|
||||
@@ -12,13 +12,16 @@
|
||||
//! 観測できない値は `None` で明示する
|
||||
//! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に
|
||||
//! 書き戻さず、`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 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.
|
||||
pub const DOMAIN: &str = "metrics";
|
||||
@@ -97,6 +100,165 @@ pub fn metrics_from_extensions(extensions: &[(String, serde_json::Value)]) -> Ve
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -151,6 +313,101 @@ mod tests {
|
||||
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]
|
||||
fn fold_skips_undeserializable_payloads() {
|
||||
// 将来 schema が変わって読めない payload も skip して落ちない。
|
||||
|
||||
@@ -14,8 +14,24 @@
|
||||
|
||||
use crate::{SegmentId, SessionId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
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.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -63,6 +79,8 @@ pub struct WorkerSpawnedScopeRule {
|
||||
pub target: PathBuf,
|
||||
pub permission: String,
|
||||
pub recursive: bool,
|
||||
#[serde(default)]
|
||||
pub symlink_policy: protocol::SymlinkPolicy,
|
||||
}
|
||||
|
||||
/// One child Worker spawned by this Worker and persisted with the spawner's
|
||||
@@ -180,6 +198,24 @@ pub trait WorkerMetadataStore: Send + Sync {
|
||||
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.
|
||||
fn set_active(
|
||||
&self,
|
||||
@@ -328,6 +364,7 @@ pub trait WorkerMetadataStore: Send + Sync {
|
||||
pub struct WorkerAggregateStore {
|
||||
root: PathBuf,
|
||||
worker_name: String,
|
||||
update_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl WorkerAggregateStore {
|
||||
@@ -339,7 +376,11 @@ impl WorkerAggregateStore {
|
||||
let worker_name = worker_name.into();
|
||||
validate_worker_name(&worker_name)?;
|
||||
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> {
|
||||
@@ -406,6 +447,47 @@ impl WorkerMetadataStore for WorkerAggregateStore {
|
||||
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> {
|
||||
Ok(if self.metadata_path().is_file() {
|
||||
vec![self.worker_name.clone()]
|
||||
@@ -432,6 +514,7 @@ impl WorkerMetadataStore for WorkerAggregateStore {
|
||||
#[derive(Clone)]
|
||||
pub struct FsWorkerStore {
|
||||
root: PathBuf,
|
||||
update_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl FsWorkerStore {
|
||||
@@ -439,7 +522,10 @@ impl FsWorkerStore {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
|
||||
let root = root.into();
|
||||
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> {
|
||||
@@ -455,12 +541,32 @@ impl FsWorkerStore {
|
||||
impl WorkerMetadataStore for FsWorkerStore {
|
||||
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||
let path = self.metadata_path(&metadata.worker_name)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
let mut content = serde_json::to_vec_pretty(metadata)?;
|
||||
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)?;
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
result
|
||||
}
|
||||
|
||||
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)?))
|
||||
}
|
||||
|
||||
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> {
|
||||
let mut names = Vec::new();
|
||||
if !self.root.exists() {
|
||||
@@ -648,6 +795,26 @@ where
|
||||
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||
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> {
|
||||
self.worker_metadata_store.list_names()
|
||||
}
|
||||
@@ -662,6 +829,7 @@ where
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{LogEntry, Store};
|
||||
|
||||
#[test]
|
||||
fn worker_metadata_manifest_snapshot_roundtrips() {
|
||||
@@ -682,6 +850,25 @@ mod tests {
|
||||
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]
|
||||
fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -835,6 +1022,7 @@ mod tests {
|
||||
target: std::path::Path::new("/tmp/delegated").into(),
|
||||
permission: "write".into(),
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
};
|
||||
store
|
||||
.set_spawned_children(
|
||||
@@ -862,4 +1050,183 @@ mod tests {
|
||||
assert_eq!(restored.reclaimed_children.len(), 1);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,10 +318,7 @@ impl StandaloneHost {
|
||||
}
|
||||
|
||||
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
|
||||
let command = protocol::WorkerCommandEnvelope::for_snapshot(
|
||||
u64::MAX,
|
||||
&self.handle.shared_state.snapshot(),
|
||||
);
|
||||
let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
|
||||
let _ = self.handle.send(Method::Shutdown { command }).await;
|
||||
let Some(shutdown) = self.shutdown.take() else {
|
||||
self.retain_lease();
|
||||
@@ -504,10 +501,7 @@ fn active_pointer(
|
||||
}
|
||||
|
||||
async fn stop_started_worker(started: BootstrappedWorker) {
|
||||
let command = protocol::WorkerCommandEnvelope::for_snapshot(
|
||||
u64::MAX,
|
||||
&started.handle.shared_state.snapshot(),
|
||||
);
|
||||
let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
|
||||
let _ = started.handle.send(Method::Shutdown { command }).await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
|
||||
}
|
||||
|
||||
@@ -191,8 +191,7 @@ impl StandaloneWorkerStore {
|
||||
StandaloneStoreError::Io(error)
|
||||
}
|
||||
})?;
|
||||
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes)
|
||||
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
|
||||
let record = decode_worker_record(id, &bytes)?;
|
||||
if record.schema_version > SCHEMA_VERSION {
|
||||
return Err(StandaloneStoreError::NewerSchema {
|
||||
id,
|
||||
@@ -408,7 +407,7 @@ impl StandaloneWorkerStore {
|
||||
.create_new(true)
|
||||
.open(&temporary)
|
||||
.map_err(StandaloneStoreError::Io)?;
|
||||
serde_json::to_writer_pretty(&mut file, next).map_err(StandaloneStoreError::Json)?;
|
||||
write_worker_record(&mut file, next)?;
|
||||
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
|
||||
file.sync_all().map_err(StandaloneStoreError::Io)?;
|
||||
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
||||
@@ -428,8 +427,7 @@ impl StandaloneWorkerStore {
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let bytes =
|
||||
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
|
||||
decode_worker_record(id, &bytes)
|
||||
}
|
||||
|
||||
fn worker_dir(&self, id: WorkerId) -> PathBuf {
|
||||
@@ -634,6 +632,50 @@ fn observe_process(pid: u32) -> ProcessObservation {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_worker_record(
|
||||
id: WorkerId,
|
||||
bytes: &[u8],
|
||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||
let decode = || -> Result<StandaloneWorkerRecord, serde_json::Error> {
|
||||
let mut snapshot: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
let object = snapshot.as_object_mut().ok_or_else(|| {
|
||||
serde_json::Error::io(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"standalone Worker record must be an object",
|
||||
))
|
||||
})?;
|
||||
let persisted_manifest = object.remove("manifest").ok_or_else(|| {
|
||||
serde_json::Error::io(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"standalone Worker record is missing manifest",
|
||||
))
|
||||
})?;
|
||||
let manifest = manifest::read_persisted_worker_manifest_snapshot(persisted_manifest)?;
|
||||
object.insert("manifest".to_string(), serde_json::to_value(manifest)?);
|
||||
serde_json::from_value(snapshot)
|
||||
};
|
||||
decode().map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
|
||||
}
|
||||
|
||||
fn write_worker_record(
|
||||
writer: &mut impl Write,
|
||||
record: &StandaloneWorkerRecord,
|
||||
) -> Result<(), StandaloneStoreError> {
|
||||
let mut snapshot = serde_json::to_value(record).map_err(StandaloneStoreError::Json)?;
|
||||
let object = snapshot.as_object_mut().ok_or_else(|| {
|
||||
StandaloneStoreError::Json(serde_json::Error::io(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"standalone Worker record must be an object",
|
||||
)))
|
||||
})?;
|
||||
object.insert(
|
||||
"manifest".to_string(),
|
||||
manifest::write_persisted_worker_manifest_snapshot(&record.manifest)
|
||||
.map_err(StandaloneStoreError::Json)?,
|
||||
);
|
||||
serde_json::to_writer_pretty(writer, &snapshot).map_err(StandaloneStoreError::Json)
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -709,7 +751,70 @@ pub enum StandaloneStoreError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LeaseLiveness, ProcessObservation, classify_lease_liveness};
|
||||
use super::*;
|
||||
|
||||
fn test_manifest() -> WorkerManifest {
|
||||
WorkerManifest::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
name = "standalone-test"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
[engine]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "/tmp"
|
||||
permission = "write"
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_record_uses_versioned_manifest_adapter_for_legacy_memory() {
|
||||
let worker_id = "01a05782-d5dd-78f1-b9cd-ce37535bdb9d".parse().unwrap();
|
||||
let manifest = test_manifest();
|
||||
let record = StandaloneWorkerRecord {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
revision: 6,
|
||||
worker_id,
|
||||
worker_name: manifest.worker.name.clone(),
|
||||
storage_key: "standalone-test".to_string(),
|
||||
cwd: StandaloneCwdIdentity {
|
||||
canonical_path: PathBuf::from("/tmp"),
|
||||
device: None,
|
||||
inode: None,
|
||||
},
|
||||
manifest,
|
||||
active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(),
|
||||
active_segment_id: None,
|
||||
status: StandaloneWorkerStatus::Stopped,
|
||||
created_at_unix_ms: 1,
|
||||
updated_at_unix_ms: 2,
|
||||
shutdown_reason: None,
|
||||
};
|
||||
let mut legacy = serde_json::to_value(&record).unwrap();
|
||||
legacy["manifest"]["feature"]["memory"] = serde_json::json!({
|
||||
"enabled": false,
|
||||
"staging": false,
|
||||
});
|
||||
|
||||
let decoded =
|
||||
decode_worker_record(worker_id, &serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
assert!(!decoded.manifest.feature.memory.profile.enabled);
|
||||
|
||||
let mut persisted = Vec::new();
|
||||
write_worker_record(&mut persisted, &decoded).unwrap();
|
||||
let persisted: serde_json::Value = serde_json::from_slice(&persisted).unwrap();
|
||||
assert_eq!(persisted["manifest"]["schema_version"], 2);
|
||||
assert_eq!(
|
||||
persisted["manifest"]["manifest"]["feature"]["memory"]["profile"]["enabled"],
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lease_liveness_requires_positive_live_or_stale_evidence() {
|
||||
|
||||
@@ -300,11 +300,13 @@ mod tests {
|
||||
target: root.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
ScopeRule {
|
||||
target: output.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
],
|
||||
deny: Vec::new(),
|
||||
|
||||
@@ -298,7 +298,12 @@ mod tests {
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("modified externally"), "{msg}");
|
||||
match err {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ impl From<ToolsError> for ToolError {
|
||||
| workdir::WorkdirError::Io { .. }
|
||||
| workdir::WorkdirError::Unavailable(_)
|
||||
| workdir::WorkdirError::OperationFailed
|
||||
| workdir::WorkdirError::Transport(_),
|
||||
| workdir::WorkdirError::Transport(_)
|
||||
| workdir::WorkdirError::Conflict(_),
|
||||
) => ToolError::ExecutionFailed(err.to_string()),
|
||||
ToolsError::FileSystem(_)
|
||||
| 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,8 +219,13 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("modified externally"), "{msg}");
|
||||
match err {
|
||||
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]
|
||||
|
||||
@@ -40,6 +40,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
||||
target: spill.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
});
|
||||
let scope = Scope::from_config(&config).unwrap();
|
||||
let fs: WorkdirSessionHandle =
|
||||
|
||||
@@ -27,6 +27,7 @@ fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
||||
target: spill.to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
});
|
||||
Scope::from_config(&config).unwrap()
|
||||
}
|
||||
|
||||
+134
-50
@@ -277,6 +277,9 @@ pub struct App {
|
||||
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
|
||||
/// replayable conversation rows during segment rotation.
|
||||
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.
|
||||
/// They are rendered in separate selectable views and never mixed into `blocks`.
|
||||
pub internal_workers: Vec<InternalWorkerView>,
|
||||
@@ -339,7 +342,7 @@ impl App {
|
||||
Self {
|
||||
worker_name,
|
||||
connected: false,
|
||||
worker_state: WorkerStateSnapshot::initial(1),
|
||||
worker_state: WorkerStateSnapshot::initial(),
|
||||
next_command_id: 1,
|
||||
worker_status: WorkerStatus::Idle,
|
||||
running: false,
|
||||
@@ -364,6 +367,8 @@ impl App {
|
||||
quit_confirm: None,
|
||||
shutdown_confirm: None,
|
||||
blocks: Vec::new(),
|
||||
active_compaction: None,
|
||||
compaction_progress: None,
|
||||
run_error_messages: Vec::new(),
|
||||
internal_workers: Vec::new(),
|
||||
selected_internal_worker_session_id: None,
|
||||
@@ -1123,25 +1128,14 @@ impl App {
|
||||
let command_id = self
|
||||
.next_command_id
|
||||
.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);
|
||||
command
|
||||
}
|
||||
|
||||
fn apply_worker_state_snapshot(&mut self, snapshot: &WorkerStateSnapshot) {
|
||||
match protocol::apply_worker_state_snapshot(&mut self.worker_state, snapshot) {
|
||||
Ok(protocol::WorkerStateSnapshotApply::Applied) => {
|
||||
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}"),
|
||||
),
|
||||
}
|
||||
self.worker_state = snapshot.clone();
|
||||
self.set_worker_status(self.worker_state.catalog_status());
|
||||
}
|
||||
|
||||
pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
|
||||
@@ -1397,14 +1391,50 @@ impl App {
|
||||
self.reset_run_state();
|
||||
}
|
||||
}
|
||||
Event::CompactStart { .. } => {
|
||||
if self.last_streaming_compact_mut().is_none() {
|
||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||
started_at: Instant::now(),
|
||||
}));
|
||||
Event::CompactionProgress { compaction } => {
|
||||
self.compaction_progress = compaction.filter(|progress| {
|
||||
matches!(
|
||||
(&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 } => {
|
||||
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;
|
||||
let new_segment_id = lifecycle
|
||||
.new_segment_id
|
||||
@@ -1430,6 +1460,16 @@ impl App {
|
||||
}
|
||||
}
|
||||
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
|
||||
.error
|
||||
.unwrap_or_else(|| "compaction failed".to_string());
|
||||
@@ -1470,9 +1510,9 @@ impl App {
|
||||
} => {
|
||||
self.rewind_refresh_fence = false;
|
||||
self.pending_submissions = session.pending_submissions.clone();
|
||||
self.apply_worker_state_snapshot(&state);
|
||||
self.restore_snapshot(&session, greeting, in_flight);
|
||||
self.replace_internal_worker_snapshots(internal_workers);
|
||||
self.apply_worker_state_snapshot(&state);
|
||||
}
|
||||
Event::InternalWorker {
|
||||
worker,
|
||||
@@ -1485,9 +1525,19 @@ impl App {
|
||||
Event::WorkerState { snapshot } => {
|
||||
self.rewind_refresh_fence = false;
|
||||
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 } => {
|
||||
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
|
||||
// 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) {
|
||||
let compaction = snapshot.compaction;
|
||||
for block in snapshot.blocks {
|
||||
match block {
|
||||
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) {
|
||||
@@ -3587,8 +3640,6 @@ mod completion_flow_tests {
|
||||
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
||||
|
||||
let running = WorkerStateSnapshot {
|
||||
execution_generation: 1,
|
||||
revision: 1,
|
||||
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||
protocol::WorkerRunState::Running,
|
||||
)),
|
||||
@@ -3605,11 +3656,9 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[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 running = WorkerStateSnapshot {
|
||||
execution_generation: 4,
|
||||
revision: 3,
|
||||
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||
protocol::WorkerRunState::Running,
|
||||
)),
|
||||
@@ -3618,22 +3667,22 @@ mod completion_flow_tests {
|
||||
app.handle_worker_event(Event::WorkerState {
|
||||
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);
|
||||
|
||||
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 {
|
||||
revision: 4,
|
||||
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||
protocol::WorkerRunState::Paused,
|
||||
)),
|
||||
last_command_id: 3,
|
||||
..running.clone()
|
||||
};
|
||||
app.handle_worker_event(Event::CommandAcknowledged {
|
||||
acknowledgement: protocol::WorkerCommandAcknowledgement {
|
||||
@@ -3644,17 +3693,6 @@ mod completion_flow_tests {
|
||||
},
|
||||
});
|
||||
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]
|
||||
@@ -3773,6 +3811,7 @@ mod completion_flow_tests {
|
||||
},
|
||||
],
|
||||
commands: Vec::new(),
|
||||
compaction: None,
|
||||
},
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
@@ -4222,6 +4261,7 @@ mod completion_flow_tests {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||
lifecycle.revision = 2;
|
||||
lifecycle.new_segment_id = Some(id.to_string());
|
||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||
|
||||
@@ -4243,6 +4283,7 @@ mod completion_flow_tests {
|
||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||
});
|
||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
|
||||
lifecycle.revision = 2;
|
||||
lifecycle.error = Some("provider 429".into());
|
||||
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]
|
||||
fn shutdown_marks_live_compact_incomplete() {
|
||||
let mut app = App::new("test".into());
|
||||
@@ -4282,10 +4369,7 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
fn test_worker_state(status: WorkerStatus) -> WorkerStateSnapshot {
|
||||
let mut snapshot = WorkerStateSnapshot::from(status);
|
||||
snapshot.execution_generation = 1;
|
||||
snapshot.revision = 1;
|
||||
snapshot
|
||||
WorkerStateSnapshot::from(status)
|
||||
}
|
||||
|
||||
fn test_greeting() -> protocol::Greeting {
|
||||
|
||||
@@ -3,8 +3,9 @@ use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{
|
||||
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers,
|
||||
list_backend_workers, restore_backend_worker,
|
||||
BackendRuntimeListTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
||||
BackendWorkerSummary, list_backend_stopped_workers, list_backend_workers,
|
||||
restore_backend_worker,
|
||||
};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::Frame;
|
||||
@@ -84,17 +85,20 @@ pub(crate) async fn run(
|
||||
let restore_target = target
|
||||
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
restore_backend_worker(&restore_target)
|
||||
let restore = restore_backend_worker(&restore_target)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"failed to restore Backend worker {}/{}: {error}",
|
||||
selected.runtime_id, selected.worker_id
|
||||
))
|
||||
})?
|
||||
.result
|
||||
.worker
|
||||
.unwrap_or(selected)
|
||||
})?;
|
||||
restored_worker(restore).map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"failed to restore Backend worker {}/{}: {error}",
|
||||
selected.runtime_id, selected.worker_id
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
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>) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
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 {
|
||||
use super::*;
|
||||
use client::{
|
||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||
BackendDiagnostic, BackendDiagnosticSeverity, BackendWorkerCapabilitySummary,
|
||||
BackendWorkerImplementationSummary, BackendWorkerRestoreResult,
|
||||
BackendWorkerWorkspaceSummary,
|
||||
};
|
||||
|
||||
@@ -427,8 +459,6 @@ mod tests {
|
||||
},
|
||||
state: "idle".to_string(),
|
||||
worker_state: Some(protocol::WorkerStateSnapshot {
|
||||
execution_generation: 1,
|
||||
revision: 1,
|
||||
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||
protocol::WorkerRunState::Running,
|
||||
)),
|
||||
@@ -463,6 +493,67 @@ mod tests {
|
||||
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]
|
||||
fn worker_row_orders_and_simplifies_columns() {
|
||||
let mut worker = worker("runtime-a", "worker-b", Some("builtin:coder"));
|
||||
@@ -475,7 +566,7 @@ mod tests {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"working_directory_id": "001a06a9f0202000000",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"materializer_kind": "runtime_git_clone",
|
||||
"status": "active",
|
||||
"cleanliness": "clean"
|
||||
}))
|
||||
@@ -500,8 +591,6 @@ mod tests {
|
||||
short.display_name = short.label.clone();
|
||||
short.state = "idle".to_string();
|
||||
short.worker_state = Some(protocol::WorkerStateSnapshot {
|
||||
execution_generation: 1,
|
||||
revision: 2,
|
||||
state: protocol::WorkerState::Idle,
|
||||
last_command_id: 0,
|
||||
});
|
||||
@@ -518,7 +607,7 @@ mod tests {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"working_directory_id": "workdir-1",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"materializer_kind": "runtime_git_clone",
|
||||
"status": "active"
|
||||
}))
|
||||
.unwrap(),
|
||||
|
||||
@@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
||||
println!("Repository path/URI is required.");
|
||||
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 operation_key = format!(
|
||||
"tui-workspace-create-{}-{}",
|
||||
@@ -204,11 +204,11 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
||||
display_name,
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
uri,
|
||||
display_name: Some(if repository_name.is_empty() {
|
||||
"Main".to_string()
|
||||
repository_key: if repository_key.is_empty() {
|
||||
"main".to_string()
|
||||
} else {
|
||||
repository_name
|
||||
}),
|
||||
repository_key
|
||||
},
|
||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -410,10 +410,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.args.raw();
|
||||
CommandExecution {
|
||||
method: Some(Method::Compact {
|
||||
command: protocol::WorkerCommandEnvelope::for_snapshot(
|
||||
0,
|
||||
&protocol::WorkerStateSnapshot::initial(1),
|
||||
),
|
||||
command: protocol::WorkerCommandEnvelope::new(0),
|
||||
}),
|
||||
diagnostics: vec![CommandDiagnostic::new("compact requested")],
|
||||
exit_command_mode: true,
|
||||
|
||||
+28
-6
@@ -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> {
|
||||
let elapsed = app
|
||||
.run_started_at
|
||||
.and_then(|started_at| now.checked_duration_since(started_at))
|
||||
.unwrap_or_default();
|
||||
let elapsed = if let Some(progress) = &app.compaction_progress {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.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 =
|
||||
((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len();
|
||||
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)
|
||||
};
|
||||
|
||||
Line::from(vec![
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
RUN_SPINNER_FRAMES[spinner_index],
|
||||
Style::default()
|
||||
@@ -159,6 +166,20 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
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(
|
||||
fmt_run_elapsed(elapsed.as_secs()),
|
||||
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),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
])
|
||||
]);
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn fmt_run_elapsed(secs: u64) -> String {
|
||||
|
||||
@@ -14,6 +14,7 @@ fs-operation.workspace = true
|
||||
manifest.workspace = true
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
+85
-11
@@ -11,7 +11,8 @@ use crate::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
||||
WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
|
||||
WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
||||
@@ -55,6 +56,8 @@ pub struct OpenWorkdirSessionResponse {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
|
||||
pub enum WorkdirSessionOperation {
|
||||
AuthorizeScope(WorkdirScopeAuthorizationRequest),
|
||||
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
||||
Stat(StatRequest),
|
||||
Read(ReadRequest),
|
||||
Write(WriteRequest),
|
||||
@@ -79,6 +82,8 @@ pub struct WorkdirSessionOperationRequest {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
|
||||
pub enum WorkdirSessionOperationResult {
|
||||
AuthorizeScope,
|
||||
ScopeRulesOverlap { overlaps: bool },
|
||||
Stat(StatResult),
|
||||
Read(ReadResult),
|
||||
Write(WriteResult),
|
||||
@@ -169,7 +174,10 @@ impl WorkdirTransportError {
|
||||
use WorkdirTransportErrorCode as Code;
|
||||
let (code, message) = match error {
|
||||
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) => {
|
||||
return Self {
|
||||
code: Code::Unsupported,
|
||||
@@ -293,7 +301,12 @@ mod client {
|
||||
/// implementations can mint short-lived capability tokens without making a
|
||||
/// Worker-bound session expire with the token used to open it.
|
||||
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
|
||||
fn bearer_token(&self) -> Result<String, WorkdirError>;
|
||||
fn bearer_token(
|
||||
&self,
|
||||
method: &str,
|
||||
path_and_query: &str,
|
||||
body: &[u8],
|
||||
) -> Result<String, WorkdirError>;
|
||||
}
|
||||
|
||||
struct FixedBearerToken(Arc<str>);
|
||||
@@ -305,7 +318,12 @@ mod client {
|
||||
}
|
||||
|
||||
impl WorkdirHttpAuthorization for FixedBearerToken {
|
||||
fn bearer_token(&self) -> Result<String, WorkdirError> {
|
||||
fn bearer_token(
|
||||
&self,
|
||||
_method: &str,
|
||||
_path_and_query: &str,
|
||||
_body: &[u8],
|
||||
) -> Result<String, WorkdirError> {
|
||||
Ok(self.0.to_string())
|
||||
}
|
||||
}
|
||||
@@ -354,10 +372,14 @@ mod client {
|
||||
&base_url,
|
||||
&["v1", "working-directories", workdir_id.as_str(), "sessions"],
|
||||
)?;
|
||||
let body = serde_json::to_vec(&request)
|
||||
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
|
||||
let token = authorization.bearer_token("POST", url.path(), &body)?;
|
||||
let response = client
|
||||
.post(url)
|
||||
.bearer_auth(authorization.bearer_token()?)
|
||||
.json(&request)
|
||||
.bearer_auth(token)
|
||||
.header("content-type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(http_unavailable)?;
|
||||
@@ -401,11 +423,15 @@ mod client {
|
||||
],
|
||||
)?;
|
||||
let operation = WorkdirSessionOperationRequest { operation };
|
||||
let body = serde_json::to_vec(&operation)
|
||||
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
|
||||
let token = self.authorization.bearer_token("POST", url.path(), &body)?;
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
.bearer_auth(self.authorization.bearer_token()?)
|
||||
.json(&operation)
|
||||
.bearer_auth(token)
|
||||
.header("content-type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(http_unavailable)?;
|
||||
@@ -429,6 +455,32 @@ mod client {
|
||||
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> {
|
||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||
@@ -543,10 +595,11 @@ mod client {
|
||||
&self.base_url,
|
||||
&["v1", "workdir-sessions", self.session_id.as_str()],
|
||||
)?;
|
||||
let token = self.authorization.bearer_token("DELETE", url.path(), &[])?;
|
||||
let response = self
|
||||
.client
|
||||
.delete(url)
|
||||
.bearer_auth(self.authorization.bearer_token()?)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(http_unavailable)?;
|
||||
@@ -623,7 +676,7 @@ mod tests {
|
||||
(
|
||||
WorkdirTransportErrorCode::Conflict,
|
||||
409,
|
||||
"modified externally",
|
||||
"The target file's content or existence changed since it was last observed",
|
||||
),
|
||||
(WorkdirTransportErrorCode::Unsupported, 400, "unsupported"),
|
||||
(WorkdirTransportErrorCode::Denied, 403, "denied"),
|
||||
@@ -665,7 +718,12 @@ mod tests {
|
||||
] {
|
||||
let transport = WorkdirTransportError {
|
||||
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);
|
||||
let workdir_error = transport.clone().into_workdir_error();
|
||||
@@ -732,5 +790,21 @@ mod tests {
|
||||
transport.into_workdir_error(),
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ pub use local::{
|
||||
};
|
||||
pub use operation::*;
|
||||
pub use scope::{
|
||||
ReadOnlyWorkdirSession, WorkdirScopeLease, WorkdirToolBroker, WorkdirToolScope,
|
||||
WorkdirToolScopePermission, WorkdirToolScopeRule,
|
||||
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease,
|
||||
WorkdirScopeOverlapRequest, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
|
||||
WorkdirToolScopeRule,
|
||||
};
|
||||
|
||||
/// Persistent, opaque identity of one materialized Workdir.
|
||||
@@ -147,6 +148,34 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
fn workdir(&self) -> &Workdir;
|
||||
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 read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
|
||||
@@ -206,7 +235,7 @@ pub enum WorkdirError {
|
||||
#[error("Workdir transport failed: {0}")]
|
||||
Transport(String),
|
||||
|
||||
#[error("Workdir content was modified externally before the operation could be applied: {0}")]
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("unknown Workdir session command: {0}")]
|
||||
@@ -320,7 +349,9 @@ impl From<fs_operation::FsError> for WorkdirError {
|
||||
fs_operation::FsError::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::InvalidRegex(message) => Self::InvalidRegex(message),
|
||||
fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message),
|
||||
|
||||
+381
-30
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use manifest::{Scope, SharedScope};
|
||||
use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{Mutex, broadcast, watch};
|
||||
@@ -28,8 +28,10 @@ use crate::{
|
||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
||||
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WriteRequest, WriteResult,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
|
||||
WriteResult,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::{EntryKind, WriteOutcome};
|
||||
@@ -211,6 +213,52 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
fn is_writable(&self, path: &Path) -> bool {
|
||||
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)]
|
||||
@@ -397,6 +445,11 @@ impl LocalWorkdirSession {
|
||||
return Err(WorkdirError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
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();
|
||||
if !scope.is_readable(path) {
|
||||
return Err(symlink_out_of_scope_or_plain(
|
||||
@@ -406,11 +459,6 @@ impl LocalWorkdirSession {
|
||||
&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() {
|
||||
std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()),
|
||||
_ => WorkdirError::io(path, e),
|
||||
@@ -556,6 +604,86 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
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> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||
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]
|
||||
async fn logical_provider_operations_cover_read_write_edit_stat_and_list() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1425,7 +1569,10 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.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();
|
||||
let error = WorkdirSession::write(
|
||||
@@ -1438,7 +1585,38 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.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]
|
||||
@@ -1533,6 +1711,102 @@ mod tests {
|
||||
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]
|
||||
async fn capability_boundary_rejects_direct_unsupported_operation() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1635,7 +1909,7 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_bytes_reports_symlink_target_outside_scope() {
|
||||
fn read_bytes_allows_logical_symlink_path_with_target_outside_scope() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1645,16 +1919,8 @@ mod tests {
|
||||
let link = dir.path().join("outside-repo.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(&link).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
|
||||
if path == &link && err_target == &target.canonicalize().unwrap()
|
||||
),
|
||||
"expected symlink out-of-scope diagnostic, got {err:?}"
|
||||
);
|
||||
let fs = make_logical_fs(&dir);
|
||||
assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -1746,7 +2012,7 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_reports_symlink_target_outside_scope() {
|
||||
fn write_allows_logical_symlink_path_with_target_outside_scope() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1756,15 +2022,14 @@ mod tests {
|
||||
let link = dir.path().join("outside-repo.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(&link, b"new").unwrap_err();
|
||||
let fs = make_logical_fs(&dir);
|
||||
fs.write(&link, b"new").unwrap();
|
||||
assert_eq!(fs::read(&target).unwrap(), b"new");
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" }
|
||||
if path == &link && err_target == &target.canonicalize().unwrap()
|
||||
),
|
||||
"expected write symlink out-of-scope diagnostic, got {err:?}"
|
||||
fs::symlink_metadata(&link)
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1787,11 +2052,13 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
@@ -1855,6 +2122,7 @@ mod tests {
|
||||
target: extra.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1891,6 +2159,7 @@ mod tests {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1927,6 +2196,7 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1942,6 +2212,83 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn provider_uses_explicit_logical_policy_through_symlinked_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(outside.path().join("worker.json"), "scope-needle\n").unwrap();
|
||||
symlink(outside.path(), dir.path().join("yoi.local")).unwrap();
|
||||
let workdir = make_logical_fs(&dir);
|
||||
|
||||
let read = WorkdirSession::read(
|
||||
&workdir,
|
||||
ReadRequest {
|
||||
path: WorkdirPath::new("yoi.local/worker.json").unwrap(),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
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(
|
||||
&workdir,
|
||||
GlobRequest {
|
||||
pattern: "**/*.json".into(),
|
||||
path: WorkdirPath::new("yoi.local").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
glob.paths,
|
||||
[WorkdirPath::new("yoi.local/worker.json").unwrap()]
|
||||
);
|
||||
let grep = WorkdirSession::grep(
|
||||
&workdir,
|
||||
GrepRequest {
|
||||
pattern: "scope-needle".into(),
|
||||
path: WorkdirPath::new("yoi.local").unwrap(),
|
||||
glob: Some("*.json".into()),
|
||||
file_type: None,
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
multiline: false,
|
||||
output_mode: crate::GrepOutputMode::Content,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(grep.match_count, 1);
|
||||
assert!(grep.output.contains("yoi.local/worker.json"));
|
||||
assert!(
|
||||
!workdir
|
||||
.scope()
|
||||
.is_readable(&outside.path().join("worker.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_executes_glob_grep_and_command_at_the_materialization() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -2029,11 +2376,13 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
ScopeRule {
|
||||
target: spill.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
],
|
||||
deny: Vec::new(),
|
||||
@@ -2110,11 +2459,13 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
ScopeRule {
|
||||
target: spill.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
],
|
||||
deny: Vec::new(),
|
||||
|
||||
+532
-139
@@ -8,6 +8,7 @@ use fs_operation::{
|
||||
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
|
||||
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
use manifest::SymlinkPolicy;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
const MAX_SCOPED_COMMANDS: usize = 16;
|
||||
@@ -31,6 +32,25 @@ pub struct WorkdirToolScopeRule {
|
||||
pub target: FsPath,
|
||||
pub permission: WorkdirToolScopePermission,
|
||||
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)]
|
||||
@@ -70,6 +90,7 @@ impl WorkdirToolBroker {
|
||||
capabilities,
|
||||
validity: SessionValidity::root(),
|
||||
child_write_leases: Mutex::new(HashMap::new()),
|
||||
scope_lock: tokio::sync::Mutex::new(()),
|
||||
next_lease_id: AtomicU64::new(1),
|
||||
close_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
owned_commands: Arc::new(Mutex::new(HashSet::new())),
|
||||
@@ -310,6 +331,7 @@ struct ScopedWorkdirSession {
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
validity: Arc<SessionValidity>,
|
||||
child_write_leases: Mutex<HashMap<u64, ActiveWriteLease>>,
|
||||
scope_lock: tokio::sync::Mutex<()>,
|
||||
next_lease_id: AtomicU64,
|
||||
close_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
owned_commands: Arc<Mutex<HashSet<String>>>,
|
||||
@@ -374,9 +396,6 @@ impl ScopedWorkdirSession {
|
||||
)));
|
||||
}
|
||||
}
|
||||
if permission == WorkdirToolScopePermission::Write {
|
||||
self.ensure_parent_write_available(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -463,78 +482,84 @@ impl ScopedWorkdirSession {
|
||||
});
|
||||
}
|
||||
|
||||
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
||||
let mut leases = self
|
||||
.child_write_leases
|
||||
.lock()
|
||||
.expect("Workdir tool scope lease mutex poisoned");
|
||||
leases.retain(|_, lease| {
|
||||
lease
|
||||
.validity
|
||||
.upgrade()
|
||||
.is_some_and(|validity| validity.is_active())
|
||||
|| lease
|
||||
.cleanup_pending
|
||||
async fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
||||
let active_write_rules = {
|
||||
let mut leases = self
|
||||
.child_write_leases
|
||||
.lock()
|
||||
.expect("Workdir tool scope lease mutex poisoned");
|
||||
leases.retain(|_, lease| {
|
||||
lease
|
||||
.validity
|
||||
.upgrade()
|
||||
.is_some_and(|pending| pending.load(Ordering::Acquire))
|
||||
});
|
||||
if leases.values().any(|lease| {
|
||||
lease.rules.iter().any(|rule| {
|
||||
rule.permission == WorkdirToolScopePermission::Write
|
||||
&& rule_allows_path(rule, path, WorkdirToolScopePermission::Write)
|
||||
})
|
||||
}) {
|
||||
Err(WorkdirError::Denied(format!(
|
||||
"logical workdir path `{path}` is leased to child Workdir tools"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_source_path_has_no_symlink(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
||||
let mut current = String::new();
|
||||
for component in Path::new(path.as_str()).components() {
|
||||
let component = component.as_os_str().to_string_lossy();
|
||||
if component.is_empty() || component == "." {
|
||||
continue;
|
||||
}
|
||||
if !current.is_empty() {
|
||||
current.push('/');
|
||||
}
|
||||
current.push_str(&component);
|
||||
let current = FsPath::new(¤t).map_err(|error| {
|
||||
WorkdirError::Denied(format!("invalid scoped Workdir path: {error}"))
|
||||
})?;
|
||||
match self.source.stat(StatRequest { path: current }).await {
|
||||
Ok(result) if result.kind == fs_operation::EntryKind::Symlink => {
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"scoped Workdir path `{path}` traverses a symlink"
|
||||
)));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(WorkdirError::NotFound(_)) => break,
|
||||
Err(error) => return Err(error),
|
||||
.is_some_and(|validity| validity.is_active())
|
||||
|| lease
|
||||
.cleanup_pending
|
||||
.upgrade()
|
||||
.is_some_and(|pending| pending.load(Ordering::Acquire))
|
||||
});
|
||||
leases
|
||||
.values()
|
||||
.flat_map(|lease| lease.rules.iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let requested = WorkdirToolScopeRule {
|
||||
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(())
|
||||
}
|
||||
|
||||
async fn ensure_scope_targets_do_not_traverse_symlinks(
|
||||
async fn ensure_scope_targets_are_authorized(
|
||||
&self,
|
||||
rules: &[WorkdirToolScopeRule],
|
||||
) -> Result<(), WorkdirError> {
|
||||
for rule in rules {
|
||||
self.ensure_source_path_has_no_symlink(&rule.target).await?;
|
||||
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) -> Result<FsPath, WorkdirError> {
|
||||
async fn resolve_operation_path(
|
||||
&self,
|
||||
path: &FsPath,
|
||||
permission: WorkdirToolScopePermission,
|
||||
) -> Result<FsPath, WorkdirError> {
|
||||
self.ensure_active()?;
|
||||
let resolved = self.resolve_path(path)?;
|
||||
if self.scope.is_some() {
|
||||
self.ensure_source_path_has_no_symlink(&resolved).await?;
|
||||
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)
|
||||
}
|
||||
@@ -613,6 +638,7 @@ impl ScopedWorkdirSession {
|
||||
self: &Arc<Self>,
|
||||
request: WorkdirToolScope,
|
||||
) -> Result<WorkdirScopeLease, WorkdirError> {
|
||||
let _scope_guard = self.scope_lock.lock().await;
|
||||
let capabilities = self.validate_scope(&request.rules, request.command)?;
|
||||
if !request
|
||||
.rules
|
||||
@@ -624,55 +650,66 @@ impl ScopedWorkdirSession {
|
||||
request.cwd
|
||||
)));
|
||||
}
|
||||
self.ensure_scope_targets_do_not_traverse_symlinks(&request.rules)
|
||||
self.ensure_scope_targets_are_authorized(&request.rules)
|
||||
.await?;
|
||||
let validity = SessionValidity::child(self.validity.clone());
|
||||
let cleanup_pending = Arc::new(AtomicBool::new(true));
|
||||
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
|
||||
if request
|
||||
let write_rules = request
|
||||
.rules
|
||||
.iter()
|
||||
.any(|rule| rule.permission == WorkdirToolScopePermission::Write)
|
||||
{
|
||||
let mut leases = self
|
||||
.child_write_leases
|
||||
.lock()
|
||||
.expect("Workdir tool scope lease mutex poisoned");
|
||||
leases.retain(|_, lease| {
|
||||
lease
|
||||
.validity
|
||||
.upgrade()
|
||||
.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| {
|
||||
.filter(|rule| rule.permission == WorkdirToolScopePermission::Write)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !write_rules.is_empty() {
|
||||
let active_write_rules = {
|
||||
let mut leases = self
|
||||
.child_write_leases
|
||||
.lock()
|
||||
.expect("Workdir tool scope lease mutex poisoned");
|
||||
leases.retain(|_, lease| {
|
||||
lease
|
||||
.rules
|
||||
.iter()
|
||||
.any(|active| rules_overlap(active, requested))
|
||||
}) {
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"scoped write path `{}` overlaps an active child scope",
|
||||
requested.target
|
||||
)));
|
||||
.validity
|
||||
.upgrade()
|
||||
.is_some_and(|validity| validity.is_active())
|
||||
|| lease
|
||||
.cleanup_pending
|
||||
.upgrade()
|
||||
.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(
|
||||
id,
|
||||
ActiveWriteLease {
|
||||
validity: Arc::downgrade(&validity),
|
||||
cleanup_pending: Arc::downgrade(&cleanup_pending),
|
||||
rules: request.rules.clone(),
|
||||
},
|
||||
);
|
||||
self.child_write_leases
|
||||
.lock()
|
||||
.expect("Workdir tool scope lease mutex poisoned")
|
||||
.insert(
|
||||
id,
|
||||
ActiveWriteLease {
|
||||
validity: Arc::downgrade(&validity),
|
||||
cleanup_pending: Arc::downgrade(&cleanup_pending),
|
||||
rules: write_rules,
|
||||
},
|
||||
);
|
||||
}
|
||||
let owned_commands = Arc::new(Mutex::new(HashSet::new()));
|
||||
let pending_command_events = Arc::new(Mutex::new(HashMap::new()));
|
||||
@@ -698,6 +735,7 @@ impl ScopedWorkdirSession {
|
||||
capabilities,
|
||||
validity: validity.clone(),
|
||||
child_write_leases: Mutex::new(HashMap::new()),
|
||||
scope_lock: tokio::sync::Mutex::new(()),
|
||||
next_lease_id: AtomicU64::new(1),
|
||||
close_lock: close_lock.clone(),
|
||||
owned_commands,
|
||||
@@ -736,49 +774,65 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
}
|
||||
|
||||
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path).await?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||
request.path = path;
|
||||
self.source.stat(request).await
|
||||
}
|
||||
|
||||
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path).await?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||
request.path = path;
|
||||
self.source.read(request).await
|
||||
}
|
||||
|
||||
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path).await?;
|
||||
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)?;
|
||||
request.path = path;
|
||||
self.source.write(request).await
|
||||
}
|
||||
|
||||
async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path).await?;
|
||||
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)?;
|
||||
request.path = path;
|
||||
self.source.edit(request).await
|
||||
}
|
||||
|
||||
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path).await?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||
request.path = path;
|
||||
self.source.list(request).await
|
||||
}
|
||||
|
||||
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path).await?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Glob)?;
|
||||
request.path = path;
|
||||
self.source.glob(request).await
|
||||
}
|
||||
|
||||
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
let path = self.resolve_operation_path(&request.path).await?;
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Grep)?;
|
||||
request.path = path;
|
||||
self.source.grep(request).await
|
||||
@@ -987,6 +1041,23 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
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> {
|
||||
self.inner.stat(request).await
|
||||
}
|
||||
@@ -1143,14 +1214,7 @@ fn unix_timestamp_ms() -> u64 {
|
||||
.min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
fn rules_overlap(left: &WorkdirToolScopeRule, right: &WorkdirToolScopeRule) -> bool {
|
||||
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(
|
||||
pub(crate) fn rule_allows_path(
|
||||
rule: &WorkdirToolScopeRule,
|
||||
path: &FsPath,
|
||||
required: WorkdirToolScopePermission,
|
||||
@@ -1182,6 +1246,11 @@ fn rule_contains_rule(parent: &WorkdirToolScopeRule, child: &WorkdirToolScopeRul
|
||||
{
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
@@ -1212,6 +1281,7 @@ mod tests {
|
||||
target: root.to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
@@ -1226,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 {
|
||||
WorkdirToolScope {
|
||||
rules: vec![WorkdirToolScopeRule {
|
||||
target: fs_path(path),
|
||||
permission,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: fs_path(path),
|
||||
command: permission == WorkdirToolScopePermission::Write,
|
||||
@@ -1342,6 +1541,7 @@ mod tests {
|
||||
target: fs_path("work"),
|
||||
permission: WorkdirToolScopePermission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: fs_path("work"),
|
||||
command: false,
|
||||
@@ -1429,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]
|
||||
fn non_recursive_rule_covers_target_and_direct_children_only() {
|
||||
let rule = WorkdirToolScopeRule {
|
||||
target: fs_path("docs"),
|
||||
permission: WorkdirToolScopePermission::Read,
|
||||
recursive: false,
|
||||
symlink_policy: Default::default(),
|
||||
};
|
||||
assert!(path_in_rule(&rule, &fs_path("docs")));
|
||||
assert!(path_in_rule(&rule, &fs_path("docs/readme.md")));
|
||||
@@ -1487,7 +1699,7 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn provider_scope_denies_read_through_symlink_outside_grant() {
|
||||
async fn provider_scope_rejects_symlink_aliases_by_default() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
@@ -1501,60 +1713,241 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = child.read(read("link")).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"symlink read escaped provider scope: {result:?}"
|
||||
);
|
||||
assert!(matches!(
|
||||
child.read(read("link")).await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("provider-resolved delegated scope")
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn provider_scope_denies_write_through_symlink_outside_grant() {
|
||||
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("granted")).unwrap();
|
||||
fs::create_dir_all(root.path().join("target")).unwrap();
|
||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||
symlink("../secret", root.path().join("granted/outside")).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::Write))
|
||||
.scope(request("granted", WorkdirToolScopePermission::Read))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = child.write(write("outside/new", "forbidden")).await;
|
||||
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!(
|
||||
result.is_err(),
|
||||
"symlink write escaped provider scope: {result:?}"
|
||||
tokio::time::timeout(std::time::Duration::from_millis(50), &mut admission)
|
||||
.await
|
||||
.is_err(),
|
||||
"scope admission must wait for the in-flight parent write"
|
||||
);
|
||||
assert!(!root.path().join("secret/new").exists());
|
||||
|
||||
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 write_delegation_rejects_symlink_target_before_lease() {
|
||||
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("granted")).unwrap();
|
||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||
symlink("../secret", root.path().join("granted/outside")).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(
|
||||
"granted/outside",
|
||||
WorkdirToolScopePermission::Write
|
||||
))
|
||||
.scope(request("alias-b", WorkdirToolScopePermission::Write))
|
||||
.await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("overlaps an active child scope after provider resolution")
|
||||
));
|
||||
parent
|
||||
.write(write("secret/parent", "still-authoritative"))
|
||||
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");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn provider_scope_allows_write_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();
|
||||
symlink("../secret", root.path().join("granted/outside")).unwrap();
|
||||
let parent = session(root.path());
|
||||
let mut scope = request("granted", WorkdirToolScopePermission::Write);
|
||||
scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||
let child = parent.scope(scope).await.unwrap();
|
||||
|
||||
child
|
||||
.write(write("outside/new", "through-logical-path"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.path().join("secret/new")).unwrap(),
|
||||
"through-logical-path"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn write_delegation_leases_logical_alias_and_resolved_target() {
|
||||
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();
|
||||
symlink("../secret", root.path().join("granted/outside")).unwrap();
|
||||
let parent = session(root.path());
|
||||
|
||||
let mut scope = request("granted/outside", WorkdirToolScopePermission::Write);
|
||||
scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||
let child = parent.scope(scope).await.unwrap();
|
||||
child
|
||||
.write(write("from-child", "child-authoritative"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
parent
|
||||
.write(write("secret/parent", "must-be-blocked"))
|
||||
.await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("leased to child Workdir tools")
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
|
||||
"child-authoritative"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -18,7 +18,7 @@ required-features = ["ws-server", "fs-store"]
|
||||
|
||||
[features]
|
||||
default = ["ws-server", "fs-store"]
|
||||
fs-store = []
|
||||
fs-store = ["dep:fs4"]
|
||||
http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
|
||||
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
||||
|
||||
@@ -29,6 +29,7 @@ axum = { workspace = true, optional = true }
|
||||
futures = { workspace = true, optional = true }
|
||||
decodal.workspace = true
|
||||
flow = { path = "../flow" }
|
||||
fs4 = { workspace = true, optional = true }
|
||||
manifest.workspace = true
|
||||
protocol.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -39,7 +40,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
|
||||
ring.workspace = true
|
||||
tar.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
||||
tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
@@ -2,6 +2,7 @@ use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
@@ -9,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
|
||||
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
|
||||
const TOKEN_PREFIX: &str = "yoi-cap-v1";
|
||||
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
|
||||
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
|
||||
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
|
||||
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
|
||||
@@ -68,6 +67,74 @@ pub enum RuntimeAuthError {
|
||||
WrongMutationTarget,
|
||||
}
|
||||
|
||||
pub(crate) struct SignedJsonToken<T> {
|
||||
pub payload: String,
|
||||
pub signature: Vec<u8>,
|
||||
pub claims: T,
|
||||
}
|
||||
|
||||
pub(crate) fn sign_json_token<T: Serialize>(
|
||||
token_prefix: &str,
|
||||
signing_input_prefix: &str,
|
||||
signing_key: &Ed25519KeyPair,
|
||||
claims: &T,
|
||||
) -> Result<String, RuntimeAuthError> {
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
|
||||
let signing_input = format!("{signing_input_prefix}{payload}");
|
||||
let signature = signing_key.sign(signing_input.as_bytes());
|
||||
Ok(format!(
|
||||
"{token_prefix}.{payload}.{}",
|
||||
URL_SAFE_NO_PAD.encode(signature.as_ref())
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn decode_signed_json_token<T: DeserializeOwned>(
|
||||
token: &str,
|
||||
expected_prefix: &str,
|
||||
) -> Result<SignedJsonToken<T>, RuntimeAuthError> {
|
||||
let (prefix, payload, signature) = split_three_part_token(token)?;
|
||||
if prefix != expected_prefix {
|
||||
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||
}
|
||||
let signature = URL_SAFE_NO_PAD.decode(signature)?;
|
||||
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
|
||||
Ok(SignedJsonToken {
|
||||
payload: payload.to_string(),
|
||||
signature,
|
||||
claims,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn verify_signed_json_token(
|
||||
signing_input_prefix: &str,
|
||||
payload: &str,
|
||||
signature: &[u8],
|
||||
public_key: &str,
|
||||
) -> Result<(), RuntimeAuthError> {
|
||||
let public_key = decode_public_key(public_key)?;
|
||||
let signing_input = format!("{signing_input_prefix}{payload}");
|
||||
UnparsedPublicKey::new(&ED25519, public_key)
|
||||
.verify(signing_input.as_bytes(), signature)
|
||||
.map_err(|_| RuntimeAuthError::InvalidSignature)
|
||||
}
|
||||
|
||||
fn split_three_part_token(token: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
|
||||
let mut parts = token.split('.');
|
||||
let prefix = parts.next().unwrap_or_default();
|
||||
let payload = parts.next().unwrap_or_default();
|
||||
let signature = parts.next().unwrap_or_default();
|
||||
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
|
||||
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||
}
|
||||
Ok((prefix, payload, signature))
|
||||
}
|
||||
|
||||
pub(crate) fn is_request_body_digest(value: &str) -> bool {
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(value)
|
||||
.is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(decoded) == value)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeIdentityMaterial {
|
||||
pub identity_id: String,
|
||||
@@ -95,21 +162,6 @@ impl RuntimeIdentityMaterial {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TrustedServerKey {
|
||||
pub server_id: String,
|
||||
pub public_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpAuthConfig {
|
||||
pub runtime_id: String,
|
||||
#[serde(default)]
|
||||
pub trusted_servers: Vec<TrustedServerKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeAuthContext {
|
||||
pub server_id: String,
|
||||
@@ -119,122 +171,6 @@ pub struct RuntimeAuthContext {
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CapabilityClaims {
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub workspace_id: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub exp: u64,
|
||||
pub jti: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CapabilityTokenSigner {
|
||||
server_id: String,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
impl CapabilityTokenSigner {
|
||||
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
server_id: server_id.into(),
|
||||
private_key: private_key.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_id(&self) -> &str {
|
||||
&self.server_id
|
||||
}
|
||||
|
||||
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
|
||||
if claims.iss != self.server_id {
|
||||
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
|
||||
}
|
||||
let private = decode_private_key(&self.private_key)?;
|
||||
let pair = Ed25519KeyPair::from_pkcs8(&private)
|
||||
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
||||
let payload = serde_json::to_vec(claims)?;
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload);
|
||||
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
|
||||
let signature = pair.sign(signing_input.as_bytes());
|
||||
Ok(format!(
|
||||
"{TOKEN_PREFIX}.{payload}.{}",
|
||||
URL_SAFE_NO_PAD.encode(signature.as_ref())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capability_claims(
|
||||
server_id: impl Into<String>,
|
||||
runtime_id: impl Into<String>,
|
||||
workspace_id: impl Into<String>,
|
||||
permissions: Vec<String>,
|
||||
ttl_seconds: u64,
|
||||
) -> Result<CapabilityClaims, RuntimeAuthError> {
|
||||
let exp = unix_now_seconds().saturating_add(ttl_seconds);
|
||||
Ok(CapabilityClaims {
|
||||
iss: server_id.into(),
|
||||
aud: runtime_id.into(),
|
||||
workspace_id: workspace_id.into(),
|
||||
permissions,
|
||||
exp,
|
||||
jti: new_token_id()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_capability_token(
|
||||
config: &RuntimeHttpAuthConfig,
|
||||
token: &str,
|
||||
required_permission: Option<&str>,
|
||||
now_seconds: u64,
|
||||
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
|
||||
let (payload, signature) = split_token(token)?;
|
||||
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
|
||||
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
|
||||
let Some(server) = config
|
||||
.trusted_servers
|
||||
.iter()
|
||||
.find(|server| server.server_id == claims.iss)
|
||||
else {
|
||||
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
|
||||
};
|
||||
let public_key = decode_public_key(&server.public_key)?;
|
||||
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
|
||||
UnparsedPublicKey::new(&ED25519, public_key)
|
||||
.verify(signing_input.as_bytes(), &signature)
|
||||
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
|
||||
|
||||
if claims.aud != config.runtime_id {
|
||||
return Err(RuntimeAuthError::WrongAudience {
|
||||
expected: config.runtime_id.clone(),
|
||||
actual: claims.aud,
|
||||
});
|
||||
}
|
||||
if claims.exp < now_seconds {
|
||||
return Err(RuntimeAuthError::Expired);
|
||||
}
|
||||
if claims.workspace_id.trim().is_empty() {
|
||||
return Err(RuntimeAuthError::MissingWorkspaceScope);
|
||||
}
|
||||
if let Some(required) = required_permission {
|
||||
if !claims
|
||||
.permissions
|
||||
.iter()
|
||||
.any(|permission| permission == required)
|
||||
{
|
||||
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(RuntimeAuthContext {
|
||||
server_id: claims.iss,
|
||||
workspace_id: claims.workspace_id,
|
||||
permissions: claims.permissions,
|
||||
token_id: claims.jti,
|
||||
expires_at: claims.exp,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeRequestSourceClaims {
|
||||
pub iss: String,
|
||||
@@ -323,28 +259,22 @@ impl RuntimeRequestSourceSigner {
|
||||
exp: now_unix.saturating_add(ttl_seconds),
|
||||
jti: new_token_id()?,
|
||||
};
|
||||
let payload = serde_json::to_vec(&claims)?;
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload);
|
||||
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
|
||||
let private = decode_private_key(&self.private_key)?;
|
||||
let key_pair = Ed25519KeyPair::from_pkcs8(&private)
|
||||
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
||||
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref());
|
||||
Ok(format!(
|
||||
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}"
|
||||
))
|
||||
sign_json_token(
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
|
||||
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
|
||||
&key_pair,
|
||||
&claims,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_runtime_request_source_claims(
|
||||
proof: &str,
|
||||
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
|
||||
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?;
|
||||
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
|
||||
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||
}
|
||||
let payload = URL_SAFE_NO_PAD.decode(payload)?;
|
||||
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
|
||||
Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims)
|
||||
}
|
||||
|
||||
pub fn verify_runtime_request_source(
|
||||
@@ -352,17 +282,17 @@ pub fn verify_runtime_request_source(
|
||||
public_key: &str,
|
||||
expected: &RuntimeRequestSourceExpectation<'_>,
|
||||
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
|
||||
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?;
|
||||
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
|
||||
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||
}
|
||||
let signature = URL_SAFE_NO_PAD.decode(signature)?;
|
||||
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
|
||||
let public_key = decode_public_key(public_key)?;
|
||||
UnparsedPublicKey::new(&ED25519, public_key)
|
||||
.verify(signing_input.as_bytes(), &signature)
|
||||
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
|
||||
let claims = decode_runtime_request_source_claims(proof)?;
|
||||
let signed = decode_signed_json_token::<RuntimeRequestSourceClaims>(
|
||||
proof,
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
|
||||
)?;
|
||||
verify_signed_json_token(
|
||||
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
|
||||
&signed.payload,
|
||||
&signed.signature,
|
||||
public_key,
|
||||
)?;
|
||||
let claims = signed.claims;
|
||||
if claims.iss != expected.identity_id
|
||||
|| claims.aud != expected.audience
|
||||
|| claims.workspace_id != expected.workspace_id
|
||||
@@ -380,17 +310,6 @@ pub fn verify_runtime_request_source(
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
|
||||
let mut parts = proof.split('.');
|
||||
let prefix = parts.next().unwrap_or_default();
|
||||
let payload = parts.next().unwrap_or_default();
|
||||
let signature = parts.next().unwrap_or_default();
|
||||
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
|
||||
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||
}
|
||||
Ok((prefix, payload, signature))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerMutationSourceClaims {
|
||||
pub iss: String,
|
||||
@@ -586,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), Ru
|
||||
}
|
||||
}
|
||||
|
||||
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
|
||||
let mut parts = token.split('.');
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
|
||||
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
|
||||
}
|
||||
_ => Err(RuntimeAuthError::InvalidTokenFormat),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_public_key(bytes: &[u8]) -> String {
|
||||
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
@@ -851,46 +760,4 @@ mod tests {
|
||||
Err(RuntimeAuthError::Expired)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_token_verifies_signature_audience_expiry_and_permission() {
|
||||
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
|
||||
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
|
||||
let claims = CapabilityClaims {
|
||||
iss: "server-main".to_string(),
|
||||
aud: "runtime-main".to_string(),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
permissions: vec!["workers:list".to_string()],
|
||||
exp: 100,
|
||||
jti: "token-1".to_string(),
|
||||
};
|
||||
let token = signer.sign(&claims).unwrap();
|
||||
let auth = RuntimeHttpAuthConfig {
|
||||
runtime_id: "runtime-main".to_string(),
|
||||
trusted_servers: vec![TrustedServerKey {
|
||||
server_id: "server-main".to_string(),
|
||||
public_key: server.public_key.clone(),
|
||||
display_name: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
|
||||
assert_eq!(context.workspace_id, "workspace-a");
|
||||
assert!(matches!(
|
||||
verify_capability_token(&auth, &token, Some("workers:create"), 99),
|
||||
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
|
||||
));
|
||||
assert!(matches!(
|
||||
verify_capability_token(&auth, &token, Some("workers:list"), 101),
|
||||
Err(RuntimeAuthError::Expired)
|
||||
));
|
||||
let wrong_audience = RuntimeHttpAuthConfig {
|
||||
runtime_id: "other-runtime".to_string(),
|
||||
trusted_servers: auth.trusted_servers.clone(),
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
|
||||
Err(RuntimeAuthError::WrongAudience { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +119,16 @@ impl std::fmt::Debug for SensitiveString {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySshMaterializationAccess {
|
||||
pub struct RepositorySshCredentialCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
#[serde(skip, default)]
|
||||
pub private_key: SensitiveString,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySshMaterializationAccess {
|
||||
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
|
||||
pub host_trust_id: String,
|
||||
pub host_trust_revision: u64,
|
||||
pub access: workspace_api::RepositoryAccessMode,
|
||||
@@ -131,8 +138,6 @@ pub struct RepositorySshMaterializationAccess {
|
||||
pub repository_uri: String,
|
||||
pub secret_resource: crate::resource::BackendResourceHandle,
|
||||
#[serde(skip, default)]
|
||||
pub private_key: SensitiveString,
|
||||
#[serde(skip, default)]
|
||||
pub known_hosts_entry: SensitiveString,
|
||||
}
|
||||
|
||||
@@ -143,8 +148,6 @@ pub struct RepositoryMaterializationContext {
|
||||
pub operation_id: String,
|
||||
pub config_revision: u64,
|
||||
pub config_projection_digest: String,
|
||||
#[serde(default)]
|
||||
pub cache_generation: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||
}
|
||||
@@ -263,11 +266,11 @@ pub struct CreateWorkerRequest {
|
||||
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
|
||||
/// particular, cancellation returns a Worker to `Idle`; it is not a lifecycle
|
||||
/// state of its own.
|
||||
/// This is not proof that the current Runtime process holds a live execution handle. Run
|
||||
/// termination details remain separate Worker protocol state; in particular, cancellation
|
||||
/// returns a Worker to `Idle` and is not a lifecycle state of its own.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerStatus {
|
||||
@@ -290,12 +293,17 @@ pub(crate) enum WorkerRestoreIntent {
|
||||
Explicit,
|
||||
}
|
||||
|
||||
/// Lightweight catalog row.
|
||||
/// Lightweight persisted Worker identity projection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerSummary {
|
||||
pub worker_ref: WorkerRef,
|
||||
pub worker_id: WorkerId,
|
||||
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")]
|
||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -310,12 +318,17 @@ pub struct WorkerSummary {
|
||||
pub config_bundle: Option<ConfigBundleRef>,
|
||||
}
|
||||
|
||||
/// Full Worker catalog/lifecycle detail.
|
||||
/// Full persisted Worker identity and lifecycle detail.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerDetail {
|
||||
pub worker_ref: WorkerRef,
|
||||
pub worker_id: WorkerId,
|
||||
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")]
|
||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -21,6 +21,12 @@ pub enum RuntimeError {
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("worker {worker_id} metadata deletion failed: {message}")]
|
||||
WorkerDeletePersistenceFailed {
|
||||
worker_id: WorkerId,
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("worker creation has no execution backend: {message}")]
|
||||
ExecutionBackendUnavailable { message: String },
|
||||
|
||||
@@ -79,6 +85,9 @@ pub enum RuntimeError {
|
||||
name: String,
|
||||
},
|
||||
|
||||
#[error("Runtime store is already owned by another process")]
|
||||
RuntimeStoreAlreadyOpen { path: PathBuf },
|
||||
|
||||
#[error("runtime store {operation} failed at {}: {source}", path.display())]
|
||||
StoreIo {
|
||||
operation: &'static str,
|
||||
|
||||
@@ -243,8 +243,6 @@ impl fmt::Debug for WorkerExecutionContext {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkerExecutionSpawnRequest {
|
||||
pub worker_ref: WorkerRef,
|
||||
/// Monotonic execution generation reserved durably before launch.
|
||||
pub run_generation: u64,
|
||||
pub request: crate::catalog::CreateWorkerRequest,
|
||||
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
|
||||
pub context: WorkerExecutionContext,
|
||||
@@ -256,8 +254,6 @@ pub struct WorkerExecutionSpawnRequest {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkerExecutionRestoreRequest {
|
||||
pub worker_ref: WorkerRef,
|
||||
/// Monotonic execution generation reserved durably before restore.
|
||||
pub run_generation: u64,
|
||||
pub request: crate::catalog::CreateWorkerRequest,
|
||||
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
|
||||
pub context: WorkerExecutionContext,
|
||||
@@ -287,6 +283,7 @@ pub enum WorkspaceConfigFetchResult {
|
||||
pub enum WorkerExecutionSpawnResult {
|
||||
Connected {
|
||||
handle: WorkerExecutionHandle,
|
||||
worker_state: protocol::WorkerStateSnapshot,
|
||||
working_directory: Option<WorkingDirectoryStatus>,
|
||||
},
|
||||
Rejected(WorkerExecutionResult),
|
||||
@@ -296,10 +293,12 @@ pub enum WorkerExecutionSpawnResult {
|
||||
impl WorkerExecutionSpawnResult {
|
||||
pub fn connected(
|
||||
handle: WorkerExecutionHandle,
|
||||
worker_state: protocol::WorkerStateSnapshot,
|
||||
working_directory: Option<WorkingDirectoryStatus>,
|
||||
) -> Self {
|
||||
Self::Connected {
|
||||
handle,
|
||||
worker_state,
|
||||
working_directory,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,13 @@ pub mod resource;
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub mod retention;
|
||||
mod runtime;
|
||||
pub mod ssh_host_key_probe;
|
||||
pub mod worker_backend;
|
||||
pub mod worker_source;
|
||||
pub mod working_directory;
|
||||
pub mod workspace_issuer;
|
||||
#[cfg(feature = "http-server")]
|
||||
pub mod workspace_request;
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
|
||||
+762
-251
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,7 @@
|
||||
use crate::auth::{
|
||||
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
|
||||
};
|
||||
use crate::auth::BACKEND_RESOURCE_FETCH_PERMISSION;
|
||||
use crate::identity::WorkerId;
|
||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
|
||||
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -13,16 +11,41 @@ pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
|
||||
"application/vnd.yoi.repository-ssh-access+json";
|
||||
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
|
||||
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
|
||||
pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
|
||||
std::time::Duration::from_secs(15);
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct RepositorySshAccessSecretCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub private_key: String,
|
||||
}
|
||||
|
||||
impl Drop for RepositorySshAccessSecretCandidate {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.private_key);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RepositorySshAccessSecretCandidate")
|
||||
.field("credential_id", &self.credential_id)
|
||||
.field("credential_revision", &self.credential_revision)
|
||||
.field("private_key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct RepositorySshAccessSecret {
|
||||
pub private_key: String,
|
||||
pub credential_candidates: Vec<RepositorySshAccessSecretCandidate>,
|
||||
pub known_hosts_entry: String,
|
||||
}
|
||||
|
||||
impl Drop for RepositorySshAccessSecret {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.private_key);
|
||||
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +54,7 @@ impl std::fmt::Debug for RepositorySshAccessSecret {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RepositorySshAccessSecret")
|
||||
.field("private_key", &"[REDACTED]")
|
||||
.field("credential_candidates", &self.credential_candidates)
|
||||
.field("known_hosts_entry", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
@@ -142,6 +165,8 @@ pub enum BackendResourceError {
|
||||
Oversized { max_bytes: u64, actual_bytes: u64 },
|
||||
#[error("backend resource content type mismatch: expected {expected}, got {actual}")]
|
||||
ContentTypeMismatch { expected: String, actual: String },
|
||||
#[error("backend resource fetch timed out")]
|
||||
Timeout,
|
||||
#[error("backend resource transport failed: {message}")]
|
||||
Transport { message: String },
|
||||
#[error("backend resource response is invalid: {message}")]
|
||||
@@ -161,9 +186,8 @@ pub trait BackendResourceClient: Send + Sync + 'static {
|
||||
pub struct HttpBackendResourceClient {
|
||||
endpoint: String,
|
||||
bearer_token: Option<String>,
|
||||
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
||||
request_source_audience: Option<String>,
|
||||
client: reqwest::Client,
|
||||
workspace_request_client: Option<RuntimeWorkspaceRequestClient>,
|
||||
request_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
#[cfg(feature = "http-server")]
|
||||
@@ -172,19 +196,18 @@ impl HttpBackendResourceClient {
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
bearer_token,
|
||||
request_source_signer: None,
|
||||
request_source_audience: None,
|
||||
client: reqwest::Client::new(),
|
||||
workspace_request_client: None,
|
||||
request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_runtime_request_source(
|
||||
mut self,
|
||||
identity: &RuntimeIdentityMaterial,
|
||||
audience: impl Into<String>,
|
||||
) -> Self {
|
||||
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
|
||||
self.request_source_audience = Some(audience.into());
|
||||
pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
|
||||
self.request_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_workspace_request_client(mut self, client: RuntimeWorkspaceRequestClient) -> Self {
|
||||
self.workspace_request_client = Some(client);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -206,55 +229,73 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let mut builder = self
|
||||
.client
|
||||
.post(endpoint.clone())
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body.clone());
|
||||
if let Some(signer) = self.request_source_signer.as_ref() {
|
||||
let audience = self.request_source_audience.as_deref().ok_or_else(|| {
|
||||
BackendResourceError::Unauthorized {
|
||||
message: "Runtime request proof audience is unavailable".to_owned(),
|
||||
}
|
||||
})?;
|
||||
let proof = signer
|
||||
.issue(
|
||||
audience,
|
||||
&request.handle.workspace_id,
|
||||
None,
|
||||
BACKEND_RESOURCE_FETCH_PERMISSION,
|
||||
"POST",
|
||||
endpoint.path(),
|
||||
&body,
|
||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||
30,
|
||||
)
|
||||
.map_err(|error| BackendResourceError::Unauthorized {
|
||||
let client = self.workspace_request_client.as_ref().ok_or_else(|| {
|
||||
BackendResourceError::Unauthorized {
|
||||
message: "Workspace request client is unavailable".to_string(),
|
||||
}
|
||||
})?;
|
||||
if client.workspace_id() != request.handle.workspace_id {
|
||||
return Err(BackendResourceError::Unauthorized {
|
||||
message: "Workspace request client does not match the resource workspace"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
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(),
|
||||
})?;
|
||||
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
|
||||
headers.insert(reqwest::header::AUTHORIZATION, value);
|
||||
}
|
||||
let builder = if let Some(token) = self.bearer_token.as_deref() {
|
||||
builder.bearer_auth(token)
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
let response = builder
|
||||
.send()
|
||||
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(|err| BackendResourceError::Transport {
|
||||
message: err.to_string(),
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
BackendResourceError::Timeout
|
||||
} else {
|
||||
BackendResourceError::Transport {
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
})?;
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json::<BackendResourceFetchResponse>()
|
||||
.await
|
||||
.map_err(|err| BackendResourceError::InvalidResponse {
|
||||
if response.status.is_success() {
|
||||
serde_json::from_slice::<BackendResourceFetchResponse>(&response.body).map_err(|err| {
|
||||
BackendResourceError::InvalidResponse {
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
let status = response.status();
|
||||
match response.json::<BackendResourceError>().await {
|
||||
let status = response.status;
|
||||
match serde_json::from_slice::<BackendResourceError>(&response.body) {
|
||||
Ok(error) => Err(error),
|
||||
Err(err) => Err(BackendResourceError::Transport {
|
||||
message: format!("backend resource fetch failed with HTTP {status}: {err}"),
|
||||
@@ -345,6 +386,7 @@ pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), Str
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::RuntimeIdentityMaterial;
|
||||
use crate::profile_archive::ProfileSourceGraphSummary;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -382,6 +424,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http-server")]
|
||||
#[tokio::test]
|
||||
async fn http_backend_resource_fetch_has_a_bounded_timeout() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
futures::future::pending::<()>().await;
|
||||
drop(stream);
|
||||
});
|
||||
let base_url = format!("http://{address}");
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let handle = handle_for(b"archive-bytes");
|
||||
let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None)
|
||||
.with_request_timeout(std::time::Duration::from_millis(25))
|
||||
.with_workspace_request_client(
|
||||
RuntimeWorkspaceRequestClient::new(
|
||||
"workspace-test",
|
||||
base_url.clone(),
|
||||
"runtime-test",
|
||||
)
|
||||
.with_runtime_request_source(&identity, base_url),
|
||||
);
|
||||
|
||||
let error = client
|
||||
.fetch_resource(BackendResourceFetchRequest {
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
handle,
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
worker_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
server.abort();
|
||||
assert_eq!(error, BackendResourceError::Timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_ssh_access_secret_debug_redacts_all_secret_values() {
|
||||
let secret = RepositorySshAccessSecret {
|
||||
credential_candidates: vec![RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 2,
|
||||
private_key: "PRIVATE KEY secret bytes".to_string(),
|
||||
}],
|
||||
known_hosts_entry: "host key secret bytes".to_string(),
|
||||
};
|
||||
|
||||
let debug = format!("{secret:?}");
|
||||
assert!(debug.contains("credential-1"));
|
||||
assert!(!debug.contains("secret bytes"));
|
||||
assert_eq!(debug.matches("[REDACTED]").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_verification_detects_digest_mismatch() {
|
||||
let bytes = b"archive-bytes";
|
||||
|
||||
@@ -39,7 +39,6 @@ pub struct WorkerRetentionInventory {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub run_generation: u64,
|
||||
pub session_id: Option<String>,
|
||||
pub segment_ids: Vec<String>,
|
||||
pub session_bytes: u64,
|
||||
@@ -118,7 +117,6 @@ pub struct WorkerRetentionExecutionRequest {
|
||||
pub source_runtime_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub expected_worker_revision: String,
|
||||
pub expected_run_generation: u64,
|
||||
pub source_created_at: String,
|
||||
pub removed_at: String,
|
||||
pub effective_profile: Option<String>,
|
||||
@@ -171,7 +169,6 @@ pub(crate) trait WorkerRetentionProvider: Send + Sync {
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
worker_id: WorkerId,
|
||||
run_generation: u64,
|
||||
) -> Result<WorkerRetentionInventory, RuntimeError>;
|
||||
|
||||
fn execute(
|
||||
@@ -283,7 +280,7 @@ impl FsWorkerRetentionProvider {
|
||||
continue;
|
||||
};
|
||||
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"),
|
||||
"scan Worker retention inventory",
|
||||
) {
|
||||
@@ -303,12 +300,7 @@ impl FsWorkerRetentionProvider {
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match self.inventory(
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
snapshot.run_generation(),
|
||||
) {
|
||||
match self.inventory(workspace_id, runtime_id, worker_id) {
|
||||
Ok(item) => workers.push(item),
|
||||
Err(_) => diagnostics.push(runtime_aggregate_diagnostic(
|
||||
&bounded_id,
|
||||
@@ -380,26 +372,18 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
worker_id: WorkerId,
|
||||
run_generation: u64,
|
||||
) -> Result<WorkerRetentionInventory, RuntimeError> {
|
||||
let worker_dir = self.worker_dir(worker_id);
|
||||
if !worker_dir.is_dir() {
|
||||
return Err(RuntimeError::WorkerNotFound { worker_id });
|
||||
}
|
||||
let worker: WorkerGenerationSnapshot = read_json(
|
||||
let worker: WorkerAggregateSnapshot = read_json(
|
||||
&worker_dir.join("worker.json"),
|
||||
"inventory Worker retention",
|
||||
)?;
|
||||
if worker.workspace_id.as_deref() != Some(workspace_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_id, segment_ids, session_bytes) = if session_dir.is_dir() {
|
||||
let manifest: CanonicalSessionManifest = read_json(
|
||||
@@ -437,7 +421,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id,
|
||||
run_generation,
|
||||
session_id,
|
||||
segment_ids,
|
||||
session_bytes,
|
||||
@@ -497,21 +480,13 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
||||
worker_id: request.worker_id,
|
||||
});
|
||||
}
|
||||
let snapshot: WorkerGenerationSnapshot =
|
||||
let snapshot: WorkerAggregateSnapshot =
|
||||
read_json(&worker_dir.join("worker.json"), "execute Worker retention")?;
|
||||
if snapshot.workspace_id.as_deref() != Some(request.workspace_id.as_str()) {
|
||||
return Err(RuntimeError::WorkerNotFound {
|
||||
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 {
|
||||
SessionDisposition::Archive => {
|
||||
Some(commit_session_archive(self, request, &worker_dir)?)
|
||||
@@ -572,30 +547,9 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WorkerGenerationSnapshot {
|
||||
struct WorkerAggregateSnapshot {
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -1286,13 +1240,12 @@ mod tests {
|
||||
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());
|
||||
write_json(
|
||||
&worker.join("worker.json"),
|
||||
&serde_json::json!({
|
||||
"workspace_id": "workspace-a",
|
||||
"execution": {"binding": {"run_generation": generation}}
|
||||
"workspace_id": "workspace-a"
|
||||
}),
|
||||
);
|
||||
write_json(
|
||||
@@ -1301,22 +1254,17 @@ mod tests {
|
||||
);
|
||||
fs::create_dir_all(worker.join("session/segments")).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(
|
||||
worker.join(format!("runs/{generation}/worker.out.log")),
|
||||
worker.join("runs/attempt-a/worker.out.log"),
|
||||
b"diagnostic\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
worker.join(format!("runs/{generation}/worker.sock")),
|
||||
b"not retained",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(worker.join("runs/attempt-a/worker.sock"), b"not retained").unwrap();
|
||||
}
|
||||
|
||||
fn request(
|
||||
worker_id: WorkerId,
|
||||
generation: u64,
|
||||
disposition: SessionDisposition,
|
||||
) -> WorkerRetentionExecutionRequest {
|
||||
WorkerRetentionExecutionRequest {
|
||||
@@ -1328,7 +1276,6 @@ mod tests {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
source_runtime_id: "runtime-a".to_string(),
|
||||
worker_id,
|
||||
expected_run_generation: generation,
|
||||
source_created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
removed_at: "2026-01-02T00:00:00Z".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() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
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 request = request(worker_id, 4, SessionDisposition::Archive);
|
||||
let request = request(worker_id, SessionDisposition::Archive);
|
||||
|
||||
let first = provider.execute(&request).unwrap();
|
||||
assert!(first.source_removed);
|
||||
@@ -1375,7 +1322,7 @@ mod tests {
|
||||
fn archive_failure_keeps_live_source_for_retry() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
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");
|
||||
fs::create_dir_all(&collision).unwrap();
|
||||
fs::write(collision.join("manifest.json"), b"not-json").unwrap();
|
||||
@@ -1383,7 +1330,7 @@ mod tests {
|
||||
|
||||
assert!(
|
||||
provider
|
||||
.execute(&request(worker_id, 2, SessionDisposition::Archive))
|
||||
.execute(&request(worker_id, SessionDisposition::Archive))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
@@ -1403,13 +1350,13 @@ mod tests {
|
||||
fn target_inventory_and_execute_reject_cross_workspace_aggregate() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
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());
|
||||
assert!(matches!(
|
||||
provider.inventory("other-workspace", "runtime-a", worker_id, 3),
|
||||
provider.inventory("other-workspace", "runtime-a", worker_id),
|
||||
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();
|
||||
assert!(matches!(
|
||||
provider.execute(&request),
|
||||
@@ -1434,20 +1381,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_removes_aggregate_and_rejects_stale_generation() {
|
||||
fn purge_removes_worker_aggregate() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||
let worker_id = WorkerId::from_legacy_u64(9);
|
||||
source(temp.path(), worker_id, 5);
|
||||
let stale = request(worker_id, 4, 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);
|
||||
source(temp.path(), worker_id);
|
||||
let mut current = request(worker_id, SessionDisposition::Purge);
|
||||
current.operation_id = "operation-current".to_string();
|
||||
current.input_fingerprint = "fingerprint-current".to_string();
|
||||
let result = provider.execute(¤t).unwrap();
|
||||
@@ -1464,9 +1403,9 @@ mod tests {
|
||||
fn pending_receipt_recovers_delete_to_receipt_crash_window() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
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 request = request(worker_id, 1, SessionDisposition::Archive);
|
||||
let request = request(worker_id, SessionDisposition::Archive);
|
||||
let completed = provider.execute(&request).unwrap();
|
||||
let receipt_path = temp.path().join("retention/operations/operation-a.json");
|
||||
let mut receipt: RetentionOperationReceipt =
|
||||
@@ -1482,9 +1421,9 @@ mod tests {
|
||||
#[test]
|
||||
fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() {
|
||||
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);
|
||||
source(temp.path(), other_worker, 1);
|
||||
source(temp.path(), other_worker);
|
||||
write_json(
|
||||
&temp
|
||||
.path()
|
||||
@@ -1492,8 +1431,7 @@ mod tests {
|
||||
.join(other_worker.to_string())
|
||||
.join("worker.json"),
|
||||
&serde_json::json!({
|
||||
"workspace_id": "other-workspace",
|
||||
"execution": {"binding": {"run_generation": 1}}
|
||||
"workspace_id": "other-workspace"
|
||||
}),
|
||||
);
|
||||
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() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
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 mut request = request(worker_id, 1, SessionDisposition::Archive);
|
||||
let mut request = request(worker_id, SessionDisposition::Archive);
|
||||
request.diagnostics_disposition = DiagnosticsDisposition::Retain;
|
||||
provider.execute(&request).unwrap();
|
||||
|
||||
@@ -1543,10 +1481,10 @@ mod tests {
|
||||
serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap();
|
||||
receipt.result.source_removed = false;
|
||||
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(
|
||||
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",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1564,9 +1502,9 @@ mod tests {
|
||||
fn concurrent_retry_produces_one_archive() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
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 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 handles = (0..2)
|
||||
.map(|_| {
|
||||
|
||||
+1728
-527
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
||||
//! Side-effect-free SSH host key discovery for Repository trust enrollment.
|
||||
//!
|
||||
//! Probing only observes public host keys. It does not persist trust, use clone
|
||||
//! credentials, or authenticate to the target host.
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub const SSH_HOST_KEY_PROBE_PATH: &str = "/v1/repositories/ssh/probe";
|
||||
pub const SSH_HOST_KEY_PROBE_OPERATION: &str = "workdirs:operate";
|
||||
pub(crate) const SSH_KEYSCAN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS: &str = "5";
|
||||
const MAX_SSH_KEYSCAN_OUTPUT_BYTES: usize = 64 * 1024;
|
||||
const MAX_PROBE_CANDIDATES: usize = 32;
|
||||
const MAX_DIAGNOSTIC_BYTES: usize = 256;
|
||||
|
||||
/// `POST /v1/repositories/ssh/probe` request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SshHostKeyProbeRequest {
|
||||
pub hostname: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// One public host key observed by an SSH host key probe.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SshHostKeyCandidate {
|
||||
/// Canonical OpenSSH public key text (`algorithm base64-key`), without a host prefix.
|
||||
pub public_key: String,
|
||||
/// OpenSSH public key algorithm name.
|
||||
pub algorithm: String,
|
||||
/// OpenSSH SHA-256 fingerprint (`SHA256:base64-digest`).
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
/// `POST /v1/repositories/ssh/probe` response.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SshHostKeyProbeResponse {
|
||||
pub candidates: Vec<SshHostKeyCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SshHostKeyProbeError {
|
||||
#[error("SSH host key probe hostname is invalid")]
|
||||
InvalidHostname,
|
||||
#[error("SSH host key probe port must be greater than zero")]
|
||||
InvalidPort,
|
||||
#[error("SSH host key probe executable is unavailable")]
|
||||
Unavailable,
|
||||
#[error("SSH host key probe timed out")]
|
||||
Timeout,
|
||||
#[error("SSH host key probe failed: {diagnostic}")]
|
||||
Failed { diagnostic: String },
|
||||
}
|
||||
|
||||
/// Observe the target's public Ed25519 host keys without persisting trust or using credentials.
|
||||
pub async fn probe_ssh_host_keys(
|
||||
request: &SshHostKeyProbeRequest,
|
||||
) -> Result<SshHostKeyProbeResponse, SshHostKeyProbeError> {
|
||||
probe_ssh_host_keys_with_program(request, Path::new("ssh-keyscan"), SSH_KEYSCAN_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub(crate) async fn probe_ssh_host_keys_with_program(
|
||||
request: &SshHostKeyProbeRequest,
|
||||
program: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<SshHostKeyProbeResponse, SshHostKeyProbeError> {
|
||||
validate_request(request)?;
|
||||
|
||||
let mut command = Command::new(program);
|
||||
command
|
||||
.args(["-T", SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS])
|
||||
.arg("-p")
|
||||
.arg(request.port.to_string())
|
||||
.args(["-t", "ed25519"])
|
||||
.arg(&request.hostname)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
// ssh-keyscan diagnostics are intentionally not returned or retained: they may contain
|
||||
// environment-specific details and are not needed for the public error contract.
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let output = tokio::time::timeout(timeout, command.output())
|
||||
.await
|
||||
.map_err(|_| SshHostKeyProbeError::Timeout)?
|
||||
.map_err(|_| SshHostKeyProbeError::Unavailable)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(SshHostKeyProbeError::Failed {
|
||||
diagnostic: bounded_diagnostic(format!(
|
||||
"ssh-keyscan exited unsuccessfully ({})",
|
||||
output.status
|
||||
)),
|
||||
});
|
||||
}
|
||||
if output.stdout.len() > MAX_SSH_KEYSCAN_OUTPUT_BYTES {
|
||||
return Err(SshHostKeyProbeError::Failed {
|
||||
diagnostic: "ssh-keyscan output exceeded the probe limit".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let candidates = parse_ssh_keyscan_output(&output.stdout);
|
||||
if candidates.is_empty() {
|
||||
return Err(SshHostKeyProbeError::Failed {
|
||||
diagnostic: "ssh-keyscan returned no valid ssh-ed25519 host keys".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(SshHostKeyProbeResponse { candidates })
|
||||
}
|
||||
|
||||
fn validate_request(request: &SshHostKeyProbeRequest) -> Result<(), SshHostKeyProbeError> {
|
||||
if request.port == 0 {
|
||||
return Err(SshHostKeyProbeError::InvalidPort);
|
||||
}
|
||||
validate_hostname(&request.hostname)
|
||||
}
|
||||
|
||||
fn validate_hostname(hostname: &str) -> Result<(), SshHostKeyProbeError> {
|
||||
if hostname.is_empty()
|
||||
|| hostname.len() > 253
|
||||
|| !hostname.is_ascii()
|
||||
|| hostname.bytes().any(|byte| byte.is_ascii_whitespace())
|
||||
|| hostname.starts_with('-')
|
||||
{
|
||||
return Err(SshHostKeyProbeError::InvalidHostname);
|
||||
}
|
||||
if hostname.parse::<IpAddr>().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hostname = hostname.strip_suffix('.').unwrap_or(hostname);
|
||||
if hostname.is_empty()
|
||||
|| hostname.split('.').any(|label| {
|
||||
label.is_empty()
|
||||
|| label.len() > 63
|
||||
|| label.starts_with('-')
|
||||
|| label.ends_with('-')
|
||||
|| !label
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
})
|
||||
{
|
||||
return Err(SshHostKeyProbeError::InvalidHostname);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_ssh_keyscan_output(output: &[u8]) -> Vec<SshHostKeyCandidate> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
for line in output.split(|byte| *byte == b'\n') {
|
||||
let Ok(line) = std::str::from_utf8(line) else {
|
||||
continue;
|
||||
};
|
||||
let mut fields = line.split_ascii_whitespace();
|
||||
let (Some(_host), Some(algorithm), Some(encoded_key)) =
|
||||
(fields.next(), fields.next(), fields.next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if line.trim_start().starts_with('#') || algorithm != "ssh-ed25519" {
|
||||
continue;
|
||||
}
|
||||
let Ok(key_blob) = STANDARD.decode(encoded_key) else {
|
||||
continue;
|
||||
};
|
||||
if !is_ed25519_public_key_blob(&key_blob) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let canonical_key = STANDARD.encode(&key_blob);
|
||||
if !seen.insert(canonical_key.clone()) {
|
||||
continue;
|
||||
}
|
||||
let public_key = format!("{algorithm} {canonical_key}");
|
||||
candidates.push(SshHostKeyCandidate {
|
||||
algorithm: algorithm.to_string(),
|
||||
fingerprint: format!(
|
||||
"SHA256:{}",
|
||||
STANDARD_NO_PAD.encode(Sha256::digest(&key_blob))
|
||||
),
|
||||
public_key,
|
||||
});
|
||||
if candidates.len() == MAX_PROBE_CANDIDATES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn is_ed25519_public_key_blob(blob: &[u8]) -> bool {
|
||||
let Some((algorithm, rest)) = take_ssh_string(blob) else {
|
||||
return false;
|
||||
};
|
||||
let Some((public_key, rest)) = take_ssh_string(rest) else {
|
||||
return false;
|
||||
};
|
||||
algorithm == b"ssh-ed25519" && public_key.len() == 32 && rest.is_empty()
|
||||
}
|
||||
|
||||
fn take_ssh_string(input: &[u8]) -> Option<(&[u8], &[u8])> {
|
||||
let length = u32::from_be_bytes(input.get(..4)?.try_into().ok()?) as usize;
|
||||
let value = input.get(4..4usize.checked_add(length)?)?;
|
||||
let rest = input.get(4usize.checked_add(length)?..)?;
|
||||
Some((value, rest))
|
||||
}
|
||||
|
||||
fn bounded_diagnostic(mut diagnostic: String) -> String {
|
||||
if diagnostic.len() <= MAX_DIAGNOSTIC_BYTES {
|
||||
return diagnostic;
|
||||
}
|
||||
let mut end = MAX_DIAGNOSTIC_BYTES;
|
||||
while !diagnostic.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
diagnostic.truncate(end);
|
||||
diagnostic
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn encoded_ed25519_key(seed: u8) -> String {
|
||||
let mut blob = Vec::new();
|
||||
blob.extend_from_slice(&("ssh-ed25519".len() as u32).to_be_bytes());
|
||||
blob.extend_from_slice(b"ssh-ed25519");
|
||||
blob.extend_from_slice(&32_u32.to_be_bytes());
|
||||
blob.extend_from_slice(&[seed; 32]);
|
||||
STANDARD.encode(blob)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_validation_rejects_option_injection_and_ambiguous_text() {
|
||||
for hostname in [
|
||||
"",
|
||||
"-example.test",
|
||||
"--help",
|
||||
"example.test other.test",
|
||||
"example.test\nother.test",
|
||||
"example_test",
|
||||
".example.test",
|
||||
"example..test",
|
||||
"example.test:22",
|
||||
"[::1]",
|
||||
"éxample.test",
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_hostname(hostname),
|
||||
Err(SshHostKeyProbeError::InvalidHostname),
|
||||
"{hostname:?} must be rejected"
|
||||
);
|
||||
}
|
||||
for hostname in [
|
||||
"localhost",
|
||||
"example.test",
|
||||
"example.test.",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
] {
|
||||
validate_hostname(hostname).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_validation_rejects_zero_port() {
|
||||
assert_eq!(
|
||||
validate_request(&SshHostKeyProbeRequest {
|
||||
hostname: "example.test".to_string(),
|
||||
port: 0,
|
||||
}),
|
||||
Err(SshHostKeyProbeError::InvalidPort)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_only_valid_ed25519_keys_and_deduplicates() {
|
||||
let key = encoded_ed25519_key(7);
|
||||
let other_key = encoded_ed25519_key(8);
|
||||
let output = format!(
|
||||
"# comment\nexample.test ssh-rsa AAAA\nexample.test ssh-ed25519 invalid!\nexample.test ssh-ed25519 {key}\n[example.test]:2222 ssh-ed25519 {key}\nexample.test ssh-ed25519 {other_key}\n"
|
||||
);
|
||||
|
||||
let candidates = parse_ssh_keyscan_output(output.as_bytes());
|
||||
|
||||
assert_eq!(candidates.len(), 2);
|
||||
assert_eq!(candidates[0].algorithm, "ssh-ed25519");
|
||||
assert_eq!(candidates[0].public_key, format!("ssh-ed25519 {key}"));
|
||||
let decoded = STANDARD.decode(key).unwrap();
|
||||
assert_eq!(
|
||||
candidates[0].fingerprint,
|
||||
format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(decoded)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_base64_that_is_not_an_ed25519_wire_key() {
|
||||
let output = format!("example.test ssh-ed25519 {}\n", STANDARD.encode([1_u8; 32]));
|
||||
assert!(parse_ssh_keyscan_output(output.as_bytes()).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn unsuccessful_command_does_not_return_stderr() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let program = temp.path().join("ssh-keyscan");
|
||||
std::fs::write(
|
||||
&program,
|
||||
"#!/bin/sh\nprintf 'secret from stderr' >&2\nexit 7\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let error = probe_ssh_host_keys_with_program(
|
||||
&SshHostKeyProbeRequest {
|
||||
hostname: "example.test".to_string(),
|
||||
port: 22,
|
||||
},
|
||||
&program,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let diagnostic = error.to_string();
|
||||
assert!(matches!(error, SshHostKeyProbeError::Failed { .. }));
|
||||
assert!(!diagnostic.contains("secret"));
|
||||
assert!(diagnostic.len() <= MAX_DIAGNOSTIC_BYTES + "SSH host key probe failed: ".len());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn command_execution_times_out_without_returning_process_diagnostics() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let program = temp.path().join("ssh-keyscan");
|
||||
std::fs::write(
|
||||
&program,
|
||||
"#!/bin/sh\nprintf 'secret from stderr' >&2\nsleep 2\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let request = SshHostKeyProbeRequest {
|
||||
hostname: "example.test".to_string(),
|
||||
port: 22,
|
||||
};
|
||||
|
||||
let error = probe_ssh_host_keys_with_program(&request, &program, Duration::from_millis(20))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, SshHostKeyProbeError::Timeout);
|
||||
assert!(!error.to_string().contains("secret"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
|
||||
use worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
|
||||
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
|
||||
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION,
|
||||
WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION, WorkerMutationActorKind,
|
||||
WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
|
||||
RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
|
||||
WORKER_REMOVE_PERMISSION, WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION,
|
||||
WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
|
||||
};
|
||||
use crate::runtime::RuntimeWorkspaceScope;
|
||||
use crate::worker_backend::WorkspacePromptProjectionCache;
|
||||
@@ -133,9 +133,7 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
|
||||
#[derive(Clone)]
|
||||
enum RuntimeWorkerMutationTransport {
|
||||
Remote {
|
||||
base_url: String,
|
||||
request_source_signer: RuntimeRequestSourceSigner,
|
||||
request_source_audience: String,
|
||||
request_client: RuntimeWorkspaceRequestClient,
|
||||
},
|
||||
Embedded {
|
||||
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
|
||||
@@ -155,17 +153,13 @@ impl RuntimeWorkerMutationForwarder {
|
||||
identity: &RuntimeIdentityMaterial,
|
||||
scope: RuntimeWorkspaceScope,
|
||||
source_worker_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
request_client: RuntimeWorkspaceRequestClient,
|
||||
) -> Self {
|
||||
Self {
|
||||
authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
|
||||
scope: scope.clone(),
|
||||
source_worker_id: source_worker_id.into(),
|
||||
transport: RuntimeWorkerMutationTransport::Remote {
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
request_source_signer: RuntimeRequestSourceSigner::from_identity(identity),
|
||||
request_source_audience: scope.server_id,
|
||||
},
|
||||
transport: RuntimeWorkerMutationTransport::Remote { request_client },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,18 +195,11 @@ impl RuntimeWorkerMutationForwarder {
|
||||
)?;
|
||||
match (&self.transport, proof) {
|
||||
(
|
||||
RuntimeWorkerMutationTransport::Remote {
|
||||
base_url,
|
||||
request_source_signer,
|
||||
request_source_audience,
|
||||
},
|
||||
RuntimeWorkerMutationTransport::Remote { request_client },
|
||||
RuntimeOwnedWorkerMutationProof::Remote(token),
|
||||
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
|
||||
base_url: base_url.clone(),
|
||||
workspace_id: self.scope.workspace_id.clone(),
|
||||
request_client: request_client.clone(),
|
||||
source_worker_id: self.source_worker_id.clone(),
|
||||
request_source_signer: request_source_signer.clone(),
|
||||
request_source_audience: request_source_audience.clone(),
|
||||
token,
|
||||
target_runtime_id: target_runtime_id.to_string(),
|
||||
target_worker_id: target_worker_id.to_string(),
|
||||
@@ -233,11 +220,8 @@ impl RuntimeWorkerMutationForwarder {
|
||||
}
|
||||
|
||||
struct RemoteWorkerRemoveHttpRequest {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
request_client: RuntimeWorkspaceRequestClient,
|
||||
source_worker_id: String,
|
||||
request_source_signer: RuntimeRequestSourceSigner,
|
||||
request_source_audience: String,
|
||||
token: String,
|
||||
target_runtime_id: String,
|
||||
target_worker_id: String,
|
||||
@@ -270,54 +254,54 @@ fn execute_remote_worker_remove_http(
|
||||
fn execute_remote_worker_remove_http_blocking(
|
||||
request: RemoteWorkerRemoveHttpRequest,
|
||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||
let path = format!("/api/w/{}/workers/remove", request.workspace_id);
|
||||
let url = format!("{}{}", request.base_url, path);
|
||||
let body = serde_json::to_string(&serde_json::json!({
|
||||
let path = format!(
|
||||
"/api/w/{}/workers/remove",
|
||||
request.request_client.workspace_id()
|
||||
);
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"target_runtime_id": request.target_runtime_id,
|
||||
"target_worker_id": request.target_worker_id,
|
||||
"reason": request.reason,
|
||||
}))
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
let request_source_proof = request.request_source_signer.issue(
|
||||
&request.request_source_audience,
|
||||
&request.workspace_id,
|
||||
Some(&request.source_worker_id),
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
"POST",
|
||||
&path,
|
||||
body.as_bytes(),
|
||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||
30,
|
||||
)?;
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.post(url)
|
||||
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, request_source_proof)
|
||||
.header(
|
||||
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
||||
request.token,
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
||||
reqwest::header::HeaderValue::from_str(&request.token)
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?,
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
let response = request
|
||||
.request_client
|
||||
.execute_blocking(RuntimeWorkspaceRequest {
|
||||
method: reqwest::Method::POST,
|
||||
path_and_query: path,
|
||||
body,
|
||||
headers,
|
||||
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
|
||||
worker_id: Some(request.source_worker_id),
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
max_response_bytes: 8 * 1024 * 1024,
|
||||
})
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
let body = String::from_utf8(response.body)
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
Ok(WorkspaceResponse { status, body })
|
||||
Ok(WorkspaceResponse {
|
||||
status: response.status.as_u16(),
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeOwnedWorkspaceClient {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
runtime_id: String,
|
||||
request_client: RuntimeWorkspaceRequestClient,
|
||||
worker_id: String,
|
||||
request_timeout: Option<Duration>,
|
||||
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
||||
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
||||
request_source_audience: Option<String>,
|
||||
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
||||
}
|
||||
|
||||
@@ -328,15 +312,32 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
runtime_id: impl Into<String>,
|
||||
worker_id: impl Into<String>,
|
||||
) -> Self {
|
||||
let workspace_id = workspace_id.into();
|
||||
Self {
|
||||
workspace_id: workspace_id.into(),
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
runtime_id: runtime_id.into(),
|
||||
request_client: RuntimeWorkspaceRequestClient::new(
|
||||
workspace_id.clone(),
|
||||
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(),
|
||||
request_timeout: None,
|
||||
worker_remove: None,
|
||||
request_source_signer: None,
|
||||
request_source_audience: None,
|
||||
prompt_projection_cache: None,
|
||||
}
|
||||
}
|
||||
@@ -351,8 +352,9 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
identity: &RuntimeIdentityMaterial,
|
||||
audience: impl Into<String>,
|
||||
) -> Self {
|
||||
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
|
||||
self.request_source_audience = Some(audience.into());
|
||||
self.request_client = self
|
||||
.request_client
|
||||
.with_runtime_request_source(identity, audience);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -375,44 +377,43 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
request: WorkspaceRequest,
|
||||
permission: &'static str,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let base_url = self.base_url.clone();
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let runtime_id = self.runtime_id.clone();
|
||||
let worker_id = self.worker_id.clone();
|
||||
let request_source_signer = self.request_source_signer.clone();
|
||||
let request_source_audience = self.request_source_audience.clone();
|
||||
let request_timeout = self.request_timeout;
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
std::thread::spawn(move || {
|
||||
execute_runtime_owned_workspace_http(
|
||||
&base_url,
|
||||
&workspace_id,
|
||||
&runtime_id,
|
||||
&worker_id,
|
||||
request_source_signer.as_ref(),
|
||||
request_source_audience.as_deref(),
|
||||
request_timeout,
|
||||
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 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 body = request.body.unwrap_or_default().into_bytes();
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if !body.is_empty() {
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
}
|
||||
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 {
|
||||
formatter
|
||||
.debug_struct("RuntimeOwnedWorkspaceClient")
|
||||
.field("workspace_id", &self.workspace_id)
|
||||
.field("base_url", &self.base_url)
|
||||
.field("workspace_id", &self.request_client.workspace_id())
|
||||
.field("base_url", &self.request_client.base_url())
|
||||
.field("source", &"Runtime-owned")
|
||||
.field(
|
||||
"worker_remove",
|
||||
@@ -433,7 +434,7 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
|
||||
|
||||
impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some(&self.workspace_id)
|
||||
Some(self.request_client.workspace_id())
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
@@ -588,117 +589,6 @@ fn percent_encode_query(value: &str) -> String {
|
||||
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(¤t_text) {
|
||||
details.push_str(": ");
|
||||
details.push_str(¤t_text);
|
||||
}
|
||||
source = std::error::Error::source(current);
|
||||
}
|
||||
details
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RuntimeWorkerMutationForwardError {
|
||||
#[error(transparent)]
|
||||
@@ -722,9 +612,9 @@ fn unix_now_seconds() -> u64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{
|
||||
WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
|
||||
decode_worker_mutation_source_claims, request_body_digest,
|
||||
verify_worker_mutation_source_proof,
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, WorkerMutationSourceExpectation,
|
||||
decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
|
||||
request_body_digest, verify_worker_mutation_source_proof,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -1132,7 +1022,12 @@ mod tests {
|
||||
&identity,
|
||||
scope,
|
||||
"worker-source",
|
||||
format!("http://{address}"),
|
||||
RuntimeWorkspaceRequestClient::new(
|
||||
"workspace-a",
|
||||
format!("http://{address}"),
|
||||
"runtime-a",
|
||||
)
|
||||
.with_runtime_request_source(&identity, "server-a"),
|
||||
);
|
||||
let response = forwarder
|
||||
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,6 @@ memory = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
session-metrics = { workspace = true }
|
||||
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"] }
|
||||
tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] }
|
||||
futures-util = { version = "0.3", features = ["sink"] }
|
||||
@@ -53,5 +52,3 @@ dotenv = "0.15.0"
|
||||
futures = { workspace = true }
|
||||
serial_test = "3.4.0"
|
||||
tempfile = { workspace = true }
|
||||
wat = "1.241.2"
|
||||
yoi-plugin-pdk = { workspace = true }
|
||||
|
||||
@@ -67,6 +67,7 @@ pub struct PreparedWorker<C: LlmClient, St: Store> {
|
||||
pub struct BootstrappedWorker {
|
||||
pub handle: WorkerHandle,
|
||||
pub shutdown: ShutdownReceiver,
|
||||
pub controller_task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -227,7 +228,7 @@ where
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
} => {
|
||||
WorkerController::spawn_with_transport(
|
||||
WorkerController::spawn_with_transport_owned(
|
||||
worker,
|
||||
&runtime_base,
|
||||
&bash_output_dir,
|
||||
@@ -239,7 +240,7 @@ where
|
||||
run_dir,
|
||||
bash_output_dir,
|
||||
} => {
|
||||
WorkerController::spawn_runtime_managed_run_with_transport(
|
||||
WorkerController::spawn_runtime_managed_run_with_transport_owned(
|
||||
worker,
|
||||
&run_dir,
|
||||
&bash_output_dir,
|
||||
@@ -250,7 +251,11 @@ where
|
||||
};
|
||||
|
||||
match controller {
|
||||
Ok((handle, shutdown)) => Ok(BootstrappedWorker { handle, shutdown }),
|
||||
Ok((handle, shutdown, controller_task)) => Ok(BootstrappedWorker {
|
||||
handle,
|
||||
shutdown,
|
||||
controller_task,
|
||||
}),
|
||||
Err(source) => {
|
||||
let cleanup_failed = match cleanup_session {
|
||||
Some(session) => session.close().await.is_err(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub(crate) mod metrics_tracker;
|
||||
pub(crate) mod prune;
|
||||
pub(crate) mod state;
|
||||
pub(crate) mod telemetry;
|
||||
pub(crate) mod token_counter;
|
||||
pub(crate) mod usage_tracker;
|
||||
pub(crate) mod worker;
|
||||
|
||||
+309
-140
@@ -1,109 +1,236 @@
|
||||
//! Shared state for compaction decisions.
|
||||
//!
|
||||
//! 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::Mutex;
|
||||
|
||||
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 {
|
||||
/// Between-turns threshold (proactive). Checked before the next turn
|
||||
/// starts. `None` disables the pre-run check.
|
||||
post_run_threshold: Option<u64>,
|
||||
/// Between-requests threshold (safety net). Checked inside a turn
|
||||
/// before each LLM request. `None` disables the request check.
|
||||
/// Proactive threshold checked before a fresh user run starts.
|
||||
compact_threshold: Option<u64>,
|
||||
/// Safety threshold checked immediately before every provider request.
|
||||
request_threshold: Option<u64>,
|
||||
/// Token budget retained verbatim at the tail after compaction.
|
||||
retained_tokens: u64,
|
||||
/// Consecutive compact failures. At `MAX_COMPACT_FAILURES`, compaction is disabled.
|
||||
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,
|
||||
runtime: Mutex<AutomaticCompactRuntimeState>,
|
||||
}
|
||||
|
||||
impl CompactState {
|
||||
pub(crate) fn new(
|
||||
post_run_threshold: Option<u64>,
|
||||
compact_threshold: Option<u64>,
|
||||
request_threshold: Option<u64>,
|
||||
retained_tokens: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
post_run_threshold,
|
||||
compact_threshold,
|
||||
request_threshold,
|
||||
retained_tokens,
|
||||
consecutive_failures: AtomicUsize::new(0),
|
||||
just_compacted: AtomicBool::new(false),
|
||||
disabled: AtomicBool::new(false),
|
||||
runtime: Mutex::new(AutomaticCompactRuntimeState {
|
||||
guard: AutomaticCompactGuard::Ready,
|
||||
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 {
|
||||
self.retained_tokens
|
||||
}
|
||||
|
||||
/// Whether compaction has been disabled by the circuit breaker.
|
||||
pub(crate) fn is_disabled(&self) -> bool {
|
||||
self.disabled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether `current_tokens` exceeds the between-requests threshold.
|
||||
/// 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);
|
||||
pub(crate) fn pre_run_eligible(&self, total_tokens: u64) -> bool {
|
||||
if !self
|
||||
.compact_threshold
|
||||
.is_some_and(|threshold| total_tokens > threshold)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
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 {
|
||||
use super::*;
|
||||
|
||||
const FAILURE: CompactFailureCategory = CompactFailureCategory::Storage;
|
||||
|
||||
#[test]
|
||||
fn both_thresholds_configured() {
|
||||
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
|
||||
assert_eq!(state.request_threshold(), Some(90_000));
|
||||
assert_eq!(state.retained_tokens(), 8_000);
|
||||
fn automatic_failure_suppresses_only_current_logical_run() {
|
||||
let state = CompactState::new(Some(10), Some(10), 2);
|
||||
assert_eq!(
|
||||
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));
|
||||
assert!(!state.exceeds_post_run(70_000));
|
||||
|
||||
assert!(!state.exceeds_request(85_000));
|
||||
assert!(state.exceeds_post_run(85_000));
|
||||
|
||||
assert!(state.exceeds_request(95_000));
|
||||
assert!(state.exceeds_post_run(95_000));
|
||||
state.begin_logical_run();
|
||||
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
|
||||
assert_eq!(
|
||||
state.evaluate_pre_run(11),
|
||||
AutomaticCompactDecision::Start(AutomaticCompactTrigger::PreRun)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_run_only() {
|
||||
let state = CompactState::new(Some(80_000), None, 8_000);
|
||||
// request check always false when threshold is None.
|
||||
assert!(!state.exceeds_request(1_000_000));
|
||||
assert!(state.exceeds_post_run(85_000));
|
||||
fn claimed_attempt_cannot_be_started_twice() {
|
||||
let state = CompactState::new(Some(10), Some(10), 2);
|
||||
assert!(matches!(
|
||||
state.evaluate_pre_run(11),
|
||||
AutomaticCompactDecision::Start(_)
|
||||
));
|
||||
assert_eq!(
|
||||
state.evaluate_request(11),
|
||||
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_only() {
|
||||
let state = CompactState::new(None, Some(90_000), 8_000);
|
||||
assert!(!state.exceeds_post_run(1_000_000));
|
||||
assert!(state.exceeds_request(95_000));
|
||||
fn cancellation_consumes_run_attempt_without_becoming_failure() {
|
||||
let state = CompactState::new(None, Some(10), 2);
|
||||
assert!(matches!(
|
||||
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]
|
||||
fn both_none_disables_all_checks() {
|
||||
let state = CompactState::new(None, None, 8_000);
|
||||
assert!(!state.exceeds_request(1_000_000));
|
||||
assert!(!state.exceeds_post_run(1_000_000));
|
||||
fn successful_compaction_requires_committed_request_before_rearming() {
|
||||
let state = CompactState::new(None, Some(10), 2);
|
||||
assert!(matches!(
|
||||
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]
|
||||
fn circuit_breaker_trips_after_max_failures() {
|
||||
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
|
||||
assert!(!state.is_disabled());
|
||||
fn pause_resume_preserves_guard_while_terminal_finish_clears_it() {
|
||||
let state = CompactState::new(None, Some(10), 2);
|
||||
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();
|
||||
assert!(!state.is_disabled());
|
||||
state.record_compact_failure();
|
||||
assert!(!state.is_disabled());
|
||||
state.record_compact_failure();
|
||||
assert!(state.is_disabled());
|
||||
state.finish_logical_run();
|
||||
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_resets_failure_count() {
|
||||
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
|
||||
state.record_compact_failure();
|
||||
state.record_compact_failure();
|
||||
assert!(!state.is_disabled());
|
||||
|
||||
state.record_compact_success();
|
||||
assert!(state.just_compacted());
|
||||
|
||||
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());
|
||||
fn hook_yield_is_guarded_without_threshold_configuration() {
|
||||
let state = CompactState::new(None, None, 2);
|
||||
assert!(matches!(
|
||||
state.claim_hook_yield(),
|
||||
AutomaticCompactDecision::Start(_)
|
||||
));
|
||||
assert_eq!(
|
||||
state.claim_hook_yield(),
|
||||
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,41 @@ use std::sync::Mutex;
|
||||
use agen::UsageRecord;
|
||||
use agen::timeline::event::UsageEvent;
|
||||
|
||||
/// One drained measurement: the underlying `UsageRecord` plus an optional
|
||||
/// `correlation_id` stamped by the prune projection (or any other future
|
||||
/// upstream observer) so that downstream metrics emitted alongside this
|
||||
/// record can be joined to it after the fact.
|
||||
/// The metric emitted after the next measured provider request.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PostRequestMetric {
|
||||
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)]
|
||||
pub(crate) struct RecordedUsage {
|
||||
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.
|
||||
@@ -34,11 +61,8 @@ pub(crate) struct UsageTracker {
|
||||
/// `history.len()` captured at the most recent `pre_llm_request`.
|
||||
/// Cleared when paired with an incoming `on_usage` event.
|
||||
pending_history_len: Mutex<Option<usize>>,
|
||||
/// Optional `correlation_id` set by an upstream observer (currently
|
||||
/// the prune projection on `Fired`). Paired into the next
|
||||
/// `RecordedUsage` and cleared. Skips that don't fire leave this
|
||||
/// `None`, so the resulting record carries no correlation.
|
||||
pending_correlation_id: Mutex<Option<String>>,
|
||||
/// Optional causal link consumed by the next measured request.
|
||||
pending_correlations: Mutex<Vec<PostRequestLink>>,
|
||||
/// Records accumulated during the current run; drained by Worker.
|
||||
pending_records: Mutex<Vec<RecordedUsage>>,
|
||||
}
|
||||
@@ -47,7 +71,7 @@ impl UsageTracker {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
pending_history_len: Mutex::new(None),
|
||||
pending_correlation_id: Mutex::new(None),
|
||||
pending_correlations: 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);
|
||||
}
|
||||
|
||||
/// Stash a `correlation_id` to be paired into the next `RecordedUsage`.
|
||||
/// 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`.
|
||||
/// Pair a prune event with the next provider request.
|
||||
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
|
||||
@@ -79,7 +110,7 @@ impl UsageTracker {
|
||||
Some(n) => n,
|
||||
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 層で「占有量(プロンプト全長)」に
|
||||
// 正規化済みである前提(Anthropic は cache_read + cache_creation を
|
||||
// 加算して emit する)。
|
||||
@@ -95,7 +126,7 @@ impl UsageTracker {
|
||||
cache_write_tokens: cache_write,
|
||||
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_write_tokens, 100);
|
||||
assert_eq!(records[0].record.output_tokens, 42);
|
||||
assert!(records[0].correlation_id.is_none());
|
||||
assert!(records[0].post_requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -192,6 +223,24 @@ mod tests {
|
||||
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]
|
||||
fn correlation_id_pairs_with_next_record_only() {
|
||||
let tracker = UsageTracker::new();
|
||||
@@ -205,7 +254,9 @@ mod tests {
|
||||
|
||||
let records = tracker.drain();
|
||||
assert_eq!(records.len(), 2);
|
||||
assert_eq!(records[0].correlation_id.as_deref(), Some("abc"));
|
||||
assert!(records[1].correlation_id.is_none());
|
||||
assert_eq!(records[0].post_requests.len(), 1);
|
||||
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
@@ -190,12 +190,6 @@ fn command_admission_disposition(
|
||||
Err(WorkerCommandDisposition::StaleCommandId)
|
||||
}
|
||||
WorkerCommandAdmission::Conflict => Err(WorkerCommandDisposition::Conflict),
|
||||
WorkerCommandAdmission::ExecutionGenerationMismatch => {
|
||||
Err(WorkerCommandDisposition::StaleExecutionGeneration)
|
||||
}
|
||||
WorkerCommandAdmission::StateRevisionMismatch => {
|
||||
Err(WorkerCommandDisposition::StaleWorkerStateRevision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,14 +198,14 @@ fn validate_command(
|
||||
kind: WorkerCommandKind,
|
||||
shared_state: &WorkerSharedState,
|
||||
) -> 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(
|
||||
envelope: WorkerCommandEnvelope,
|
||||
shared_state: &WorkerSharedState,
|
||||
) -> Result<(), WorkerCommandDisposition> {
|
||||
match shared_state.admit_command(envelope, WorkerCommandKind::Shutdown, false) {
|
||||
match shared_state.admit_command(envelope, WorkerCommandKind::Shutdown) {
|
||||
WorkerCommandAdmission::Accepted | WorkerCommandAdmission::Retry => Ok(()),
|
||||
admission => command_admission_disposition(admission),
|
||||
}
|
||||
@@ -486,7 +480,7 @@ impl WorkerController {
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Self::spawn_inner(
|
||||
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
@@ -494,7 +488,8 @@ impl WorkerController {
|
||||
None,
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
Ok((handle, shutdown))
|
||||
}
|
||||
|
||||
/// Spawn a direct Worker while letting an in-process host select the
|
||||
@@ -505,6 +500,22 @@ impl WorkerController {
|
||||
bash_output_dir: &Path,
|
||||
transport: WorkerControllerTransport,
|
||||
) -> 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
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
@@ -535,7 +546,7 @@ impl WorkerController {
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Self::spawn_inner(
|
||||
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
@@ -543,7 +554,8 @@ impl WorkerController {
|
||||
None,
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
Ok((handle, shutdown))
|
||||
}
|
||||
|
||||
/// Spawn into an exact persistent `runs/<generation>` directory.
|
||||
@@ -573,6 +585,26 @@ impl WorkerController {
|
||||
bash_output_dir: &Path,
|
||||
transport: WorkerControllerTransport,
|
||||
) -> 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
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
@@ -598,7 +630,7 @@ impl WorkerController {
|
||||
runtime_managed: bool,
|
||||
runtime_run: Option<&Path>,
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
@@ -629,7 +661,7 @@ impl WorkerController {
|
||||
runtime_managed: bool,
|
||||
runtime_run: Option<&Path>,
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
@@ -699,6 +731,7 @@ impl WorkerController {
|
||||
target: bash_output_dir.clone(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
@@ -728,9 +761,9 @@ impl WorkerController {
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if let Some(session) = fs_for_view.as_ref() {
|
||||
wire_workdir_command_events(session, &in_flight);
|
||||
}
|
||||
let command_observer = fs_for_view
|
||||
.as_ref()
|
||||
.and_then(|session| wire_workdir_command_events(session, &in_flight));
|
||||
|
||||
// Intake role Workers self-terminate only after a successful
|
||||
// TicketIntakeReady turn has fully settled back to Idle. The request
|
||||
@@ -754,19 +787,11 @@ impl WorkerController {
|
||||
.await
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||
let greeting = build_greeting(&worker);
|
||||
let execution_generation = runtime_dir
|
||||
.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(
|
||||
let shared_state = Arc::new(WorkerSharedState::new(
|
||||
worker.manifest().worker.name.clone(),
|
||||
worker.segment_id(),
|
||||
manifest_toml.clone(),
|
||||
greeting,
|
||||
execution_generation,
|
||||
));
|
||||
if let Some(fs_for_view) = 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 notify_buffer = worker.notify_buffer_handle();
|
||||
|
||||
tokio::spawn(controller_loop(
|
||||
let controller_task = tokio::spawn(controller_loop(
|
||||
worker,
|
||||
method_rx,
|
||||
working_event_tx,
|
||||
@@ -819,26 +844,27 @@ impl WorkerController {
|
||||
shutdown_tx,
|
||||
socket_server,
|
||||
shutdown_after_idle,
|
||||
command_observer,
|
||||
));
|
||||
|
||||
Ok((handle, shutdown_rx))
|
||||
Ok((handle, shutdown_rx, controller_task))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wire_workdir_command_events(
|
||||
session: &Arc<dyn WorkdirSession>,
|
||||
in_flight: &InFlightEvents,
|
||||
) {
|
||||
) -> Option<tokio::task::JoinHandle<()>> {
|
||||
in_flight.replace_command_snapshot(protocol_command_snapshots(session.as_ref()));
|
||||
let Some(mut events) = session.subscribe_command_events() else {
|
||||
return;
|
||||
return None;
|
||||
};
|
||||
// Keep only a weak reference in the observer task. Holding the session
|
||||
// strongly here would keep its broadcast sender alive forever and prevent
|
||||
// the receiver from observing closure during Worker teardown.
|
||||
let session = Arc::downgrade(session);
|
||||
let in_flight = in_flight.clone();
|
||||
tokio::spawn(async move {
|
||||
Some(tokio::spawn(async move {
|
||||
loop {
|
||||
match events.recv().await {
|
||||
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,
|
||||
}
|
||||
}
|
||||
});
|
||||
}))
|
||||
}
|
||||
|
||||
fn protocol_command_snapshots(session: &dyn WorkdirSession) -> Vec<ProtocolCommandSnapshot> {
|
||||
@@ -1385,12 +1411,6 @@ where
|
||||
feature_registry
|
||||
.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(module) =
|
||||
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<()>,
|
||||
socket_server: Option<SocketServer>,
|
||||
shutdown_after_idle: ShutdownAfterIdleRequest,
|
||||
mut command_observer: Option<tokio::task::JoinHandle<()>>,
|
||||
) where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + 'static,
|
||||
@@ -2348,28 +2369,53 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
}
|
||||
|
||||
drop(_socket_server);
|
||||
if let Err(error) = runtime_dir.close_socket().await {
|
||||
tracing::warn!(%error, "Worker runtime socket cleanup failed");
|
||||
// Close method admission before any fallible child/session cleanup. Existing
|
||||
// senders then fail instead of accepting work that this execution can no
|
||||
// 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
|
||||
// Memory/Workdir teardown so they cannot observe a partially closed Worker.
|
||||
worker.stop_feature_runtime("controller shutdown").await;
|
||||
|
||||
let child_cleanup_succeeded = match spawned_registry.shutdown_internal().await {
|
||||
Ok(()) => true,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "Internal SubWorker cleanup failed before Workdir shutdown");
|
||||
false
|
||||
loop {
|
||||
match spawned_registry.shutdown_internal().await {
|
||||
Ok(()) => break,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "Internal SubWorker cleanup failed; retrying");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if child_cleanup_succeeded
|
||||
&& let Some(session) = worker.workdir_session()
|
||||
&& let Err(error) = session.close().await
|
||||
{
|
||||
tracing::warn!(%error, "Workdir session close failed");
|
||||
if let Some(session) = worker.workdir_session() {
|
||||
loop {
|
||||
match session.close().await {
|
||||
Ok(()) => break,
|
||||
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
|
||||
@@ -3376,7 +3422,7 @@ mod tests {
|
||||
.transition(WorkerState::Busy(WorkerBusyState::Run(
|
||||
WorkerRunState::Running,
|
||||
)));
|
||||
let command = WorkerCommandEnvelope::for_snapshot(1, &env.shared_state.snapshot());
|
||||
let command = WorkerCommandEnvelope::new(1);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
method_tx
|
||||
@@ -3659,7 +3705,7 @@ mod tests {
|
||||
.transition(WorkerState::Busy(WorkerBusyState::Run(
|
||||
WorkerRunState::Running,
|
||||
)));
|
||||
let command = WorkerCommandEnvelope::for_snapshot(1, &env.shared_state.snapshot());
|
||||
let command = WorkerCommandEnvelope::new(1);
|
||||
env._method_tx
|
||||
.send(Method::Compact { command })
|
||||
.await
|
||||
@@ -3710,8 +3756,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_admission_rejects_stale_generation_revision_and_order() {
|
||||
let shared = WorkerSharedState::new_with_generation(
|
||||
fn command_admission_rejects_stale_ids_and_reuse_conflicts() {
|
||||
let shared = WorkerSharedState::new(
|
||||
"worker".into(),
|
||||
session_store::new_segment_id(),
|
||||
String::new(),
|
||||
@@ -3725,39 +3771,10 @@ mod tests {
|
||||
context_window: 1,
|
||||
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!(
|
||||
validate_command(
|
||||
WorkerCommandEnvelope {
|
||||
command_id: 1,
|
||||
expected_execution_generation: 9,
|
||||
expected_worker_state_revision: 0,
|
||||
},
|
||||
WorkerCommandEnvelope { command_id: 1 },
|
||||
WorkerCommandKind::Pause,
|
||||
&shared,
|
||||
)
|
||||
@@ -3765,11 +3782,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
validate_command(
|
||||
WorkerCommandEnvelope {
|
||||
command_id: 1,
|
||||
expected_execution_generation: 9,
|
||||
expected_worker_state_revision: 0,
|
||||
},
|
||||
WorkerCommandEnvelope { command_id: 1 },
|
||||
WorkerCommandKind::Pause,
|
||||
&shared,
|
||||
),
|
||||
@@ -3777,11 +3790,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
validate_command(
|
||||
WorkerCommandEnvelope {
|
||||
command_id: 1,
|
||||
expected_execution_generation: 9,
|
||||
expected_worker_state_revision: 0,
|
||||
},
|
||||
WorkerCommandEnvelope { command_id: 1 },
|
||||
WorkerCommandKind::Cancel,
|
||||
&shared,
|
||||
),
|
||||
@@ -3789,11 +3798,7 @@ mod tests {
|
||||
);
|
||||
assert!(
|
||||
validate_command(
|
||||
WorkerCommandEnvelope {
|
||||
command_id: 2,
|
||||
expected_execution_generation: 9,
|
||||
expected_worker_state_revision: 1,
|
||||
},
|
||||
WorkerCommandEnvelope { command_id: 2 },
|
||||
WorkerCommandKind::Pause,
|
||||
&shared,
|
||||
)
|
||||
|
||||
@@ -743,6 +743,7 @@ fn comm_info_from_spawned_child(child: &session_store::WorkerSpawnedChild) -> Co
|
||||
target: rule.target.clone(),
|
||||
permission,
|
||||
recursive: rule.recursive,
|
||||
symlink_policy: rule.symlink_policy,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -1324,6 +1325,7 @@ mod tests {
|
||||
target: root.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
active_child_segment,
|
||||
)
|
||||
@@ -1795,6 +1797,7 @@ mod tests {
|
||||
target: PathBuf::from("/tmp"),
|
||||
permission: "read".into(),
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
callback_address: PathBuf::from("/tmp/parent.sock"),
|
||||
}
|
||||
|
||||
@@ -286,6 +286,7 @@ fn read_rule(target: PathBuf) -> ScopeRule {
|
||||
target,
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +295,7 @@ fn write_rule(target: PathBuf) -> ScopeRule {
|
||||
target,
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +308,7 @@ fn workspace_scope(
|
||||
target: workspace_root.to_path_buf(),
|
||||
permission,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
};
|
||||
let deny = deny_write
|
||||
.iter()
|
||||
@@ -711,6 +714,7 @@ permission = "write"
|
||||
target: target.to_path_buf(),
|
||||
permission,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2211,7 +2211,6 @@ pub enum FeatureInstallError {
|
||||
pub mod background;
|
||||
pub mod builtin;
|
||||
pub mod mcp;
|
||||
pub mod plugin;
|
||||
pub(crate) mod session;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2224,6 +2223,31 @@ mod tests {
|
||||
use serde_json::json;
|
||||
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)]
|
||||
struct DummyClient;
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
|
||||
use workdir::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -283,6 +284,26 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
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> {
|
||||
match self.operate(WorkdirSessionOperation::Stat(request))? {
|
||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||
@@ -863,9 +884,9 @@ mod tests {
|
||||
"repository_key": "main",
|
||||
"creation_selector": "refs/heads/main",
|
||||
"creation_ref": "0123456789abcdef",
|
||||
"materializer_kind": "local_git_worktree",
|
||||
"materializer_kind": "runtime_git_clone",
|
||||
"cleanup_target": {
|
||||
"kind": "git_worktree",
|
||||
"kind": "runtime_git_clone",
|
||||
"working_directory_id": id,
|
||||
"repository_key": "main"
|
||||
},
|
||||
@@ -1243,10 +1264,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn scoped_broker_operations_carry_no_child_context() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||
response(json!({
|
||||
"operation": "stat",
|
||||
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
||||
})),
|
||||
response(json!({ "operation": "authorize_scope" })),
|
||||
response(json!({ "operation": "authorize_scope" })),
|
||||
response(json!({
|
||||
"operation": "stat",
|
||||
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
||||
@@ -1261,6 +1280,7 @@ mod tests {
|
||||
target: workdir::WorkdirPath::new("").unwrap(),
|
||||
permission: workdir::WorkdirToolScopePermission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: workdir::WorkdirPath::new("").unwrap(),
|
||||
command: false,
|
||||
@@ -1275,7 +1295,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let requests = client.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests.len(), 3);
|
||||
for request in requests {
|
||||
assert_eq!(
|
||||
request.path,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -411,11 +411,13 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: secret.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
@@ -574,11 +576,13 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: secret.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
|
||||
@@ -23,6 +23,7 @@ pub(crate) struct InFlightInner {
|
||||
next_block_id: u64,
|
||||
blocks: Vec<TrackedBlock>,
|
||||
commands: Vec<CommandSnapshot>,
|
||||
compaction: Option<protocol::InFlightCompaction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -53,6 +54,7 @@ impl InFlightEvents {
|
||||
next_block_id: 1,
|
||||
blocks: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
compaction: None,
|
||||
})),
|
||||
working_event_tx,
|
||||
}
|
||||
@@ -219,6 +221,15 @@ impl InFlightEvents {
|
||||
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) {
|
||||
let mut inner = self.lock();
|
||||
inner.clear();
|
||||
@@ -378,6 +389,7 @@ impl InFlightInner {
|
||||
.filter_map(TrackedBlock::to_snapshot_block)
|
||||
.collect(),
|
||||
commands: self.commands.clone(),
|
||||
compaction: self.compaction.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,6 +752,45 @@ mod tests {
|
||||
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]
|
||||
fn clear_discards_uncommitted_blocks_without_protocol_event() {
|
||||
let (working_event_tx, _) = broadcast::channel(16);
|
||||
|
||||
@@ -127,7 +127,6 @@ where
|
||||
// parent manifest cannot accidentally grant its normal public tool surface
|
||||
// or recursively schedule Feature-owned background work.
|
||||
manifest.feature = Default::default();
|
||||
manifest.plugins = Default::default();
|
||||
manifest.mcp = Default::default();
|
||||
manifest.skills = None;
|
||||
manifest.compaction = None;
|
||||
@@ -297,7 +296,6 @@ impl InternalWorkerSessionStatus {
|
||||
|
||||
fn send_internal_worker_state(
|
||||
event_tx: &broadcast::Sender<Event>,
|
||||
state_revision: &std::sync::atomic::AtomicU64,
|
||||
status: InternalWorkerSessionStatus,
|
||||
) {
|
||||
let state = match status {
|
||||
@@ -314,13 +312,8 @@ fn send_internal_worker_state(
|
||||
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 {
|
||||
snapshot: protocol::WorkerStateSnapshot {
|
||||
execution_generation: 1,
|
||||
revision,
|
||||
last_command_id: 0,
|
||||
state,
|
||||
},
|
||||
@@ -383,7 +376,6 @@ pub(crate) struct InternalWorkerSessionSnapshot {
|
||||
pub(crate) struct InternalWorkerSessionHandle {
|
||||
command_tx: tokio::sync::mpsc::Sender<InternalWorkerSessionCommand>,
|
||||
status: Arc<std::sync::atomic::AtomicU8>,
|
||||
state_revision: Arc<std::sync::atomic::AtomicU64>,
|
||||
store: EphemeralSessionStore,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
@@ -434,7 +426,7 @@ impl InternalWorkerSessionHandle {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -681,7 +673,6 @@ pub(crate) fn prepare_internal_worker_from_spec(
|
||||
} = spec;
|
||||
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
||||
manifest.feature = Default::default();
|
||||
manifest.plugins = Default::default();
|
||||
manifest.mcp = Default::default();
|
||||
manifest.skills = None;
|
||||
manifest.compaction = None;
|
||||
@@ -805,13 +796,11 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
let status = Arc::new(std::sync::atomic::AtomicU8::new(
|
||||
InternalWorkerSessionStatus::Idle.encode(),
|
||||
));
|
||||
let state_revision = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let state_changed = Arc::new(tokio::sync::Notify::new());
|
||||
let last_error = Arc::new(Mutex::new(None));
|
||||
let handle = InternalWorkerSessionHandle {
|
||||
command_tx,
|
||||
status: status.clone(),
|
||||
state_revision: state_revision.clone(),
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
@@ -847,11 +836,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
message,
|
||||
});
|
||||
}
|
||||
send_internal_worker_state(
|
||||
&event_tx,
|
||||
&state_revision,
|
||||
turn_status,
|
||||
);
|
||||
send_internal_worker_state(&event_tx, turn_status);
|
||||
if let Some(callback) = &on_turn_end {
|
||||
callback(turn_status);
|
||||
}
|
||||
@@ -893,11 +878,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
InternalWorkerSessionStatus::Stopped.encode(),
|
||||
std::sync::atomic::Ordering::Release,
|
||||
);
|
||||
send_internal_worker_state(
|
||||
&event_tx,
|
||||
&state_revision,
|
||||
InternalWorkerSessionStatus::Stopped,
|
||||
);
|
||||
send_internal_worker_state(&event_tx, InternalWorkerSessionStatus::Stopped);
|
||||
let _ = event_tx.send(Event::Shutdown);
|
||||
state_changed.notify_waiters();
|
||||
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(
|
||||
InternalWorkerSessionStatus::Idle.encode(),
|
||||
)),
|
||||
state_revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
|
||||
@@ -25,7 +25,7 @@ use arc_swap::ArcSwap;
|
||||
use async_trait::async_trait;
|
||||
use tracing::info;
|
||||
|
||||
use crate::compact::state::CompactState;
|
||||
use crate::compact::state::{AutomaticCompactDecision, CompactState};
|
||||
use crate::compact::usage_tracker::UsageTracker;
|
||||
use session_store::SystemItem;
|
||||
|
||||
@@ -111,6 +111,9 @@ pub(crate) struct WorkerInterceptor {
|
||||
tool_calls_this_turn: AtomicUsize,
|
||||
}
|
||||
|
||||
const THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC: &str =
|
||||
"automatic compaction could not make the provider request context safe";
|
||||
|
||||
impl WorkerInterceptor {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(
|
||||
@@ -229,27 +232,45 @@ impl WorkerInterceptor {
|
||||
Some(total_tokens(context, &records).tokens)
|
||||
}
|
||||
|
||||
fn request_threshold_exceeded(&self, current_tokens: Option<u64>, context: &[Item]) -> bool {
|
||||
if let Some(state) = self.compact_state.as_ref() {
|
||||
if !state.is_disabled() && !state.just_compacted() {
|
||||
let current = current_tokens.unwrap_or(0);
|
||||
if state.exceeds_request(current) {
|
||||
let shape = context_shape(context);
|
||||
info!(
|
||||
input_tokens = current,
|
||||
threshold = state.request_threshold().unwrap_or(0),
|
||||
items_len = shape.items_len,
|
||||
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 compaction threshold exceeded, yielding"
|
||||
);
|
||||
return true;
|
||||
fn request_compact_decision(
|
||||
&self,
|
||||
current_tokens: Option<u64>,
|
||||
context: &[Item],
|
||||
) -> AutomaticCompactDecision {
|
||||
let Some(state) = self.compact_state.as_ref() else {
|
||||
return AutomaticCompactDecision::Continue;
|
||||
};
|
||||
let current = current_tokens.unwrap_or(0);
|
||||
let decision = state.evaluate_request(current);
|
||||
if !matches!(decision, AutomaticCompactDecision::Continue) {
|
||||
let shape = context_shape(context);
|
||||
info!(
|
||||
input_tokens = current,
|
||||
?decision,
|
||||
items_len = shape.items_len,
|
||||
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]) {
|
||||
let prompts = self.prompts.load();
|
||||
@@ -388,8 +409,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
let context = context.items;
|
||||
let initial_tokens = self.estimated_tokens(context);
|
||||
if self.request_threshold_exceeded(initial_tokens, context) {
|
||||
return Ok(PreRequestAction::Yield);
|
||||
if let Some(action) =
|
||||
self.decision_action(self.request_compact_decision(initial_tokens, context))
|
||||
{
|
||||
return Ok(action);
|
||||
}
|
||||
let info = PreRequestInfo {
|
||||
item_count: context.len(),
|
||||
@@ -423,6 +446,22 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
return Ok(PreRequestAction::Cancel(reason));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -445,16 +484,26 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
};
|
||||
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) {
|
||||
return Ok(PreRequestAction::Cancel(format!(
|
||||
"session persistence failed: {error}"
|
||||
)));
|
||||
}
|
||||
return Ok(if appended_items.is_empty() {
|
||||
PreRequestAction::Yield
|
||||
} else {
|
||||
PreRequestAction::YieldWith(appended_items)
|
||||
return Ok(match compact_decision {
|
||||
AutomaticCompactDecision::Start(_) if !appended_items.is_empty() => {
|
||||
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())
|
||||
}
|
||||
|
||||
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 {
|
||||
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]
|
||||
async fn pre_llm_request_yields_and_skips_hooks_when_request_threshold_exceeded() {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
@@ -761,7 +852,7 @@ mod tests {
|
||||
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
registry,
|
||||
Some(state),
|
||||
Some(Arc::clone(&state)),
|
||||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -779,6 +870,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(action, PreRequestAction::Yield));
|
||||
assert!(state.has_claimed_attempt());
|
||||
// Hook must not run when an internal mechanism short-circuits first.
|
||||
assert_eq!(count.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
@@ -798,7 +890,7 @@ mod tests {
|
||||
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
registry,
|
||||
Some(state),
|
||||
Some(Arc::clone(&state)),
|
||||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -821,10 +913,48 @@ mod tests {
|
||||
PreRequestAction::YieldWith(items) => assert_eq!(items.len(), 1),
|
||||
other => panic!("expected YieldWith queued system item, got {other:?}"),
|
||||
}
|
||||
assert!(state.has_claimed_attempt());
|
||||
assert!(saw_handle.load(Ordering::Relaxed));
|
||||
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]
|
||||
async fn pre_llm_request_counts_in_flight_usage_records() {
|
||||
let registry = Arc::new(HookRegistryBuilder::new().build());
|
||||
@@ -843,7 +973,7 @@ mod tests {
|
||||
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
registry,
|
||||
Some(state),
|
||||
Some(Arc::clone(&state)),
|
||||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -875,7 +1005,7 @@ mod tests {
|
||||
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
registry,
|
||||
Some(state),
|
||||
Some(Arc::clone(&state)),
|
||||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -923,7 +1053,7 @@ mod tests {
|
||||
let history = Arc::new(Mutex::new(vec![record]));
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
registry,
|
||||
Some(state),
|
||||
Some(Arc::clone(&state)),
|
||||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -957,7 +1087,7 @@ mod tests {
|
||||
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
registry,
|
||||
Some(state),
|
||||
Some(Arc::clone(&state)),
|
||||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
|
||||
@@ -4,7 +4,8 @@ use std::path::PathBuf;
|
||||
|
||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||
use tokio::net::UnixListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
use crate::controller::WorkerHandle;
|
||||
use crate::ipc::protocol_session::{
|
||||
@@ -19,7 +20,8 @@ use protocol::{ErrorCode, Event};
|
||||
/// - Client writes Method lines → forwarded to WorkerController
|
||||
/// - Worker events → written as Event lines to all connected clients
|
||||
pub struct SocketServer {
|
||||
_accept_task: JoinHandle<()>,
|
||||
accept_task: Option<JoinHandle<()>>,
|
||||
shutdown: Option<oneshot::Sender<()>>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -33,20 +35,45 @@ impl SocketServer {
|
||||
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
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 {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let handle = handle.clone();
|
||||
tokio::spawn(handle_connection(stream, handle));
|
||||
tokio::select! {
|
||||
_ = &mut shutdown_rx => break,
|
||||
accepted = listener.accept() => match accepted {
|
||||
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.
|
||||
@@ -57,6 +84,10 @@ impl SocketServer {
|
||||
|
||||
impl Drop for SocketServer {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +299,7 @@ mod tests {
|
||||
target: "/tmp/work".into(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
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),
|
||||
permission: Permission::Write,
|
||||
recursive,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ pub(crate) fn read_rule(path: &str, recursive: bool) -> ScopeRule {
|
||||
target: PathBuf::from(path),
|
||||
permission: Permission::Read,
|
||||
recursive,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ pub(crate) enum WorkerCommandAdmission {
|
||||
Retry,
|
||||
Conflict,
|
||||
StaleCommandId,
|
||||
ExecutionGenerationMismatch,
|
||||
StateRevisionMismatch,
|
||||
}
|
||||
|
||||
/// Shared state between WorkerController and runtime directory.
|
||||
@@ -59,23 +57,13 @@ impl WorkerSharedState {
|
||||
segment_id: SegmentId,
|
||||
manifest_toml: String,
|
||||
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 {
|
||||
worker_name,
|
||||
segment_id,
|
||||
manifest_toml,
|
||||
greeting,
|
||||
state: RwLock::new(WorkerStateSnapshot::initial(execution_generation)),
|
||||
state: RwLock::new(WorkerStateSnapshot::initial()),
|
||||
accepted_commands: RwLock::new(VecDeque::new()),
|
||||
fs_view: OnceLock::new(),
|
||||
flow_transition_enabled: AtomicBool::new(false),
|
||||
@@ -108,7 +96,6 @@ impl WorkerSharedState {
|
||||
.write()
|
||||
.expect("worker state lock poisoned; refusing an inferred fallback state");
|
||||
if snapshot.state != state {
|
||||
snapshot.revision = snapshot.revision.saturating_add(1);
|
||||
snapshot.state = state;
|
||||
}
|
||||
snapshot.clone()
|
||||
@@ -118,7 +105,6 @@ impl WorkerSharedState {
|
||||
&self,
|
||||
envelope: WorkerCommandEnvelope,
|
||||
kind: WorkerCommandKind,
|
||||
require_state_revision: bool,
|
||||
) -> WorkerCommandAdmission {
|
||||
let mut snapshot = self
|
||||
.state
|
||||
@@ -138,18 +124,11 @@ impl WorkerSharedState {
|
||||
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 {
|
||||
return WorkerCommandAdmission::StaleCommandId;
|
||||
}
|
||||
|
||||
snapshot.last_command_id = envelope.command_id;
|
||||
snapshot.revision = snapshot.revision.saturating_add(1);
|
||||
accepted.push_back(AcceptedWorkerCommand {
|
||||
envelope,
|
||||
kind,
|
||||
@@ -248,12 +227,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_state() -> WorkerSharedState {
|
||||
WorkerSharedState::new_with_generation(
|
||||
WorkerSharedState::new(
|
||||
"test-worker".into(),
|
||||
session_store::new_segment_id(),
|
||||
"[engine]\nname = \"test-worker\"".into(),
|
||||
test_greeting(),
|
||||
7,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -273,43 +251,34 @@ mod tests {
|
||||
#[test]
|
||||
fn initial_snapshot_is_idle() {
|
||||
let state = test_state();
|
||||
assert_eq!(state.snapshot(), WorkerStateSnapshot::initial(7));
|
||||
assert_eq!(state.snapshot(), WorkerStateSnapshot::initial());
|
||||
assert_eq!(state.catalog_status(), WorkerStatus::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_increment_revision_only_when_state_changes() {
|
||||
fn transitions_publish_full_state() {
|
||||
let state = test_state();
|
||||
let running = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running));
|
||||
let snapshot = state.transition(running.clone());
|
||||
assert_eq!(snapshot.revision, 1);
|
||||
assert_eq!(snapshot.state, running);
|
||||
assert_eq!(state.transition(running).revision, 1);
|
||||
assert_eq!(state.transition(running.clone()).state, running);
|
||||
|
||||
let paused = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused));
|
||||
let snapshot = state.transition(paused.clone());
|
||||
assert_eq!(snapshot.revision, 2);
|
||||
assert_eq!(snapshot.state, paused);
|
||||
assert_eq!(snapshot.last_command_id, 0);
|
||||
assert_eq!(state.catalog_status(), WorkerStatus::Paused);
|
||||
}
|
||||
|
||||
#[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 envelope = WorkerCommandEnvelope {
|
||||
command_id: 9,
|
||||
expected_execution_generation: 7,
|
||||
expected_worker_state_revision: 0,
|
||||
};
|
||||
let envelope = WorkerCommandEnvelope { command_id: 9 };
|
||||
assert_eq!(
|
||||
state.admit_command(envelope, WorkerCommandKind::Pause, true),
|
||||
state.admit_command(envelope, WorkerCommandKind::Pause),
|
||||
WorkerCommandAdmission::Accepted
|
||||
);
|
||||
assert_eq!(
|
||||
state.snapshot(),
|
||||
WorkerStateSnapshot {
|
||||
execution_generation: 7,
|
||||
revision: 1,
|
||||
last_command_id: 9,
|
||||
state: WorkerState::Idle,
|
||||
}
|
||||
@@ -325,14 +294,14 @@ mod tests {
|
||||
Some(Some(WorkerCommandDisposition::Accepted))
|
||||
);
|
||||
assert_eq!(
|
||||
state.admit_command(envelope, WorkerCommandKind::Pause, true),
|
||||
state.admit_command(envelope, WorkerCommandKind::Pause),
|
||||
WorkerCommandAdmission::Retry
|
||||
);
|
||||
assert_eq!(
|
||||
state.admit_command(envelope, WorkerCommandKind::Cancel, true),
|
||||
state.admit_command(envelope, WorkerCommandKind::Cancel),
|
||||
WorkerCommandAdmission::Conflict
|
||||
);
|
||||
assert_eq!(state.snapshot().revision, 1);
|
||||
assert_eq!(state.snapshot().last_command_id, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -343,8 +312,8 @@ mod tests {
|
||||
)));
|
||||
let parsed: serde_json::Value = serde_json::from_str(&state.status_json()).unwrap();
|
||||
assert_eq!(parsed["state"], "running");
|
||||
assert_eq!(parsed["worker_state"]["execution_generation"], 7);
|
||||
assert_eq!(parsed["worker_state"]["revision"], 1);
|
||||
assert!(parsed["worker_state"].get("execution_generation").is_none());
|
||||
assert!(parsed["worker_state"].get("revision").is_none());
|
||||
assert_eq!(parsed["worker_state"]["state"]["kind"], "busy");
|
||||
assert_eq!(parsed["worker_name"], "test-worker");
|
||||
assert!(parsed["segment_id"].is_string());
|
||||
|
||||
@@ -138,10 +138,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let method = Method::Shutdown {
|
||||
command: protocol::WorkerCommandEnvelope::for_snapshot(
|
||||
1,
|
||||
&protocol::WorkerStateSnapshot::initial(1),
|
||||
),
|
||||
command: protocol::WorkerCommandEnvelope::new(1),
|
||||
};
|
||||
connect_and_send(&socket, &method).await.unwrap();
|
||||
|
||||
|
||||
@@ -1030,6 +1030,7 @@ fn record_from_worker_state(child: &WorkerSpawnedChild) -> io::Result<SpawnedWor
|
||||
target: rule.target.clone(),
|
||||
permission,
|
||||
recursive: rule.recursive,
|
||||
symlink_policy: rule.symlink_policy,
|
||||
})
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
@@ -1072,6 +1073,7 @@ mod tests {
|
||||
target: std::path::PathBuf::from("/tmp"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
@@ -1090,6 +1092,7 @@ mod tests {
|
||||
target: root.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
@@ -1109,6 +1112,7 @@ mod tests {
|
||||
target: workdir::WorkdirPath::new("").unwrap(),
|
||||
permission: workdir::WorkdirToolScopePermission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: workdir::WorkdirPath::new("").unwrap(),
|
||||
command: false,
|
||||
|
||||
@@ -16,8 +16,8 @@ use manifest::{
|
||||
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
|
||||
PermissionConfigPartial, ProfileDiscovery, ProfileError, ProfileRegistry,
|
||||
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
|
||||
ScopeRule, SessionConfigPartial, ToolOutputLimitsPartial, WorkerManifest, WorkerManifestConfig,
|
||||
WorkerMetaConfig,
|
||||
ScopeRule, SessionConfigPartial, SymlinkPolicy, ToolOutputLimitsPartial, WorkerManifest,
|
||||
WorkerManifestConfig, WorkerMetaConfig,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -61,7 +61,9 @@ struct SubWorkerSpawnInput {
|
||||
task: String,
|
||||
/// Allow rules delegated to the spawned SubWorker. Must be a subset of the
|
||||
/// 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>,
|
||||
/// Explicitly grant command execution through the parent-owned Workdir tool broker.
|
||||
#[serde(default)]
|
||||
@@ -88,6 +90,27 @@ struct ScopeRuleInput {
|
||||
/// children only. Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
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)]
|
||||
@@ -506,6 +529,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
target: child_bash_output_dir.clone(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
@@ -707,6 +731,7 @@ fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirToolScopeR
|
||||
PermissionInput::Write => WorkdirToolScopePermission::Write,
|
||||
},
|
||||
recursive: rule.recursive,
|
||||
symlink_policy: rule.symlink_policy.into(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -926,7 +951,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
|
||||
rules: p.rules.clone(),
|
||||
}),
|
||||
feature: manifest.feature.clone().into(),
|
||||
plugins: manifest.plugins.clone(),
|
||||
mcp: manifest.mcp.clone(),
|
||||
compaction: manifest
|
||||
.compaction
|
||||
@@ -1074,21 +1098,26 @@ mod tests {
|
||||
target: ".".to_string(),
|
||||
permission: PermissionInput::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
ScopeRuleInput {
|
||||
target: "src".to_string(),
|
||||
permission: PermissionInput::Write,
|
||||
recursive: false,
|
||||
symlink_policy: SymlinkPolicyInput::Logical,
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(rules[0].target.as_str(), "");
|
||||
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"] {
|
||||
let error = parse_workdir_scope(&[ScopeRuleInput {
|
||||
target: target.to_string(),
|
||||
permission: PermissionInput::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||
@@ -1126,6 +1155,7 @@ mod tests {
|
||||
target: path.to_path_buf(),
|
||||
permission,
|
||||
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 == "Bash"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
remote_client.requests().is_empty(),
|
||||
"spawning a child must not open or delegate a provider Workdir session"
|
||||
);
|
||||
let requests = remote_client.requests();
|
||||
assert!(!requests.is_empty());
|
||||
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]
|
||||
@@ -1548,6 +1582,9 @@ enabled = false
|
||||
.expect("schema properties");
|
||||
assert!(properties.contains_key("cwd"), "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
|
||||
.get("required")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
@@ -1708,10 +1745,29 @@ enabled = false
|
||||
self.requests
|
||||
.lock()
|
||||
.expect("remote Workdir request lock")
|
||||
.push(request);
|
||||
Err(WorkspaceClientError::Request(
|
||||
"SubWorker spawn must not call the remote Workdir provider".into(),
|
||||
))
|
||||
.push(request.clone());
|
||||
let operation: workdir::workspace::WorkspaceWorkdirSessionOperationRequest =
|
||||
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"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}];
|
||||
|
||||
let config_json =
|
||||
|
||||
+399
-357
File diff suppressed because it is too large
Load Diff
@@ -8,23 +8,112 @@
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
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::{ClientError, LlmClient, Request};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use protocol::{Event, Method, RunResult};
|
||||
use session_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
|
||||
use session_store::{FsStore, LogEntry, Store};
|
||||
use session_store::{
|
||||
CombinedStore, FsStore, FsWorkerStore, LogEntry, Store, WorkerMetadata, WorkerMetadataStore,
|
||||
WorkerStoreError,
|
||||
};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use worker::{Worker, WorkerController};
|
||||
use worker::{Worker, WorkerController, WorkerError};
|
||||
|
||||
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 {
|
||||
session_store::LoggedHistoryEntry {
|
||||
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
|
||||
// the first time we check after a run.
|
||||
const POST_RUN_MANIFEST_TOML: &str = r#"
|
||||
@@ -168,6 +300,27 @@ target = "./"
|
||||
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
|
||||
// path. `compact_threshold` is left unset so the post-run check stays inert.
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
fn drain(rx: &mut broadcast::Receiver<Event>) -> Vec<Event> {
|
||||
let mut out = Vec::new();
|
||||
@@ -282,6 +470,118 @@ fn system_texts_in_sink_session_start(
|
||||
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
|
||||
/// active once the first SegmentStart is materialized by `run`.
|
||||
#[tokio::test]
|
||||
@@ -411,16 +711,18 @@ permission = "write"
|
||||
async fn compact_emits_session_start_carrying_summary_and_task_snapshot() {
|
||||
let client = MockClient::new(vec![
|
||||
single_text_events("hi"),
|
||||
write_summary_tool_use_events("call-1", "summary"),
|
||||
single_text_events("done"),
|
||||
write_summary_tool_use_events_with_usage("call-1", "summary", 100, 10, 5, 20),
|
||||
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);
|
||||
worker.attach_working_event_tx(tx);
|
||||
|
||||
worker.run_text("first").await.unwrap();
|
||||
let session_id = worker.session_id();
|
||||
let source_segment_id = worker.segment_id();
|
||||
worker.compact(10_000).await.unwrap();
|
||||
let compacted_segment_id = worker.segment_id();
|
||||
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]")),
|
||||
"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]
|
||||
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
|
||||
// emits write_summary then closes (two LLM calls inside the compact
|
||||
// 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);
|
||||
|
||||
let events = drain(&mut rx);
|
||||
let kinds: Vec<&str> = 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
|
||||
let progress = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::CompactStart { lifecycle } => Some(lifecycle),
|
||||
Event::CompactionProgress { compaction } => {
|
||||
Some(compaction.as_ref().map(|item| item.phase))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
starts.len(),
|
||||
2,
|
||||
"start and Internal Worker binding revisions"
|
||||
progress,
|
||||
vec![
|
||||
Some(protocol::CompactionPhase::Preparing),
|
||||
Some(protocol::CompactionPhase::Summarizing),
|
||||
Some(protocol::CompactionPhase::Committing),
|
||||
None,
|
||||
]
|
||||
);
|
||||
assert_eq!(starts[0].compaction_id, starts[1].compaction_id);
|
||||
assert_eq!(starts[0].revision, 1);
|
||||
assert!(starts[0].internal_worker.is_none());
|
||||
assert_eq!(starts[1].revision, 2);
|
||||
assert!(matches!(
|
||||
starts[1].internal_worker.as_ref().map(|worker| &worker.kind),
|
||||
Some(protocol::InternalWorkerKind::Service { kind }) if kind == "compaction"
|
||||
));
|
||||
assert!(events.iter().all(|event| !matches!(
|
||||
event,
|
||||
Event::CompactStart { .. } | Event::CompactDone { .. } | Event::CompactFailed { .. }
|
||||
)));
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
Event::InternalWorker { worker, .. }
|
||||
if matches!(&worker.kind, protocol::InternalWorkerKind::Service { kind } if kind == "compaction")
|
||||
)), "compactor activity must be projected through the parent stream");
|
||||
let completed = events
|
||||
.iter()
|
||||
.find_map(|event| match event {
|
||||
Event::CompactDone { lifecycle } => Some(lifecycle),
|
||||
_ => None,
|
||||
})
|
||||
.expect("completed lifecycle");
|
||||
assert_eq!(completed.compaction_id, starts[0].compaction_id);
|
||||
assert_eq!(completed.revision, 3);
|
||||
assert_eq!(completed.summary.as_deref(), Some("summary"));
|
||||
assert_eq!(completed.state, protocol::CompactionLifecycleState::Done);
|
||||
let done_index = events
|
||||
.iter()
|
||||
.position(|event| matches!(event, Event::CompactDone { .. }))
|
||||
.expect("done event");
|
||||
let removed_index = events
|
||||
.iter()
|
||||
.position(|event| matches!(event, Event::InternalWorkerRemoved { .. }))
|
||||
.expect("terminal compactor session must be released");
|
||||
assert!(
|
||||
done_index < removed_index,
|
||||
"terminal lifecycle precedes release fence"
|
||||
);
|
||||
|
||||
// CompactDone carries the new Segment ID; the Session ID is unchanged.
|
||||
let new_id_in_event = events.iter().find_map(|e| match e {
|
||||
Event::CompactDone { lifecycle } => lifecycle
|
||||
.new_segment_id
|
||||
.as_deref()
|
||||
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
|
||||
_ => None,
|
||||
});
|
||||
assert!(new_id_in_event.is_some(), "CompactDone missing");
|
||||
assert_eq!(new_id_in_event.unwrap(), worker.segment_id());
|
||||
let active_entries = worker
|
||||
.store()
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap();
|
||||
assert!(!active_entries.iter().any(|entry| matches!(
|
||||
entry,
|
||||
LogEntry::Extension { domain, .. } if domain == "yoi.compaction"
|
||||
)));
|
||||
let metrics = session_metrics::read_session_metrics(worker.store(), session_before).unwrap();
|
||||
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]
|
||||
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.
|
||||
//
|
||||
// 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).
|
||||
// [3] resume() after compact makes one more LLM call.
|
||||
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"),
|
||||
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);
|
||||
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();
|
||||
|
||||
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()
|
||||
.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:?}"
|
||||
.find(|record| record.metric.name == "compact.start")
|
||||
.unwrap();
|
||||
assert_eq!(start.metric.dimensions["trigger"], "request_threshold");
|
||||
assert_eq!(
|
||||
start.metric.dimensions["threshold_policy"],
|
||||
"request_threshold"
|
||||
);
|
||||
assert!(
|
||||
!kinds.contains(&"failed"),
|
||||
"unexpected CompactFailed in {kinds:?}"
|
||||
);
|
||||
|
||||
let new_id_in_event = events.iter().find_map(|e| match e {
|
||||
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()));
|
||||
let correlation_id = start.metric.correlation_id.as_deref().unwrap();
|
||||
let post = metrics
|
||||
.iter()
|
||||
.find(|record| record.metric.name == "compact.post_request")
|
||||
.unwrap();
|
||||
assert_eq!(post.metric.correlation_id.as_deref(), Some(correlation_id));
|
||||
}
|
||||
|
||||
#[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
|
||||
// compact worker which immediately exhausts the mock → failure.
|
||||
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();
|
||||
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;
|
||||
|
||||
let events = drain(&mut rx);
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Event::CompactStart { .. } => "start",
|
||||
Event::CompactDone { .. } => "done",
|
||||
Event::CompactFailed { .. } => "failed",
|
||||
_ => "other",
|
||||
})
|
||||
.collect();
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
Event::CompactionProgress { compaction: Some(progress) }
|
||||
if progress.phase == protocol::CompactionPhase::Preparing
|
||||
)));
|
||||
assert!(
|
||||
kinds.contains(&"start") && kinds.contains(&"failed"),
|
||||
"expected CompactStart + CompactFailed in {kinds:?}"
|
||||
);
|
||||
assert!(
|
||||
!kinds.contains(&"done"),
|
||||
"unexpected CompactDone in {kinds:?}"
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, Event::CompactionProgress { compaction: None }))
|
||||
);
|
||||
assert!(events.iter().all(|event| !matches!(
|
||||
event,
|
||||
Event::CompactStart { .. } | Event::CompactDone { .. } | Event::CompactFailed { .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
||||
async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
||||
let worker =
|
||||
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new()).await;
|
||||
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
|
||||
.send(Method::Compact { command: compact })
|
||||
.await
|
||||
@@ -693,13 +1146,15 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
||||
.await
|
||||
.expect("timeout waiting for compact start")
|
||||
.expect("event"),
|
||||
Event::CompactStart { .. }
|
||||
Event::CompactionProgress {
|
||||
compaction: Some(_)
|
||||
}
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let cancel = protocol::WorkerCommandEnvelope::for_snapshot(2, &handle.shared_state.snapshot());
|
||||
let cancel = protocol::WorkerCommandEnvelope::new(2);
|
||||
handle
|
||||
.send(Method::Cancel { command: cancel })
|
||||
.await
|
||||
@@ -712,9 +1167,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
||||
.expect("timeout waiting for compact cancellation")
|
||||
.expect("event")
|
||||
{
|
||||
Event::CompactFailed { lifecycle }
|
||||
if lifecycle.state == protocol::CompactionLifecycleState::Interrupted =>
|
||||
{
|
||||
Event::CompactionProgress { compaction: None } => {
|
||||
saw_interrupted = true;
|
||||
}
|
||||
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
|
||||
.send(Method::Compact { command: compact })
|
||||
.await
|
||||
@@ -741,13 +1194,14 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
||||
.await
|
||||
.expect("timeout waiting for second compact start")
|
||||
.expect("event"),
|
||||
Event::CompactStart { .. }
|
||||
Event::CompactionProgress {
|
||||
compaction: Some(_)
|
||||
}
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let shutdown =
|
||||
protocol::WorkerCommandEnvelope::for_snapshot(4, &handle.shared_state.snapshot());
|
||||
let shutdown = protocol::WorkerCommandEnvelope::new(4);
|
||||
handle
|
||||
.send(Method::Shutdown { command: shutdown })
|
||||
.await
|
||||
@@ -759,9 +1213,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
||||
.expect("timeout waiting for shutdown")
|
||||
.expect("event")
|
||||
{
|
||||
Event::CompactFailed { lifecycle }
|
||||
if lifecycle.state == protocol::CompactionLifecycleState::Interrupted =>
|
||||
{
|
||||
Event::CompactionProgress { compaction: None } => {
|
||||
interrupted_before_shutdown = true;
|
||||
}
|
||||
Event::Shutdown => {
|
||||
@@ -781,14 +1233,14 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
||||
}
|
||||
|
||||
#[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![
|
||||
text_events_with_usage("hi", 1000),
|
||||
write_summary_tool_use_events("manual-summary", "manual compact summary"),
|
||||
single_text_events("done"),
|
||||
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 bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
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
|
||||
.send(Method::Compact { command })
|
||||
.await
|
||||
@@ -828,14 +1280,12 @@ async fn controller_compact_method_emits_start_and_done() {
|
||||
.expect("timeout waiting for compact events")
|
||||
.expect("event")
|
||||
{
|
||||
Event::CompactStart { .. } => saw_start = true,
|
||||
Event::CompactDone { .. } => {
|
||||
Event::CompactionProgress {
|
||||
compaction: Some(_),
|
||||
} => saw_start = true,
|
||||
Event::CompactionProgress { compaction: None } => {
|
||||
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,
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -27,11 +27,8 @@ type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||
|
||||
static NEXT_COMMAND_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
fn worker_command(handle: &WorkerHandle) -> protocol::WorkerCommandEnvelope {
|
||||
protocol::WorkerCommandEnvelope::for_snapshot(
|
||||
NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed),
|
||||
&handle.shared_state.snapshot(),
|
||||
)
|
||||
fn worker_command(_handle: &WorkerHandle) -> protocol::WorkerCommandEnvelope {
|
||||
protocol::WorkerCommandEnvelope::new(NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[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]
|
||||
async fn socket_run_receives_events() {
|
||||
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(),
|
||||
permission: "write".into(),
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
});
|
||||
store.write(&metadata).unwrap();
|
||||
@@ -35,6 +36,7 @@ async fn restore_reclaims_and_clears_legacy_process_children() {
|
||||
target: scope_root.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
};
|
||||
let parent_scope = SharedScope::new(
|
||||
Scope::from_config(&ScopeConfig {
|
||||
|
||||
@@ -36,6 +36,7 @@ async fn legacy_callback_cannot_register_process_subworker_authority() {
|
||||
target: scope_root.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
+443
-30
@@ -365,23 +365,23 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> {
|
||||
///
|
||||
/// Local paths remain distinct from network Git transports so callers cannot
|
||||
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositorySourceKind {
|
||||
LocalPath,
|
||||
File,
|
||||
Ssh,
|
||||
Http,
|
||||
Https,
|
||||
/// A legacy value that could not be classified during migration. It remains
|
||||
/// inspectable but every provider operation must fail closed.
|
||||
/// inspectable but every provider operation must fail closed. Historical
|
||||
/// `http` wire values decode into this non-executable classification.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl RepositorySourceKind {
|
||||
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 {
|
||||
@@ -389,7 +389,6 @@ impl RepositorySourceKind {
|
||||
Self::LocalPath => "local_path",
|
||||
Self::File => "file",
|
||||
Self::Ssh => "ssh",
|
||||
Self::Http => "http",
|
||||
Self::Https => "https",
|
||||
Self::Invalid => "invalid",
|
||||
}
|
||||
@@ -400,14 +399,28 @@ impl RepositorySourceKind {
|
||||
"local_path" => Self::LocalPath,
|
||||
"file" => Self::File,
|
||||
"ssh" => Self::Ssh,
|
||||
"http" => Self::Http,
|
||||
"https" => Self::Https,
|
||||
"invalid" => Self::Invalid,
|
||||
"http" | "invalid" => Self::Invalid,
|
||||
_ => 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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -507,6 +520,25 @@ pub struct WorkspaceRepositoryRecord {
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -577,7 +609,7 @@ pub struct WorkspaceResponse {
|
||||
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)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -607,6 +639,64 @@ pub struct WorkspaceMetadataMutationResponse {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Lifecycle state for a Workspace-scoped Ed25519 signing identity.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceSigningIdentityState {
|
||||
PendingProvisioning,
|
||||
Active,
|
||||
}
|
||||
|
||||
/// Public metadata for a Workspace signing identity. Private material and its
|
||||
/// storage reference are deliberately not part of this wire authority.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceSigningIdentityPublic {
|
||||
pub workspace_id: String,
|
||||
pub key_id: String,
|
||||
pub algorithm: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub public_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub public_key_fingerprint: Option<String>,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub revision: u64,
|
||||
pub state: WorkspaceSigningIdentityState,
|
||||
pub created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub provisioned_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Copyable public trust bundle consumed by future Runtime enrollment work.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspacePublicIdentityBundle {
|
||||
pub workspace_id: String,
|
||||
pub backend_url: String,
|
||||
pub key_id: String,
|
||||
pub algorithm: String,
|
||||
pub public_key: String,
|
||||
pub public_key_fingerprint: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceSigningIdentityResponse {
|
||||
pub identity: WorkspaceSigningIdentityPublic,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||
pub public_bundle: Option<WorkspacePublicIdentityBundle>,
|
||||
}
|
||||
|
||||
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
|
||||
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
|
||||
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
|
||||
@@ -1094,6 +1184,58 @@ pub struct RepositoryDetailResponse {
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshConnectionProbeRequest {
|
||||
pub runtime_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshHostKeyCandidate {
|
||||
pub algorithm: String,
|
||||
pub host_key: String,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositorySshConnectionTrustState {
|
||||
Untrusted,
|
||||
Verified,
|
||||
Changed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshConnectionProbeResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_key: String,
|
||||
pub runtime_id: String,
|
||||
pub hostname: String,
|
||||
pub port: u16,
|
||||
pub trust_state: RepositorySshConnectionTrustState,
|
||||
pub host_trust_id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||
pub expected_host_trust_revision: Option<u64>,
|
||||
pub candidates: Vec<RepositorySshHostKeyCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfirmRepositorySshHostTrustRequest {
|
||||
pub operation_id: String,
|
||||
pub runtime_id: String,
|
||||
pub host_key: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||
pub expected_host_trust_revision: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -1138,8 +1280,7 @@ pub struct Diagnostic {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryMaterializerKind {
|
||||
#[default]
|
||||
RuntimeGitCache,
|
||||
LocalGitWorktree,
|
||||
RuntimeGitClone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -1522,6 +1663,71 @@ pub struct RuntimeSummary {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceRuntimeBindingState {
|
||||
Configured,
|
||||
Verified,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeConnectionDisplayState {
|
||||
Configured,
|
||||
Verified,
|
||||
Unavailable,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeVerificationOutcome {
|
||||
Verified,
|
||||
ChallengeIssued,
|
||||
VerificationFailed,
|
||||
ConnectivityFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeVerificationEvidenceSummary {
|
||||
pub verified_at: Option<String>,
|
||||
pub last_checked_at: String,
|
||||
pub last_outcome: RuntimeVerificationOutcome,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub binding_revision: u64,
|
||||
pub workspace_key_id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub workspace_identity_revision: u64,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub workspace_trust_generation: u64,
|
||||
pub runtime_public_key_fingerprint: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub runtime_identity_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceRuntimeBindingSummary {
|
||||
pub state: WorkspaceRuntimeBindingState,
|
||||
pub connection_state: RuntimeConnectionDisplayState,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub revision: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_key_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||
pub workspace_key_generation: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub verification: Option<RuntimeVerificationEvidenceSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -1531,6 +1737,8 @@ pub struct RuntimeManagementSummary {
|
||||
pub removable: bool,
|
||||
pub endpoint_configured: bool,
|
||||
pub token_ref_configured: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub binding: Option<WorkspaceRuntimeBindingSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -1616,19 +1824,46 @@ pub struct RuntimeTrustKeyRevealResponse {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PutRuntimeTrustKeyRequest {
|
||||
pub public_key: String,
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||
pub expected_revision: Option<u64>,
|
||||
pub struct RevokeRuntimeTrustKeyRequest {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
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 RevokeRuntimeTrustKeyRequest {
|
||||
pub struct RemoveRuntimeRequest {
|
||||
pub operation_id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub expected_revision: u64,
|
||||
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)]
|
||||
@@ -1653,12 +1888,33 @@ pub struct RuntimeTrustConflictResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimePublicIdentityBundle {
|
||||
pub identity_id: String,
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateRemoteRuntimeRequest {
|
||||
pub runtime_id: String,
|
||||
pub public_bundle: RuntimePublicIdentityBundle,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
pub endpoint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||
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,
|
||||
pub token_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -1690,6 +1946,10 @@ pub enum RuntimeConnectionTestFailureKind {
|
||||
pub struct RuntimeConnectionTestResponse {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub binding_revision: u64,
|
||||
pub connection_state: RuntimeConnectionDisplayState,
|
||||
pub verification: Option<RuntimeVerificationEvidenceSummary>,
|
||||
pub checked_at: String,
|
||||
pub status: RuntimeConnectionTestStatus,
|
||||
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
|
||||
@@ -2324,6 +2584,27 @@ pub struct CreateRepositorySshCredentialRequest {
|
||||
pub passphrase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GenerateRepositorySshCredentialRequest {
|
||||
pub operation_id: String,
|
||||
pub credential_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshPublicKey {
|
||||
pub credential_id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub current_revision: u64,
|
||||
pub public_key_algorithm: String,
|
||||
pub public_key_fingerprint: String,
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -2848,6 +3129,10 @@ pub fn catalog_typescript() -> String {
|
||||
WorkspaceMetadataSettingsResponse::decl(&config),
|
||||
UpdateWorkspaceMetadataRequest::decl(&config),
|
||||
WorkspaceMetadataMutationResponse::decl(&config),
|
||||
WorkspaceSigningIdentityState::decl(&config),
|
||||
WorkspaceSigningIdentityPublic::decl(&config),
|
||||
WorkspacePublicIdentityBundle::decl(&config),
|
||||
WorkspaceSigningIdentityResponse::decl(&config),
|
||||
ProfileSettingsResponse::decl(&config),
|
||||
WorkspaceProfileSummary::decl(&config),
|
||||
WorkspaceProfileSourceSummary::decl(&config),
|
||||
@@ -2862,12 +3147,22 @@ pub fn catalog_typescript() -> String {
|
||||
GitCommitSummary::decl(&config),
|
||||
RepositoryListResponse::decl(&config),
|
||||
RepositoryDetailResponse::decl(&config),
|
||||
RepositorySshConnectionProbeRequest::decl(&config),
|
||||
RepositorySshHostKeyCandidate::decl(&config),
|
||||
RepositorySshConnectionTrustState::decl(&config),
|
||||
RepositorySshConnectionProbeResponse::decl(&config),
|
||||
ConfirmRepositorySshHostTrustRequest::decl(&config),
|
||||
RepositoryLogResponse::decl(&config),
|
||||
RuntimeSourceKind::decl(&config),
|
||||
RuntimeSourceStatus::decl(&config),
|
||||
RuntimeIdentityAuthority::decl(&config),
|
||||
RuntimeSourceSummary::decl(&config),
|
||||
RuntimeSummary::decl(&config),
|
||||
WorkspaceRuntimeBindingState::decl(&config),
|
||||
RuntimeConnectionDisplayState::decl(&config),
|
||||
RuntimeVerificationOutcome::decl(&config),
|
||||
RuntimeVerificationEvidenceSummary::decl(&config),
|
||||
WorkspaceRuntimeBindingSummary::decl(&config),
|
||||
RuntimeManagementSummary::decl(&config),
|
||||
WorkspaceRuntimeResource::decl(&config),
|
||||
RuntimeTrustKeyStatus::decl(&config),
|
||||
@@ -2876,10 +3171,15 @@ pub fn catalog_typescript() -> String {
|
||||
RuntimeTrustAuditEntry::decl(&config),
|
||||
WorkspaceRuntimeDetail::decl(&config),
|
||||
RuntimeTrustKeyRevealResponse::decl(&config),
|
||||
PutRuntimeTrustKeyRequest::decl(&config),
|
||||
RevokeRuntimeTrustKeyRequest::decl(&config),
|
||||
RemoveRuntimeRequest::decl(&config),
|
||||
RuntimeRemovalOperationState::decl(&config),
|
||||
RuntimeRemovalOperationResponse::decl(&config),
|
||||
RuntimeTrustConflictKind::decl(&config),
|
||||
RuntimeTrustConflictResponse::decl(&config),
|
||||
RuntimePublicIdentityBundle::decl(&config),
|
||||
CreateRemoteRuntimeRequest::decl(&config),
|
||||
UpdateRemoteRuntimeRequest::decl(&config),
|
||||
RuntimeConnectionTestStatus::decl(&config),
|
||||
RuntimeConnectionTestFailureKind::decl(&config),
|
||||
RuntimeConnectionTestResponse::decl(&config),
|
||||
@@ -2900,6 +3200,8 @@ pub fn repository_access_api_typescript() -> String {
|
||||
let declarations = [
|
||||
RepositorySshCredential::decl(&config),
|
||||
CreateRepositorySshCredentialRequest::decl(&config),
|
||||
GenerateRepositorySshCredentialRequest::decl(&config),
|
||||
RepositorySshPublicKey::decl(&config),
|
||||
RotateRepositorySshCredentialRequest::decl(&config),
|
||||
DeleteRepositorySshCredentialRequest::decl(&config),
|
||||
RepositorySshHostTrust::decl(&config),
|
||||
@@ -3180,6 +3482,27 @@ mod workdir_typescript_tests {
|
||||
mod tests {
|
||||
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 {
|
||||
SkillProjectionIdentity {
|
||||
config_revision: 42,
|
||||
@@ -3397,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]
|
||||
fn worker_launch_optional_omission_and_request_shape_are_stable() {
|
||||
assert_eq!(
|
||||
@@ -3568,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]
|
||||
fn workspace_and_repository_response_shapes_round_trip() {
|
||||
let workspace = serde_json::json!({
|
||||
@@ -3707,14 +4086,6 @@ mod tests {
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
|
||||
"public_key": "key",
|
||||
"expected_revision": 1,
|
||||
"replace": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
|
||||
"expected_revision": 1,
|
||||
@@ -3729,6 +4100,9 @@ mod tests {
|
||||
let compatible = serde_json::json!({
|
||||
"workspace_id": "workspace-test",
|
||||
"runtime_id": "runtime-test",
|
||||
"binding_revision": 3,
|
||||
"connection_state": "verified",
|
||||
"verification": null,
|
||||
"checked_at": "2026-09-01T12:00:00Z",
|
||||
"status": "compatible",
|
||||
"failure_kind": null,
|
||||
@@ -3877,6 +4251,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_signing_identity_wire_contract_omits_private_and_pending_fields() {
|
||||
let response = WorkspaceSigningIdentityResponse {
|
||||
identity: WorkspaceSigningIdentityPublic {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
key_id: "WK-test".to_string(),
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: None,
|
||||
public_key_fingerprint: None,
|
||||
revision: 1,
|
||||
state: WorkspaceSigningIdentityState::PendingProvisioning,
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
provisioned_at: None,
|
||||
},
|
||||
public_bundle: None,
|
||||
};
|
||||
let encoded = serde_json::to_value(&response).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({
|
||||
"identity": {
|
||||
"workspace_id": "workspace-test",
|
||||
"key_id": "WK-test",
|
||||
"algorithm": "ed25519",
|
||||
"revision": 1,
|
||||
"state": "pending_provisioning",
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceSigningIdentityResponse>(serde_json::json!({
|
||||
"identity": encoded["identity"].clone(),
|
||||
"private_material_ref": "must-not-cross-the-wire"
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
|
||||
WorkspaceWorkerDiscoveryItem {
|
||||
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||
@@ -4302,7 +4715,7 @@ mod tests {
|
||||
current_ref: None,
|
||||
current_tree: None,
|
||||
observed_at_epoch_seconds: None,
|
||||
materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitCache,
|
||||
materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitClone,
|
||||
cleanup_target: None,
|
||||
status: WorkingDirectoryStatusKind::Active,
|
||||
cleanliness: None,
|
||||
@@ -4339,7 +4752,7 @@ mod tests {
|
||||
"items": [{
|
||||
"working_directory_id": "workdir-1",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "runtime_git_cache",
|
||||
"materializer_kind": "runtime_git_clone",
|
||||
"status": "active",
|
||||
"occupied_by": {
|
||||
"runtime_worker_id": "worker-1",
|
||||
|
||||
@@ -38,7 +38,7 @@ memory.workspace = true
|
||||
merge-request.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||
tower.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
||||
worker.workspace = true
|
||||
workspace-api.workspace = true
|
||||
workdir = { workspace = true, features = ["http-client"] }
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::{fs, io};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::server::{AuthConfig, ServerConfig};
|
||||
use crate::store::WorkspaceRecord;
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
|
||||
@@ -100,15 +100,15 @@ impl ServerHostConfigFile {
|
||||
impl ResolvedWorkspaceBackendConfig {
|
||||
pub fn local_dev(
|
||||
workspace_root: impl AsRef<Path>,
|
||||
identity: WorkspaceIdentity,
|
||||
workspace: WorkspaceRecord,
|
||||
host_config: &ServerHostConfigFile,
|
||||
) -> Result<Self> {
|
||||
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 (browser_public_url, browser_rp_id) =
|
||||
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.embedded_runtime_store_root = data_root.join("embedded-runtime");
|
||||
server.max_records = DEFAULT_MAX_RECORDS;
|
||||
@@ -185,11 +185,14 @@ fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identity() -> WorkspaceIdentity {
|
||||
WorkspaceIdentity {
|
||||
fn workspace() -> WorkspaceRecord {
|
||||
WorkspaceRecord {
|
||||
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(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
display_name: "Workspace".to_string(),
|
||||
state: "active".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +200,7 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
ResolvedWorkspaceBackendConfig::local_dev(
|
||||
dir.path(),
|
||||
identity(),
|
||||
workspace(),
|
||||
&ServerHostConfigFile::default(),
|
||||
)
|
||||
.unwrap()
|
||||
@@ -250,7 +253,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||
tempfile::tempdir().unwrap().path(),
|
||||
identity(),
|
||||
workspace(),
|
||||
&host_config,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -280,7 +283,7 @@ mod tests {
|
||||
};
|
||||
let result = ResolvedWorkspaceBackendConfig::local_dev(
|
||||
tempfile::tempdir().unwrap().path(),
|
||||
identity(),
|
||||
workspace(),
|
||||
&host_config,
|
||||
);
|
||||
let error = match result {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -441,13 +441,49 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
public_key TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
|
||||
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
|
||||
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
|
||||
workspace_key_id TEXT,
|
||||
workspace_key_generation INTEGER CHECK (workspace_key_generation > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
PRIMARY KEY (workspace_id, runtime_id),
|
||||
UNIQUE (workspace_id, public_key_fingerprint),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
|
||||
CHECK (
|
||||
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL)
|
||||
OR
|
||||
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL)
|
||||
),
|
||||
CHECK (
|
||||
(state = 'revoked' AND revoked_at IS NOT NULL)
|
||||
OR
|
||||
(state != 'revoked' AND revoked_at IS NULL)
|
||||
)
|
||||
);
|
||||
CREATE TABLE workspace_runtime_verifications (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
binding_revision INTEGER NOT NULL CHECK(binding_revision > 0),
|
||||
workspace_key_id TEXT NOT NULL,
|
||||
workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0),
|
||||
workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0),
|
||||
runtime_public_key_fingerprint TEXT NOT NULL,
|
||||
runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0),
|
||||
challenge_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
|
||||
last_outcome TEXT NOT NULL,
|
||||
verified_at TEXT,
|
||||
checked_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, runtime_id),
|
||||
FOREIGN KEY(workspace_id, runtime_id)
|
||||
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
|
||||
CHECK((state = 'verified' AND verified_at IS NOT NULL)
|
||||
OR (state != 'verified' AND verified_at IS NULL))
|
||||
);
|
||||
CREATE INDEX workspace_runtime_verifications_state_idx
|
||||
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);
|
||||
CREATE TABLE workspace_runtime_binding_audit (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
@@ -588,10 +624,46 @@ CREATE TABLE workdir_create_operations (
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')),
|
||||
failure TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT, cache_generation INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_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" (
|
||||
workspace_id TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
@@ -703,7 +775,7 @@ CREATE TABLE "worker_registry" (
|
||||
CREATE TABLE worker_removal_operations (
|
||||
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,
|
||||
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,
|
||||
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
|
||||
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
|
||||
@@ -795,6 +867,51 @@ CREATE TABLE workspace_create_operations (
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_signing_identities (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'),
|
||||
public_key TEXT,
|
||||
public_key_fingerprint TEXT,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
|
||||
created_at TEXT NOT NULL,
|
||||
provisioned_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
(state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL)
|
||||
OR
|
||||
(state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL)
|
||||
),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_signing_identity_provisioning_operations (
|
||||
operation_key TEXT PRIMARY KEY,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')),
|
||||
workspace_id TEXT NOT NULL UNIQUE,
|
||||
key_id TEXT NOT NULL UNIQUE,
|
||||
private_material_ref TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
actor_account_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE TABLE workspace_signing_identity_audit (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('provisioned')),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX workspace_signing_identity_audit_workspace_idx
|
||||
ON workspace_signing_identity_audit(workspace_id, created_at DESC);
|
||||
CREATE TABLE workspace_memory_documents (
|
||||
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
body_md TEXT NOT NULL,
|
||||
@@ -940,6 +1057,200 @@ CREATE UNIQUE INDEX worker_workdir_links_active_worker_unique
|
||||
WHERE unlinked_at IS NULL;
|
||||
CREATE INDEX worker_workdir_links_workdir
|
||||
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 (
|
||||
operation_id TEXT PRIMARY KEY,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
|
||||
@@ -10,7 +10,6 @@ pub mod companion;
|
||||
pub mod config;
|
||||
pub mod config_source;
|
||||
pub mod hosts;
|
||||
pub mod identity;
|
||||
pub mod memory_backend;
|
||||
pub mod memory_staging;
|
||||
pub mod observation;
|
||||
@@ -34,6 +33,7 @@ mod workdir_removal;
|
||||
pub mod worker_source;
|
||||
pub mod workspace_catalog;
|
||||
mod workspace_deletion;
|
||||
pub mod workspace_signing_identity;
|
||||
mod workspace_subscription;
|
||||
|
||||
pub use authority::{
|
||||
@@ -42,7 +42,6 @@ pub use authority::{
|
||||
WorkspaceAuthority,
|
||||
};
|
||||
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
||||
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
|
||||
pub use server::{
|
||||
@@ -136,8 +135,8 @@ pub enum Error {
|
||||
RegistryInconsistency(String),
|
||||
#[error("Worker source identity is invalid: {0}")]
|
||||
WorkerSourceIdentity(String),
|
||||
#[error("workspace identity error: {0}")]
|
||||
WorkspaceIdentity(String),
|
||||
#[error("Workspace signing identity error ({code}): {message}")]
|
||||
WorkspaceSigningIdentity { code: String, message: String },
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
+116
-458
@@ -1,25 +1,25 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::TcpListener;
|
||||
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
||||
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
||||
use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding};
|
||||
use yoi_workspace_server::hosts::{
|
||||
EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin,
|
||||
};
|
||||
use yoi_workspace_server::store::{
|
||||
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
|
||||
WorkspaceRuntimeBindingState,
|
||||
};
|
||||
use yoi_workspace_server::{
|
||||
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
||||
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
||||
WorkspaceRecord, serve_workspace_catalog,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Command {
|
||||
Serve(ServeOptions),
|
||||
Identity(Vec<String>),
|
||||
TrustRuntime(Vec<String>),
|
||||
Migrate(MigrateOptions),
|
||||
Skills(SkillsCommand),
|
||||
Help,
|
||||
@@ -76,8 +76,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
match parse_command(&args)? {
|
||||
Command::Serve(options) => run_serve(options).await,
|
||||
Command::Identity(args) => run_identity_command(args),
|
||||
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
||||
Command::Migrate(options) => run_migrate(options),
|
||||
Command::Skills(command) => run_skills(command),
|
||||
Command::Help => Ok(()),
|
||||
@@ -91,8 +89,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||
};
|
||||
|
||||
match command.as_str() {
|
||||
"identity" => Ok(Command::Identity(rest.to_vec())),
|
||||
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
||||
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
|
||||
"skills" => parse_skills_command(rest),
|
||||
"serve" => {
|
||||
@@ -107,371 +103,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||
Ok(Command::Help)
|
||||
}
|
||||
other => Err(CliError(format!(
|
||||
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
|
||||
"unknown command `{other}`; expected `migrate`, `skills`, or `serve`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct ServerIdentityFile {
|
||||
identity: RuntimeIdentityMaterial,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct PublicIdentityView {
|
||||
identity_id: String,
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
fn server_identity_path() -> PathBuf {
|
||||
ServerConfig::default_server_data_root().join("identity.toml")
|
||||
}
|
||||
|
||||
fn read_server_identity_file(
|
||||
path: &Path,
|
||||
) -> Result<Option<ServerIdentityFile>, Box<dyn std::error::Error>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
Ok(Some(toml::from_str(&contents)?))
|
||||
}
|
||||
|
||||
fn write_server_identity_file(
|
||||
path: &Path,
|
||||
identity: &ServerIdentityFile,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let contents = toml::to_string_pretty(identity)?;
|
||||
write_secret_file(path, contents.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
use std::io::Write as _;
|
||||
file.write_all(contents)?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(path, contents)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn public_identity_view(identity: &RuntimeIdentityMaterial) -> PublicIdentityView {
|
||||
PublicIdentityView {
|
||||
identity_id: identity.identity_id.clone(),
|
||||
public_key: identity.public_key.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_identity_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = VecDeque::from(args);
|
||||
let subcommand = args
|
||||
.pop_front()
|
||||
.ok_or_else(|| CliError("identity requires `init` or `show`".to_string()))?;
|
||||
match subcommand.as_str() {
|
||||
"init" => {
|
||||
let mut server_id = None;
|
||||
let mut replace = false;
|
||||
while let Some(arg) = args.pop_front() {
|
||||
let (flag, inline_value) = split_flag_value(arg)?;
|
||||
match flag.as_str() {
|
||||
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
|
||||
"--replace" => {
|
||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||
replace = true;
|
||||
}
|
||||
_ => {
|
||||
return Err(Box::new(CliError(format!(
|
||||
"unknown identity init argument `{flag}`"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let server_id = server_id
|
||||
.ok_or_else(|| CliError("identity init requires --server-id".to_string()))?;
|
||||
let path = server_identity_path();
|
||||
if read_server_identity_file(&path)?.is_some() && !replace {
|
||||
return Err(Box::new(CliError(format!(
|
||||
"server identity already exists at {}; pass --replace to rotate it",
|
||||
path.display()
|
||||
))));
|
||||
}
|
||||
let identity = RuntimeIdentityMaterial::generate(server_id)?;
|
||||
write_server_identity_file(
|
||||
&path,
|
||||
&ServerIdentityFile {
|
||||
identity: identity.clone(),
|
||||
},
|
||||
)?;
|
||||
println!("server_id={}", identity.identity_id);
|
||||
println!("public_key={}", identity.public_key);
|
||||
println!("identity_file={}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
"show" => {
|
||||
let mut json = false;
|
||||
while let Some(arg) = args.pop_front() {
|
||||
let (flag, inline_value) = split_flag_value(arg)?;
|
||||
match flag.as_str() {
|
||||
"--json" => {
|
||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||
json = true;
|
||||
}
|
||||
_ => {
|
||||
return Err(Box::new(CliError(format!(
|
||||
"unknown identity show argument `{flag}`"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = server_identity_path();
|
||||
let identity = read_server_identity_file(&path)?.ok_or_else(|| {
|
||||
CliError(format!(
|
||||
"server identity is not initialized at {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let view = public_identity_view(&identity.identity);
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&view)?);
|
||||
} else {
|
||||
println!("server_id={}", view.identity_id);
|
||||
println!("public_key={}", view.public_key);
|
||||
println!("identity_file={}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(Box::new(CliError(format!(
|
||||
"unknown identity subcommand `{subcommand}`"
|
||||
)))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = VecDeque::from(args);
|
||||
let subcommand = args
|
||||
.pop_front()
|
||||
.ok_or_else(|| CliError("trust-runtime requires `add`, `list`, or `revoke`".to_string()))?;
|
||||
let database_path = ServerConfig::default_server_database_path();
|
||||
if let Some(parent) = database_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let store = SqliteWorkspaceStore::open(&database_path)?;
|
||||
match subcommand.as_str() {
|
||||
"add" => {
|
||||
let mut runtime_id = None;
|
||||
let mut workspace_id = None;
|
||||
let mut base_url = None;
|
||||
let mut public_key = None;
|
||||
let mut display_name = None;
|
||||
let mut replace = false;
|
||||
while let Some(arg) = args.pop_front() {
|
||||
let (flag, inline_value) = split_flag_value(arg)?;
|
||||
match flag.as_str() {
|
||||
"--runtime-id" => {
|
||||
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
"--workspace-id" => {
|
||||
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
"--base-url" | "--endpoint" => {
|
||||
base_url = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
"--public-key" => {
|
||||
public_key = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
"--display-name" => {
|
||||
display_name = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
"--replace" => {
|
||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||
replace = true;
|
||||
}
|
||||
_ => {
|
||||
return Err(Box::new(CliError(format!(
|
||||
"unknown trust-runtime add argument `{flag}`"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let runtime_id = runtime_id
|
||||
.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
|
||||
let workspace_id = workspace_id
|
||||
.ok_or_else(|| CliError("trust-runtime add requires --workspace-id".to_string()))?;
|
||||
if !store
|
||||
.list_workspaces()?
|
||||
.iter()
|
||||
.any(|workspace| workspace.workspace_id == workspace_id)
|
||||
{
|
||||
return Err(Box::new(CliError(format!(
|
||||
"Workspace `{workspace_id}` is not registered"
|
||||
))));
|
||||
}
|
||||
let base_url = base_url
|
||||
.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
|
||||
let public_key = public_key
|
||||
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
|
||||
decode_public_key(&public_key)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let outcome = store.upsert_workspace_runtime_binding(
|
||||
WorkspaceRuntimeBinding {
|
||||
workspace_id: workspace_id.clone(),
|
||||
runtime_id: runtime_id.clone(),
|
||||
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
|
||||
base_url,
|
||||
public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
revoked_at: None,
|
||||
},
|
||||
replace,
|
||||
)?;
|
||||
println!("workspace_id={workspace_id}");
|
||||
println!("runtime_id={runtime_id}");
|
||||
println!(
|
||||
"result={}",
|
||||
match outcome {
|
||||
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Created =>
|
||||
"created",
|
||||
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged =>
|
||||
"unchanged",
|
||||
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced =>
|
||||
"replaced",
|
||||
}
|
||||
);
|
||||
println!("server_db={}", database_path.display());
|
||||
Ok(())
|
||||
}
|
||||
"list" => {
|
||||
let mut workspace_id = None;
|
||||
let mut json = false;
|
||||
let mut include_revoked = false;
|
||||
while let Some(arg) = args.pop_front() {
|
||||
let (flag, inline_value) = split_flag_value(arg)?;
|
||||
match flag.as_str() {
|
||||
"--workspace-id" => {
|
||||
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
"--json" => {
|
||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||
json = true;
|
||||
}
|
||||
"--include-revoked" => {
|
||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||
include_revoked = true;
|
||||
}
|
||||
_ => {
|
||||
return Err(Box::new(CliError(format!(
|
||||
"unknown trust-runtime list argument `{flag}`"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let workspace_id = workspace_id.ok_or_else(|| {
|
||||
CliError("trust-runtime list requires --workspace-id".to_string())
|
||||
})?;
|
||||
let records = store.list_workspace_runtime_bindings(&workspace_id, include_revoked)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&records)?);
|
||||
} else {
|
||||
for runtime in records {
|
||||
println!(
|
||||
"workspace_id={} runtime_id={} base_url={} public_key_fingerprint={} revoked_at={}",
|
||||
runtime.workspace_id,
|
||||
runtime.runtime_id,
|
||||
runtime.base_url,
|
||||
runtime.public_key_fingerprint,
|
||||
runtime.revoked_at.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"revoke" => {
|
||||
let mut workspace_id = None;
|
||||
let mut runtime_id = None;
|
||||
while let Some(arg) = args.pop_front() {
|
||||
let (flag, inline_value) = split_flag_value(arg)?;
|
||||
match flag.as_str() {
|
||||
"--workspace-id" => {
|
||||
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
"--runtime-id" => {
|
||||
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
_ => {
|
||||
return Err(Box::new(CliError(format!(
|
||||
"unknown trust-runtime revoke argument `{flag}`"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let workspace_id = workspace_id.ok_or_else(|| {
|
||||
CliError("trust-runtime revoke requires --workspace-id".to_string())
|
||||
})?;
|
||||
let runtime_id = runtime_id.ok_or_else(|| {
|
||||
CliError("trust-runtime revoke requires --runtime-id".to_string())
|
||||
})?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if !store.revoke_workspace_runtime_binding(&workspace_id, &runtime_id, &now)? {
|
||||
return Err(Box::new(CliError(format!(
|
||||
"trusted runtime `{runtime_id}` is not registered or is already revoked"
|
||||
))));
|
||||
}
|
||||
println!("revoked_runtime_id={runtime_id}");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(Box::new(CliError(format!(
|
||||
"unknown trust-runtime subcommand `{subcommand}`"
|
||||
)))),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
|
||||
if let Some((flag, value)) = arg.split_once('=') {
|
||||
if flag.is_empty() {
|
||||
return Err(CliError("empty flag name".to_string()));
|
||||
}
|
||||
Ok((flag.to_string(), Some(value.to_string())))
|
||||
} else {
|
||||
Ok((arg, None))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_value(
|
||||
flag: &str,
|
||||
inline_value: Option<String>,
|
||||
args: &mut VecDeque<String>,
|
||||
) -> Result<String, CliError> {
|
||||
if let Some(value) = inline_value {
|
||||
return Ok(value);
|
||||
}
|
||||
args.pop_front()
|
||||
.ok_or_else(|| CliError(format!("{flag} requires a value")))
|
||||
}
|
||||
|
||||
fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), CliError> {
|
||||
if inline_value.is_some() {
|
||||
return Err(CliError(format!("{flag} does not accept a value")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
SkillsCommand::List(options) => {
|
||||
@@ -519,6 +155,31 @@ fn load_skill_workspace_config(
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_runtime_config_from_binding(
|
||||
binding: WorkspaceRuntimeBinding,
|
||||
) -> Result<Option<RemoteRuntimeConfig>, CliError> {
|
||||
if binding.runtime_id == EMBEDDED_RUNTIME_ID {
|
||||
return Ok(None);
|
||||
}
|
||||
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity {
|
||||
return Err(CliError(format!(
|
||||
"Runtime binding '{}:{}' still uses removed legacy Server-issued authentication",
|
||||
binding.workspace_id, binding.runtime_id
|
||||
)));
|
||||
}
|
||||
let strict_public_egress = !is_loopback_runtime_origin(&binding.base_url);
|
||||
Ok(Some(
|
||||
RemoteRuntimeConfig::new(
|
||||
binding.runtime_id,
|
||||
binding.display_name,
|
||||
binding.base_url,
|
||||
None,
|
||||
)
|
||||
.with_workspace_id(binding.workspace_id)
|
||||
.with_strict_public_egress(strict_public_egress),
|
||||
))
|
||||
}
|
||||
|
||||
fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if options.help {
|
||||
print_migrate_help();
|
||||
@@ -574,21 +235,21 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
||||
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
||||
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_id: workspace.workspace_id.clone(),
|
||||
created_at: workspace.created_at.clone(),
|
||||
display_name: workspace.display_name.clone(),
|
||||
},
|
||||
workspace_root_from_server_data(workspace)?,
|
||||
workspace.clone(),
|
||||
workspace_execution_root_from_server_data(workspace)?,
|
||||
)
|
||||
} else {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
(
|
||||
WorkspaceIdentity {
|
||||
WorkspaceRecord {
|
||||
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(),
|
||||
state: "bootstrap".to_string(),
|
||||
},
|
||||
database_path
|
||||
.parent()
|
||||
@@ -600,8 +261,11 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
||||
Some(path) => ServerHostConfigFile::load_from_path(path)?,
|
||||
None => ServerHostConfigFile::load_default()?,
|
||||
};
|
||||
let mut resolved =
|
||||
ResolvedWorkspaceBackendConfig::local_dev(&workspace_root, identity, &host_config)?;
|
||||
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||
&workspace_execution_root,
|
||||
workspace,
|
||||
&host_config,
|
||||
)?;
|
||||
resolved.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)?;
|
||||
@@ -639,6 +303,7 @@ fn append_workspace_runtime_sources(
|
||||
.into_iter()
|
||||
.filter(|binding| {
|
||||
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
|
||||
&& binding.state == WorkspaceRuntimeBindingState::Verified
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
@@ -647,37 +312,22 @@ fn append_workspace_runtime_sources(
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
|
||||
if !bindings.is_empty() {
|
||||
return Err(Box::new(CliError(
|
||||
"Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
for runtime in bindings {
|
||||
let auth = RemoteRuntimeAuthConfig {
|
||||
server_id: server_identity.identity.identity_id.clone(),
|
||||
server_private_key: server_identity.identity.private_key.clone(),
|
||||
for binding in bindings {
|
||||
let Some(remote) = remote_runtime_config_from_binding(binding)? else {
|
||||
continue;
|
||||
};
|
||||
let remote = RemoteRuntimeConfig::new(
|
||||
runtime.runtime_id.clone(),
|
||||
runtime.display_name,
|
||||
runtime.base_url,
|
||||
None,
|
||||
)
|
||||
.with_workspace_id(runtime.workspace_id.clone())
|
||||
.with_auth(auth);
|
||||
remote_runtime_sources.retain(|existing| {
|
||||
existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str())
|
||||
|| existing.runtime_id != runtime.runtime_id
|
||||
existing.workspace_id.as_deref() != remote.workspace_id.as_deref()
|
||||
|| existing.runtime_id != remote.runtime_id
|
||||
});
|
||||
remote_runtime_sources.push(remote);
|
||||
}
|
||||
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(
|
||||
&workspace.workspace_id,
|
||||
))
|
||||
@@ -840,7 +490,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||
"yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -952,60 +602,68 @@ mod tests {
|
||||
"unknown serve option `--frontend=/tmp/web`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_identity_init_requires_explicit_server_id() {
|
||||
let error = run_identity_command(vec!["init".to_string()]).unwrap_err();
|
||||
assert_eq!(error.to_string(), "identity init requires --server-id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_binding_requires_explicit_replace_for_changed_authority() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
rusqlite::Connection::open(&path)
|
||||
.unwrap()
|
||||
.execute_batch(
|
||||
"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
|
||||
VALUES ('owner', 'user', 'owner', 'Owner', '1', '1');
|
||||
INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at)
|
||||
VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');",
|
||||
)
|
||||
.unwrap();
|
||||
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
|
||||
.unwrap()
|
||||
.public_key;
|
||||
fn runtime_startup_rejects_legacy_server_issuer_bindings() {
|
||||
let binding = WorkspaceRuntimeBinding {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
display_name: "Runtime A".to_string(),
|
||||
base_url: "http://127.0.0.1:18080".to_string(),
|
||||
public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
workspace_id: "workspace-a".to_owned(),
|
||||
runtime_id: "runtime-a".to_owned(),
|
||||
display_name: "Runtime A".to_owned(),
|
||||
base_url: "https://runtime.example.test".to_owned(),
|
||||
public_key: "unused".to_owned(),
|
||||
public_key_fingerprint: "unused".to_owned(),
|
||||
binding_revision: 1,
|
||||
created_at: "2026-07-26T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||
workspace_key_id: None,
|
||||
workspace_key_generation: None,
|
||||
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
};
|
||||
store
|
||||
.upsert_workspace_runtime_binding(binding.clone(), false)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store
|
||||
.upsert_workspace_runtime_binding(binding.clone(), false)
|
||||
.unwrap(),
|
||||
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged
|
||||
));
|
||||
let mut changed = binding;
|
||||
changed.base_url = "http://127.0.0.1:18081".to_string();
|
||||
assert!(
|
||||
store
|
||||
.upsert_workspace_runtime_binding(changed.clone(), false)
|
||||
.is_err()
|
||||
let error = remote_runtime_config_from_binding(binding)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
error,
|
||||
"Runtime binding 'workspace-a:runtime-a' still uses removed legacy Server-issued authentication"
|
||||
);
|
||||
store
|
||||
.upsert_workspace_runtime_binding(changed, true)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_startup_uses_non_strict_transport_for_literal_loopback_origin() {
|
||||
let binding = WorkspaceRuntimeBinding {
|
||||
workspace_id: "workspace-a".to_owned(),
|
||||
runtime_id: "arcadia".to_owned(),
|
||||
display_name: "Arcadia".to_owned(),
|
||||
base_url: "http://127.0.0.1:8788".to_owned(),
|
||||
public_key: "unused".to_owned(),
|
||||
public_key_fingerprint: "unused".to_owned(),
|
||||
binding_revision: 1,
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some("WK-test".to_owned()),
|
||||
workspace_key_generation: Some(1),
|
||||
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
};
|
||||
|
||||
let config = remote_runtime_config_from_binding(binding)
|
||||
.unwrap()
|
||||
.expect("remote Runtime config");
|
||||
|
||||
assert_eq!(config.base_url, "http://127.0.0.1:8788");
|
||||
assert!(!config.strict_public_egress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_rejects_removed_server_global_runtime_trust_commands() {
|
||||
for command in ["identity", "trust-runtime"] {
|
||||
let error = parse_command(&[command.to_owned()]).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!("unknown command `{command}`; expected `migrate`, `skills`, or `serve`")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
|
||||
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use worker::EffectivePromptCatalog;
|
||||
use worker_runtime::config_bundle::{
|
||||
@@ -13,14 +11,14 @@ use worker_runtime::config_bundle::{
|
||||
};
|
||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||
use workspace_api::{
|
||||
Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest,
|
||||
WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
|
||||
ProfileSettingsResponse, WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
|
||||
WorkspaceProfileSourceSummary, WorkspaceProfileSummary,
|
||||
};
|
||||
|
||||
use crate::config_source::{
|
||||
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
|
||||
};
|
||||
use crate::store::WorkspaceRecord;
|
||||
use crate::{Error, Result};
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
#[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(
|
||||
workspace_root: &Path,
|
||||
fallback_workspace_id: &str,
|
||||
fallback_created_at: &str,
|
||||
fallback_display_name: &str,
|
||||
workspace: &WorkspaceRecord,
|
||||
) -> 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 {
|
||||
workspace_id,
|
||||
display_name,
|
||||
created_at,
|
||||
revision: file_revision(&path),
|
||||
source: "workspace_identity".to_string(),
|
||||
diagnostics,
|
||||
workspace_id: workspace.workspace_id.clone(),
|
||||
display_name: workspace.display_name.clone(),
|
||||
created_at: workspace.created_at.clone(),
|
||||
revision: workspace.updated_at.clone(),
|
||||
source: "server_db".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_workspace_metadata(
|
||||
workspace_root: &Path,
|
||||
request: UpdateWorkspaceMetadataRequest,
|
||||
) -> Result<WorkspaceMetadataSettingsResponse> {
|
||||
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
|
||||
let current_revision = file_revision(&path);
|
||||
if request.revision != current_revision {
|
||||
pub fn sanitize_workspace_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_metadata_revision_conflict".to_string(),
|
||||
message: "Workspace metadata changed before this update was applied".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(),
|
||||
});
|
||||
}
|
||||
let raw = fs::read_to_string(&path)?;
|
||||
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,
|
||||
))
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
||||
@@ -728,17 +652,6 @@ fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
|
||||
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(
|
||||
id: &str,
|
||||
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
||||
@@ -753,48 +666,41 @@ pub fn selector_for_builtin_candidate(
|
||||
_ => 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)]
|
||||
mod tests {
|
||||
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 {
|
||||
format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#)
|
||||
}
|
||||
|
||||
@@ -288,13 +288,12 @@ impl RepositoryRegistryReader {
|
||||
|
||||
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
|
||||
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 {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_source_insecure_http".to_string(),
|
||||
message:
|
||||
"HTTP Repository source is unencrypted; prefer HTTPS or SSH when available."
|
||||
.to_string(),
|
||||
severity: "error".to_string(),
|
||||
code: "repository_source_plain_http_unsupported".to_string(),
|
||||
message: "Plain HTTP Repository sources are not executable; register an HTTPS or SSH source instead."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
let git = match repository.provider.as_str() {
|
||||
@@ -607,6 +606,39 @@ mod tests {
|
||||
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]
|
||||
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
|
||||
let source = RepositorySource {
|
||||
|
||||
@@ -7,16 +7,19 @@ use std::sync::Arc;
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use config_source::ConfigSchemaContribution;
|
||||
use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
|
||||
use ring::hmac;
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use ssh_key::private::Ed25519Keypair;
|
||||
use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey};
|
||||
use workspace_api::{
|
||||
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode,
|
||||
RepositoryAccessProjection, RepositorySshAccessBinding, RepositorySshCredential,
|
||||
RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshHostTrustRequest, GenerateRepositorySshCredentialRequest,
|
||||
PutRepositorySshHostTrustRequest, RepositoryAccessMode, RepositoryAccessProjection,
|
||||
RepositorySshAccessBinding, RepositorySshCredential, RepositorySshHostTrust,
|
||||
RepositorySshPublicKey, RotateRepositorySshCredentialRequest,
|
||||
};
|
||||
|
||||
use crate::config_source::{
|
||||
@@ -42,6 +45,9 @@ const MAX_NAME_BYTES: usize = 200;
|
||||
const MAX_IDENTIFIER_BYTES: usize = 128;
|
||||
const MASTER_KEY_BYTES: usize = 32;
|
||||
const NONCE_BYTES: usize = 12;
|
||||
pub const WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID: &str = "workspace-default";
|
||||
const WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID: &str = "workspace-default-repository-ssh-v1";
|
||||
const WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME: &str = "Workspace default SSH key";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RepositoryAccessConfigSchemaProvider;
|
||||
@@ -130,6 +136,55 @@ 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(
|
||||
repository_key: &str,
|
||||
repository_uri: &str,
|
||||
) -> Result<Option<(String, u16)>> {
|
||||
if !repository_uri.contains("://") {
|
||||
if let Some((identity, path)) = repository_uri.split_once(':')
|
||||
&& !path.is_empty()
|
||||
&& let Some((_, hostname)) = identity.rsplit_once('@')
|
||||
&& !hostname.is_empty()
|
||||
{
|
||||
return Ok(Some((hostname.to_ascii_lowercase(), 22)));
|
||||
}
|
||||
}
|
||||
let parsed = url::Url::parse(repository_uri).map_err(|error| {
|
||||
Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` has invalid SSH URI: {error}"
|
||||
))
|
||||
})?;
|
||||
if parsed.scheme() != "ssh" {
|
||||
return Ok(None);
|
||||
}
|
||||
if parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` must use ssh://user@host[:port]/path without embedded credentials"
|
||||
)));
|
||||
}
|
||||
let hostname = parsed.host_str().ok_or_else(|| {
|
||||
Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` SSH URI has no hostname"
|
||||
))
|
||||
})?;
|
||||
Ok(Some((
|
||||
hostname.to_ascii_lowercase(),
|
||||
parsed.port().unwrap_or(22),
|
||||
)))
|
||||
}
|
||||
|
||||
fn project_repository_access_evaluation(
|
||||
store: &dyn ControlPlaneStore,
|
||||
secrets: &RepositorySecretService,
|
||||
@@ -145,6 +200,9 @@ fn project_repository_access_evaluation(
|
||||
.map_err(|error| {
|
||||
Error::InvalidInput(format!("invalid Repository access config: {error}"))
|
||||
})?;
|
||||
if !config.repository_access.is_empty() {
|
||||
secrets.ensure_workspace_default_credential(workspace_id)?;
|
||||
}
|
||||
let mut bindings = Vec::with_capacity(config.repository_access.len());
|
||||
for (repository_key, access) in config.repository_access {
|
||||
workspace_api::validate_repository_key(&repository_key)
|
||||
@@ -154,6 +212,7 @@ fn project_repository_access_evaluation(
|
||||
let repository = store
|
||||
.get_repository_by_key(workspace_id, &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 {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` is not an ssh:// Repository"
|
||||
@@ -181,22 +240,14 @@ fn project_repository_access_evaluation(
|
||||
access.ssh.host_trust
|
||||
))
|
||||
})?;
|
||||
let uri = url::Url::parse(&repository.source.uri).map_err(|_| {
|
||||
Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` has an invalid SSH URI"
|
||||
))
|
||||
})?;
|
||||
if uri.scheme() != "ssh" || uri.username().is_empty() || uri.password().is_some() {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` must use ssh://user@host[:port]/path without credentials"
|
||||
)));
|
||||
}
|
||||
let hostname = uri.host_str().ok_or_else(|| {
|
||||
Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` SSH URI has no hostname"
|
||||
))
|
||||
})?;
|
||||
let port = uri.port().unwrap_or(22);
|
||||
let (hostname, port) =
|
||||
repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else(
|
||||
|| {
|
||||
Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` must use an SSH source"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
if hostname != host_trust.hostname || port != host_trust.port {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` SSH host does not match host trust `{}`",
|
||||
@@ -245,6 +296,152 @@ impl RepositorySecretService {
|
||||
})
|
||||
}
|
||||
|
||||
fn generated_ed25519_private_key(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
credential_id: &str,
|
||||
intent: &str,
|
||||
) -> Result<String> {
|
||||
let master_key = self.master_key.as_ref().ok_or_else(|| {
|
||||
Error::Store("Repository secret encryption authority is unavailable".to_string())
|
||||
})?;
|
||||
let key = hmac::Key::new(hmac::HMAC_SHA256, master_key.as_slice());
|
||||
let context = format!(
|
||||
"yoi/repository-ssh-key/v1\0{workspace_id}\0{operation_id}\0{credential_id}\0{intent}"
|
||||
);
|
||||
let seed = hmac::sign(&key, context.as_bytes());
|
||||
PrivateKey::from(Ed25519Keypair::from_seed(
|
||||
seed.as_ref().try_into().map_err(|_| {
|
||||
Error::Store("generated SSH Ed25519 seed had an invalid length".to_string())
|
||||
})?,
|
||||
))
|
||||
.to_openssh(LineEnding::LF)
|
||||
.map(|key| key.to_string())
|
||||
.map_err(|err| Error::Store(format!("failed to encode generated SSH key: {err}")))
|
||||
}
|
||||
|
||||
pub fn generate_credential(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
request: GenerateRepositorySshCredentialRequest,
|
||||
actor_account_id: &str,
|
||||
) -> Result<RepositorySshCredential> {
|
||||
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
||||
let credential_id = validate_identifier("credential_id", &request.credential_id)?;
|
||||
let name = normalize_name(&request.name)?;
|
||||
let private_key = self.generated_ed25519_private_key(
|
||||
workspace_id,
|
||||
&operation_id,
|
||||
&credential_id,
|
||||
&format!("create\0{name}"),
|
||||
)?;
|
||||
self.create_credential(
|
||||
workspace_id,
|
||||
CreateRepositorySshCredentialRequest {
|
||||
operation_id,
|
||||
credential_id,
|
||||
name,
|
||||
private_key,
|
||||
passphrase: None,
|
||||
},
|
||||
actor_account_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn ensure_workspace_default_credential(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<RepositorySshCredential> {
|
||||
if let Some(credential) = self.store.with_conn(|conn| {
|
||||
read_credential(
|
||||
conn,
|
||||
workspace_id,
|
||||
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||
)
|
||||
})? {
|
||||
return Ok(credential);
|
||||
}
|
||||
self.generate_credential(
|
||||
workspace_id,
|
||||
GenerateRepositorySshCredentialRequest {
|
||||
operation_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID.to_string(),
|
||||
credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(),
|
||||
name: WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME.to_string(),
|
||||
},
|
||||
"workspace-system",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn credential_public_key(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
credential_id: &str,
|
||||
) -> Result<Option<RepositorySshPublicKey>> {
|
||||
let credential_id = validate_identifier("credential_id", credential_id)?;
|
||||
let Some((credential, private_secret, passphrase_secret)) =
|
||||
self.store.with_conn(|conn| {
|
||||
let Some(credential) = read_credential(conn, workspace_id, &credential_id)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let private_secret = read_sealed_secret(
|
||||
conn,
|
||||
workspace_id,
|
||||
&credential_id,
|
||||
credential.current_revision,
|
||||
"private_key",
|
||||
)?
|
||||
.ok_or_else(|| Error::Store("credential private key is missing".to_string()))?;
|
||||
let passphrase_secret = read_sealed_secret(
|
||||
conn,
|
||||
workspace_id,
|
||||
&credential_id,
|
||||
credential.current_revision,
|
||||
"passphrase",
|
||||
)?;
|
||||
Ok(Some((credential, private_secret, passphrase_secret)))
|
||||
})?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let private_key = zeroize::Zeroizing::new(self.unseal(
|
||||
workspace_id,
|
||||
&credential_id,
|
||||
credential.current_revision,
|
||||
"private_key",
|
||||
private_secret,
|
||||
)?);
|
||||
let passphrase = passphrase_secret
|
||||
.map(|secret| {
|
||||
self.unseal(
|
||||
workspace_id,
|
||||
&credential_id,
|
||||
credential.current_revision,
|
||||
"passphrase",
|
||||
secret,
|
||||
)
|
||||
.map(zeroize::Zeroizing::new)
|
||||
})
|
||||
.transpose()?;
|
||||
let private_key = std::str::from_utf8(private_key.as_slice())
|
||||
.map_err(|_| Error::Store("credential private key is not UTF-8".to_string()))?;
|
||||
let passphrase = passphrase
|
||||
.as_deref()
|
||||
.map(|value| std::str::from_utf8(value.as_slice()))
|
||||
.transpose()
|
||||
.map_err(|_| Error::Store("credential passphrase is not UTF-8".to_string()))?;
|
||||
let parsed = parse_private_key(private_key, passphrase).map_err(|err| {
|
||||
Error::Store(format!("stored credential private key is invalid: {err}"))
|
||||
})?;
|
||||
Ok(Some(RepositorySshPublicKey {
|
||||
credential_id,
|
||||
current_revision: credential.current_revision,
|
||||
public_key_algorithm: parsed.algorithm,
|
||||
public_key_fingerprint: parsed.fingerprint,
|
||||
public_key: parsed.public_key,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn create_credential(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -384,6 +581,11 @@ impl RepositorySecretService {
|
||||
actor_account_id: &str,
|
||||
) -> Result<RepositorySshCredential> {
|
||||
let credential_id = validate_identifier("credential_id", credential_id)?;
|
||||
if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID {
|
||||
return Err(Error::WorkspaceConfigConflict(
|
||||
"Workspace default SSH credential is immutable".to_string(),
|
||||
));
|
||||
}
|
||||
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
||||
let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?;
|
||||
let next_revision = request
|
||||
@@ -529,6 +731,11 @@ impl RepositorySecretService {
|
||||
projection: &RepositoryAccessProjection,
|
||||
) -> Result<()> {
|
||||
let credential_id = validate_identifier("credential_id", credential_id)?;
|
||||
if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID {
|
||||
return Err(Error::WorkspaceConfigConflict(
|
||||
"Workspace default SSH credential is immutable".to_string(),
|
||||
));
|
||||
}
|
||||
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
||||
let references = credential_references(projection, &credential_id);
|
||||
if !references.is_empty() {
|
||||
@@ -555,6 +762,20 @@ impl RepositorySecretService {
|
||||
"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)?;
|
||||
let deleted = tx.execute(
|
||||
"DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3",
|
||||
@@ -839,6 +1060,73 @@ impl RepositorySecretService {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn host_trusts_for_endpoint(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
hostname: &str,
|
||||
port: u16,
|
||||
) -> Result<Vec<RepositorySshHostTrust>> {
|
||||
self.store.with_conn(|conn| {
|
||||
let mut statement = conn.prepare(
|
||||
r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm,
|
||||
host_key, fingerprint, current_revision, created_at, updated_at
|
||||
FROM repository_ssh_host_trusts
|
||||
WHERE workspace_id = ?1 AND lower(hostname) = lower(?2) AND port = ?3
|
||||
ORDER BY host_trust_id"#,
|
||||
)?;
|
||||
statement
|
||||
.query_map(
|
||||
params![workspace_id, hostname, i64::from(port)],
|
||||
read_host_trust_row,
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn automatic_host_trust_id(hostname: &str, port: u16) -> String {
|
||||
let normalized = hostname
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
|
||||
character.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.take(96)
|
||||
.collect::<String>();
|
||||
format!("tofu-{normalized}-{port}")
|
||||
}
|
||||
|
||||
pub fn default_ssh_binding_for_repository(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
repository_key: &str,
|
||||
repository_uri: &str,
|
||||
) -> Result<Option<RepositorySshAccessBinding>> {
|
||||
let Some((hostname, port)) = repository_ssh_endpoint(repository_key, repository_uri)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let matches = self.host_trusts_for_endpoint(workspace_id, &hostname, port)?;
|
||||
let Some(host_trust) = matches.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if matches.len() > 1 {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Repository `{repository_key}` matches multiple SSH host trusts for {hostname}:{port}; configure an explicit Repository access binding"
|
||||
)));
|
||||
}
|
||||
self.ensure_workspace_default_credential(workspace_id)?;
|
||||
Ok(Some(RepositorySshAccessBinding {
|
||||
repository_key: repository_key.to_string(),
|
||||
credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(),
|
||||
host_trust_id: host_trust.host_trust_id.clone(),
|
||||
access: RepositoryAccessMode::ReadOnly,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn lease_ssh_materialization_access(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -1056,6 +1344,7 @@ impl RepositorySecretService {
|
||||
struct ParsedKey {
|
||||
algorithm: String,
|
||||
fingerprint: String,
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<ParsedKey> {
|
||||
@@ -1092,6 +1381,9 @@ fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<Pars
|
||||
Ok(ParsedKey {
|
||||
algorithm: public_key.algorithm().to_string(),
|
||||
fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
public_key: public_key.to_openssh().map_err(|err| {
|
||||
Error::Store(format!("failed to encode Repository SSH public key: {err}"))
|
||||
})?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1691,6 +1983,30 @@ mod tests {
|
||||
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]
|
||||
fn master_key_is_external_and_stable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -1708,6 +2024,102 @@ mod tests {
|
||||
assert!(!error.contains(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_default_credential_is_generated_once_and_immutable() {
|
||||
let (_dir, _store, service) = test_service();
|
||||
|
||||
let created = service
|
||||
.ensure_workspace_default_credential("workspace-a")
|
||||
.unwrap();
|
||||
let replayed = service
|
||||
.ensure_workspace_default_credential("workspace-a")
|
||||
.unwrap();
|
||||
let public_key = service
|
||||
.credential_public_key(
|
||||
"workspace-a",
|
||||
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created, replayed);
|
||||
assert_eq!(created.current_revision, 1);
|
||||
assert_eq!(
|
||||
public_key.public_key_fingerprint,
|
||||
created.public_key_fingerprint
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.rotate_credential(
|
||||
"workspace-a",
|
||||
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||
RotateRepositorySshCredentialRequest {
|
||||
operation_id: "rotate-default".to_string(),
|
||||
expected_revision: 1,
|
||||
private_key: test_private_key(12).0,
|
||||
passphrase: None,
|
||||
},
|
||||
"owner-a",
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.delete_credential(
|
||||
"workspace-a",
|
||||
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||
DeleteRepositorySshCredentialRequest {
|
||||
operation_id: "delete-default".to_string(),
|
||||
expected_revision: 1,
|
||||
},
|
||||
"owner-a",
|
||||
&RepositoryAccessProjection {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
config_revision: 1,
|
||||
projection_digest: "sha256:empty".to_string(),
|
||||
bindings: Vec::new(),
|
||||
},
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_credential_is_replayable_and_exposes_only_its_public_key() {
|
||||
let (_dir, _store, service) = test_service();
|
||||
let request = GenerateRepositorySshCredentialRequest {
|
||||
operation_id: "generate-one".to_string(),
|
||||
credential_id: "workspace-key".to_string(),
|
||||
name: "Workspace key".to_string(),
|
||||
};
|
||||
|
||||
let created = service
|
||||
.generate_credential("workspace-a", request.clone(), "owner-a")
|
||||
.unwrap();
|
||||
let replayed = service
|
||||
.generate_credential("workspace-a", request, "owner-a")
|
||||
.unwrap();
|
||||
let public_key = service
|
||||
.credential_public_key("workspace-a", "workspace-key")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(replayed, created);
|
||||
assert_eq!(public_key.current_revision, created.current_revision);
|
||||
assert_eq!(
|
||||
public_key.public_key_fingerprint,
|
||||
created.public_key_fingerprint
|
||||
);
|
||||
assert!(public_key.public_key.starts_with("ssh-ed25519 "));
|
||||
assert!(!public_key.public_key.contains("PRIVATE KEY"));
|
||||
assert!(
|
||||
service
|
||||
.credential_public_key("workspace-b", "workspace-key")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_create_rotate_replay_and_cross_workspace_scope_keep_secrets_write_only() {
|
||||
let (_dir, store, service) = test_service();
|
||||
@@ -1964,6 +2376,194 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_binding_resolves_unique_host_trust_for_url_and_scp_ssh_sources() {
|
||||
let (_dir, _store, service) = test_service();
|
||||
assert!(
|
||||
service
|
||||
.default_ssh_binding_for_repository(
|
||||
"workspace-a",
|
||||
"main",
|
||||
"git@example.test:org/main.git",
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let (_, host_key) = test_private_key(10);
|
||||
service
|
||||
.put_host_trust(
|
||||
"workspace-a",
|
||||
PutRepositorySshHostTrustRequest {
|
||||
operation_id: "host-default".to_string(),
|
||||
host_trust_id: "example".to_string(),
|
||||
hostname: "example.test".to_string(),
|
||||
port: 22,
|
||||
host_key,
|
||||
expected_revision: None,
|
||||
},
|
||||
"owner-a",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for uri in [
|
||||
"ssh://git@example.test/org/main.git",
|
||||
"git@example.test:org/main.git",
|
||||
] {
|
||||
let binding = service
|
||||
.default_ssh_binding_for_repository("workspace-a", "main", uri)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
binding.credential_id,
|
||||
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
||||
);
|
||||
assert_eq!(binding.host_trust_id, "example");
|
||||
assert_eq!(binding.access, RepositoryAccessMode::ReadOnly);
|
||||
}
|
||||
assert!(
|
||||
service
|
||||
.credential_public_key(
|
||||
"workspace-a",
|
||||
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||
)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let (_, second_host_key) = test_private_key(11);
|
||||
service
|
||||
.put_host_trust(
|
||||
"workspace-a",
|
||||
PutRepositorySshHostTrustRequest {
|
||||
operation_id: "host-default-second".to_string(),
|
||||
host_trust_id: "example-second".to_string(),
|
||||
hostname: "example.test".to_string(),
|
||||
port: 22,
|
||||
host_key: second_host_key,
|
||||
expected_revision: None,
|
||||
},
|
||||
"owner-a",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
service
|
||||
.default_ssh_binding_for_repository(
|
||||
"workspace-a",
|
||||
"main",
|
||||
"git@example.test:org/main.git",
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn referenced_resources_cannot_be_deleted() {
|
||||
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)?;
|
||||
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() {
|
||||
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)?;
|
||||
if parsed.scheme() == "http" {
|
||||
RepositorySourceKind::Http
|
||||
} else {
|
||||
RepositorySourceKind::Https
|
||||
}
|
||||
RepositorySourceKind::Https
|
||||
}
|
||||
scheme => {
|
||||
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 {
|
||||
let payload = serde_json::to_vec(source).expect("Repository source serializes");
|
||||
let mut hasher = Sha256::new();
|
||||
@@ -172,7 +177,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_local_file_ssh_http_and_https_sources_without_io() {
|
||||
fn parses_local_file_ssh_and_https_sources_without_io() {
|
||||
let cases = [
|
||||
("/runtime/repos/project", RepositorySourceKind::LocalPath),
|
||||
("file:///runtime/repos/project", RepositorySourceKind::File),
|
||||
@@ -184,10 +189,6 @@ mod tests {
|
||||
"git@example.test:org/project.git",
|
||||
RepositorySourceKind::Ssh,
|
||||
),
|
||||
(
|
||||
"http://git.test/org/project.git",
|
||||
RepositorySourceKind::Http,
|
||||
),
|
||||
(
|
||||
"https://git.test/org/project.git",
|
||||
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]
|
||||
fn rejects_relative_unsupported_and_credential_bearing_sources() {
|
||||
for source in [
|
||||
|
||||
@@ -397,7 +397,13 @@ mod tests {
|
||||
"1",
|
||||
i64::MAX,
|
||||
RepositorySshAccessSecret {
|
||||
private_key: "private-key-bytes".to_string(),
|
||||
credential_candidates: vec![
|
||||
worker_runtime::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-test".to_string(),
|
||||
credential_revision: 1,
|
||||
private_key: "private-key-bytes".to_string(),
|
||||
},
|
||||
],
|
||||
known_hosts_entry: "known-hosts-entry".to_string(),
|
||||
},
|
||||
)
|
||||
@@ -419,7 +425,10 @@ mod tests {
|
||||
assert!(!debug.contains("private-key-bytes"));
|
||||
assert!(debug.contains("REDACTED"));
|
||||
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap();
|
||||
assert_eq!(secret.private_key, "private-key-bytes");
|
||||
assert_eq!(
|
||||
secret.credential_candidates[0].private_key,
|
||||
"private-key-bytes"
|
||||
);
|
||||
assert!(matches!(
|
||||
broker.fetch_resource(request(handle, "runtime-test", None)),
|
||||
Err(BackendResourceError::MissingResource)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user